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:
- a bash CLI to initialize the sandbox for a project, copying and customizing
the necessary files (see
cli/) - reusable proxy definitions under
cli/templates/devcontainer/sandcat/:Dockerfile.wg-client,compose-proxy.yml, andscripts/that perform the network filtering & secret substitution - template application and dev container configuration under
cli/templates/devcontainer/: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.
Sandcat is part of Visdom, VirtusLab's AI-driven software delivery infrastructure.
The CLI is a helper script and thin wrapper around docker-compose that simplifies the process of initializing and starting the sandbox.
It has two main tasks:
- copy the necessary configuration files from the
cli/templatesdirectory into your project and customize them based on your choices (development stack, etc.) - run
docker composecommands with the correct compose file automatically detected, so you don't have to remember the file names or paths.
Install sandcat CLI to ~/.local/share/sandcat/ with a launcher symlink
at ~/.local/bin/sandcat. Requires yq (Mike Farah's Go variant) already
installed on the host — see yq prerequisite below.
curl -fsSL https://raw.githubusercontent.com/VirtusLab/sandcat/master/install.sh | shEnsure ~/.local/bin is on your PATH (the installer prints a hint if it
isn't), then jump to Initialize the sandbox.
Upgrade: re-run the same command. The installer atomically swaps the
existing install; ~/.config/sandcat/ (user settings) is never touched.
Combine with SANDCAT_REF to jump to a different branch/tag/commit:
curl -fsSL https://raw.githubusercontent.com/VirtusLab/sandcat/master/install.sh | SANDCAT_REF=v1.0.0 sh
curl -fsSL https://raw.githubusercontent.com/VirtusLab/sandcat/master/install.sh | SANDCAT_REF=abc123 shCustom paths (env overrides), e.g. system-wide install:
SANDCAT_HOME=/opt/sandcat SANDCAT_BIN_DIR=/usr/local/bin \
curl -fsSL https://.../install.sh | sudo -E shNon-interactive mode (CI):
curl -fsSL https://.../install.sh | SANDCAT_NON_INTERACTIVE=true shUninstall (preserves user config and Docker state):
bash <(curl -fsSL https://raw.githubusercontent.com/VirtusLab/sandcat/master/install.sh) --uninstallEnv overrides in one place:
| Var | Default | Purpose |
|---|---|---|
SANDCAT_HOME |
$HOME/.local/share/sandcat |
Install root |
SANDCAT_BIN_DIR |
$HOME/.local/bin |
Launcher symlink dir |
SANDCAT_REF |
master |
Branch / tag / commit to fetch |
SANDCAT_NON_INTERACTIVE |
false |
Skip all prompts (CI) |
For contributors, or if you prefer to track a working tree directly:
# 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"Update via git pull in the cloned directory.
yq is required to edit compose files. Sandcat uses Mike Farah's Go yq; the unrelated Python yq (kislyuk/yq) is not compatible.
On Debian/Ubuntu, apt install yq installs the Python variant. Install Mike Farah's yq instead — for example snap install yq, or download a binary from the release page. Homebrew and Alpine apk already ship the correct one.
sandcat initThis prompts you to select the agent type, IDE (for devcontainer mode), and development stacks to install. You can also pass flags to skip prompts:
sandcat init --agent claude --ide vscode --stacks "python,node"
# With optional features (proxy TUI, 1Password integration)
sandcat init --secret-provider 1password --agent claude --ide vscodeAvailable agents:
claude(Claude Code CLI)cursor(Cursor IDE)codex(OpenAI Codex CLI — https://github.com/openai/codex)
Available stacks: node, python, java, rust, go, scala, ruby,
dotnet, zig. Versions default to LTS where available (e.g. Node.js LTS,
Java LTS 25). To change a version for a single project, add the desired
package to .devcontainer/devbox.tools.json — see Stack and tool packages
via devbox below for how tool entries
override stack defaults.
Selecting scala automatically includes java as a dependency. Stacks also
install the corresponding VS Code extension (e.g. rust-analyzer for Rust,
metals for Scala).
All packages inside the sandbox — both stack toolchains and user tools —
are managed with devbox, which resolves
them from Nix. sandcat init generates two config files side by side in
.devcontainer/:
devbox.stack.json — sandcat-managed. Regenerated on every
sandcat init from the --stacks selection plus a baseline of shell tools
every sandbox needs (fd, fzf, gh, jq, ripgrep, tmux, vim).
Do not edit by hand — your changes will be overwritten on the next init.
devbox.tools.json — user-managed. Written once with an empty
packages list; subsequent sandcat init invocations leave it untouched.
Add project-specific tools here.
At image build time the two files are merged into a single devbox global
config. devbox.tools.json wins over devbox.stack.json on:
- Same package name — the
@prefix. Putnodejs@22.5.1in tools to replace the stack'snodejs(without a specifier,nodejsrefers to lts). - Cross-family collisions — tools packages providing the same file
as a stack package win too. Put
openjdk17@latestin tools to make it the active Java over the stack'stemurin-bin-25@latest; the agent'sjava,JAVA_HOMEand the injected mitmproxy CA all resolve to the tools JDK.
Non-overriding tools entries just add to the merged config. Search available packages on nixhub.io.
Example — give the agent yq,
shellcheck, and
hyperfine by dropping them into
devbox.tools.json:
{
"packages": ["yq-go@latest", "shellcheck@latest", "hyperfine@latest"]
}Then rebuild the agent image:
sandcat run --build
# or, without starting the full stack:
docker compose -f .devcontainer/compose-all.yml build agentEvery shell inside the sandbox — including the agent's — picks up the
packages on PATH. Iterating on devbox.tools.json is the fast path:
the stack install layer stays cached and only the delta downloads
(typically seconds).
Installs are build-time only: devbox add inside the sandbox is not
supported, and no Nix download hosts are added to the network allowlist.
To pin the exact package versions across environments, commit
.devcontainer/devbox.lock next to the JSON files; the build picks it up
automatically.
Optional volume mounts (agent config, .git, .idea) are written into the
generated .devcontainer/compose-all.yml. See Customizing optional volume
mounts below. For scripted sandcat init,
set SANDCAT_* environment variables (see the CLI README).
sandcat init adds optional bind-mounts to services.agent.volumes in
.devcontainer/compose-all.yml. Each mount is an independent line — you can
enable or disable individual paths by editing that file after init. This
works the same way for Claude and Cursor; there are no per-folder sandcat init
flags today.
All-or-nothing at init time (scripted workflows only):
| Agent | Environment variable | Default |
|---|---|---|
| Claude | SANDCAT_MOUNT_CLAUDE_CONFIG |
true |
| Cursor | SANDCAT_MOUNT_CURSOR_CONFIG |
true |
| Codex | SANDCAT_MOUNT_CODEX_CONFIG |
true |
| Any | SANDCAT_MOUNT_GIT_READONLY |
false (commented in compose) |
| JetBrains | SANDCAT_MOUNT_IDEA_READONLY |
false (active when --ide jetbrains) |
| Any | SANDCAT_MOUNT_SHARED_CACHE |
true — see Shared dependency caches |
| Any | SANDCAT_GITIGNORE |
true (see Gitignore defaults) |
| Any | SANDCAT_RTK |
true (see RTK — LLM token compression) |
When an agent mount flag is false, Sandcat lists every path as a foot comment
on the first volume entry — copy the lines you want into the active volumes:
list.
Per-path tuning (recommended): edit .devcontainer/compose-all.yml, remove
or comment out mounts you do not want, then rebuild/reopen the devcontainer:
# Mount a different workspace's Cursor transcripts (not recommended):
# - ${HOME}/.cursor/projects/workspaces-other-project:/home/vscode/.cursor/projects/workspaces-other-projectDo not re-run sandcat init unless you intend to reset generated files —
it recopies the template and overwrites manual compose edits. Commit your
customized compose-all.yml to keep changes across the team.
Claude paths (host ~/.claude/, read-only when mounted):
CLAUDE.md,agents/,commands/
Cursor paths (host ~/.cursor/):
| Path | Mode | Typical use |
|---|---|---|
AGENTS.md, rules/, skills/, commands/, hooks.json, hooks/, agents/, mcp.json |
read-only | Shared customization |
projects/<workspace-id>/ |
read-write | This sandbox's transcripts/terminals |
Sandcat mounts only projects/<workspace-id>/ for the current sandbox
(workspaces-<project-name>), not the whole host projects/ tree. chats/,
plugins/, and subagents/ stay in agent-home so other workspaces' runtime
state is not exposed.
Cursor CLI keys Sandcat manages (cursor.cli in settings) are not
host-mounted — see the Cursor section below.
Project-local config (per repository, via the workspace code mount — not
controlled by SANDCAT_MOUNT_*_CONFIG):
- Claude:
.claude/in the repo (skills, agents, etc.) - Cursor:
.cursor/in the repo (rules, skills, agents,cli.json, etc.)
Use host mounts for personal defaults shared across sandboxes; use repo
.claude/ or .cursor/ for project-specific or team-shared customization.
Isolation notes: host ~/.claude/ and shared Cursor customization mounts
(rules/, skills/, etc.) are one profile per user on the machine — all
sandcat projects on that host see the same mounted trees. Cursor transcripts for
this sandbox persist under host projects/<workspace-id>/ only
(workspaces-<project-name>). Other workspaces' projects/, plus chats/,
plugins/, and subagents/, are not mounted. To keep a sandcat project fully
isolated from host agent state, set SANDCAT_MOUNT_<AGENT>_CONFIG=false and
rely on repo-local config plus the agent-home volume inside the container.
For stacks that download a lot of common dependencies, sandcat mounts a set
of host-scoped named volumes so cats-effect, spring-boot, etc.
downloaded in one project are instantly available in every other project on
the same host. Otherwise each sandbox re-downloads and re-stores the same
JAR trees inside its own agent-home, adding several GB per project.
The cache set is picked per stack — a project without a matching stack
gets no shared cache mounts at all. Today only the JVM stack (--stacks java,
also pulled in by scala) contributes cache entries; other language stacks
are not supported now.
Java/Scala cache mounts (all under /home/vscode/ in the container):
| Path | Cache for |
|---|---|
.m2/repository/ |
Maven local repository |
.cache/coursier/ |
Coursier — sbt (modern), scala-cli, Metals |
.gradle/caches/ |
Gradle dependency cache |
.gradle/wrapper/dists/ |
Gradle Wrapper distributions |
.ivy2/cache/ |
Ivy — legacy sbt (pre-Coursier resolver) |
.sbt/boot/ |
sbt bootstrap (sbt binaries + Scala compiler) |
Each is a Docker named volume with a stable host-wide name
(sandcat-cache-maven, sandcat-cache-coursier, …) declared as
external: true in the generated compose-all.yml. Multiple sandcat compose
projects reference the same physical volume, and sandcat compose down -v on
one project will not wipe caches other projects rely on. The sandcat run
wrapper creates them lazily via docker volume create (idempotent), so no
manual setup is required.
Only /home/vscode/.m2/repository/ is shared, not the whole .m2/ — user
config like settings.xml stays per-project inside agent-home. Same pattern
for .gradle/ (only caches/ and wrapper/dists/, not daemon/ or
init.d/) and .ivy2/ (only cache/, not local/ where sbt publishLocal
outputs live).
Opt out per project:
sandcat init --features no-shared-cache ... # interactive selection also
# exposes it in the menuOr set the env var before init (equivalent to the feature flag):
SANDCAT_MOUNT_SHARED_CACHE=false sandcat init ...With shared cache disabled, the mount lines stay in compose-all.yml as
comments — you can flip individual ones back on by uncommenting.
Trade-offs to be aware of:
- Shared caches break sandcat's per-project isolation model for those specific paths. If one project's build corrupts a JAR (rare — Maven and Coursier both do content-hash validation), other projects using shared cache pick up the corruption. Disable per project if you need hermetic isolation (regulated environments, security-sensitive projects).
- Two parallel builds writing the same artifact rely on the tools' own file
locking (Maven
.locks/, Coursier per-artifact.lock, Gradle.lock). This works reliably in practice but is not sandcat-mediated.
Managing shared caches:
# Detailed table — volume name, size, file count, running container users
sandcat cache list
sandcat cache # same as `list`
# Quick total across all shared-cache volumes
sandcat cache size
# Wipe one (next build re-downloads what the project needs)
sandcat cache rm sandcat-cache-maven
# Wipe them all — resets every shared cache on the host
sandcat cache rm --allsandcat cache rm refuses to remove a volume that a running sandbox
still mounts; stop the sandbox first, or pass --force to bypass the
check (Docker will then error out if the volume is truly locked).
When the project has a .git/ directory, sandcat init appends a
# Sandcat block to .gitignore (creating the file if needed) so
users don't accidentally commit files that are either regenerated on
next init or per-machine:
# Sandcat
.devcontainer/*
!.devcontainer/devbox.tools.json
.sandcat/settings.local.json
# /SandcatThe !.devcontainer/devbox.tools.json negation keeps the user-managed
tool list in git — it's the project-shared extension of the stack (see
Stack and tool packages via devbox)
and travels with the repo even though everything else under
.devcontainer/ is ignored.
The block is bracketed by # Sandcat / # /Sandcat sentinels so
sandcat can manage it symmetrically: enabling on a subsequent init is
a no-op when the block is already present, and disabling removes
the block cleanly (preserving your other rules).
Opt out if you'd rather keep the generated files in git (e.g. team
convention where each dev clones a ready-to-run devcontainer without
re-running sandcat init):
sandcat init --features no-gitignore ...
SANDCAT_GITIGNORE=false sandcat init ...Both are equivalent — the env var is the scripted counterpart of the
interactive/CSV feature flag. If a Sandcat block already exists in
.gitignore, opting out on a re-init deletes the block (and, when
the block was the file's only content, deletes the file too). Rules
outside the sandcat markers are always preserved.
If the project is not a git working tree (no .git/ directory), init
silently skips the gitignore step — no .gitignore gets created.
rtk-ai/rtk ("Rust Token Killer") wraps
shell commands invoked by AI agents and compresses their output before
the agent reads it, cutting token consumption 60-90% on typical dev
commands (test runs, grep output, build logs). sandcat init installs
the rtk binary into every sandbox by default and wires the agent
hook so the agent picks it up automatically. Setup differs slightly
per agent — see below.
Opt out if you'd rather run without it (e.g. debugging a shell command's raw output):
sandcat init --features no-rtk ...
SANDCAT_RTK=false sandcat init ...Both are equivalent — the env var is the scripted counterpart of the interactive/CSV feature flag. When disabled, the rtk binary is not installed into the image and no init hook is emitted for any agent.
Works out of the box, zero configuration. sandcat init generates an
app-user-init.sh block that runs rtk init -g --hook-only --auto-patch
on the first container start; the hook lands in the sandbox's
~/.claude/settings.json (inside the agent-home volume, not
bind-mounted). Subsequent starts are idempotent no-ops.
Cursor's rtk hook lives in ~/.cursor/hooks.json. Sandcat bind-mounts
that file read-only from your host (SANDCAT_MOUNT_CURSOR_CONFIG=true
default) so cursor customizations are shared across all your sandboxes.
Because the mount is read-only, sandcat cannot install the rtk hook
into the container's copy of hooks.json.
Setup — run once on your host:
# Install rtk locally (needed once on the host)
brew install rtk # or: curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh | sh
# Register the cursor hook in your host ~/.cursor/hooks.json
rtk init -g --hook-only --auto-patch --agent cursorThat writes the rtk hook to your host ~/.cursor/hooks.json. Every
sandcat cursor sandbox from now on bind-mounts that file into the
container, so Cursor CLI sees the hook and calls rtk hook cursor on
each Bash tool invocation. The container's own rtk binary
(installed by sandcat) executes the hook — you never need rtk on the
host for anything except this one-time init step, and you can
uninstall it afterwards if you like.
Bonus: the same host hook is picked up by every cursor sandbox you start on that machine (and by host Cursor CLI, if you use it directly).
If you skip the host init: the container prints a one-time warning
on start (sandcat: rtk hook not found for cursor. Install rtk on your host …) and Cursor CLI runs without the hook. The rtk binary is still
on PATH inside the container, so you can invoke rtk grep, rtk ls,
etc. by hand.
Sandcat installs OpenAI's Codex CLI
into every codex-agent sandbox and wires OPENAI_API_KEY through the
mitmproxy secret substitution layer. Codex reads its config from
~/.codex/config.toml (per-sandbox, agent-home volume) and picks up
the API key directly from the environment — no codex login required.
Setup:
sandcat init --agent codex --ide vscode
# Edit ~/.config/sandcat/settings.json — set secrets.OPENAI_API_KEY.value
sandcat run
codex "explain this codebase"Bash alias: codex-yolo (= codex --yolo) is available in every
codex sandbox for parity with claude-yolo.
Host config sharing (optional, default on): ~/.codex/AGENTS.md,
~/.codex/skills/, and ~/.codex/commands/ are bind-mounted read-only
from the host into the container, matching how ~/.claude/ is handled.
The rest of ~/.codex/ (config.toml, credentials, history) lives in
the container's agent-home volume — per-sandbox persistent, per-sandbox
isolated. Opt out with SANDCAT_MOUNT_CODEX_CONFIG=false.
RTK integration: works out of the box. On first container start,
sandcat seeds ~/.codex/AGENTS.md (from the host bind-mount if
present) and runs rtk init -g --codex to write ~/.codex/RTK.md
and add an @RTK.md reference to AGENTS.md. Idempotent: skipped
once the reference is already there. Disable with --features no-rtk or SANDCAT_RTK=false.
Note: because rtk needs to patch a writable AGENTS.md, sandcat
mounts the host's ~/.codex/AGENTS.md at ~/.codex-host/AGENTS.md
(a helper path) — the user-init step copies it into the writable
~/.codex/AGENTS.md. Host edits to AGENTS.md take effect after a
docker compose down -v (or manual rm inside). Skills and commands
directories are bind-mounted normally at ~/.codex/skills and
~/.codex/commands, so those live-reload as usual.
Auth model: first iteration supports OPENAI_API_KEY only.
ChatGPT sign-in (chatgpt.com / auth.openai.com) is not in the
default allowlist — users who want that flow can add the hosts to
.sandcat/settings.local.json and run codex login manually inside
the container.
CLI mode:
# Open a shell in the agent container
sandcat run
# Rebuild images first (after editing Dockerfile.app or scripts)
sandcat run --build
# Start your agent cli (e.g. claude). Because you're in a sandbox, you can use yolo mode!
# (an alias for --dangerously-skip-permissions)
claude-yoloAttaching to a running container:
If the sandbox is already running (e.g. started by VS Code's devcontainer integration or
another terminal), use attach to open an additional shell in it without starting a new
container:
sandcat attach # opens bash --login
sandcat attach <cmd> # runs <cmd> directly, e.g. sandcat attach zshUnlike sandcat run, this connects to an existing container rather than starting a fresh one.
It uses find_compose_file to locate the correct project, so it works reliably even when
multiple sandboxes are running in parallel.
compose-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 agent-specific
config bind-mounts (for example ~/.claude/* or ~/.cursor/*) forward host
customizations — remove any mount whose
source does not exist on your host.
Dockerfile.app — installs everything the sandbox needs via
devbox, a wrapper over Nix. Stack
toolchains and user tools are merged from devbox.stack.json +
devbox.tools.json into a single devbox global profile at build time —
see Stack and tool packages via devbox
for the two-file model. 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.extra_hosts— 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.dns_servers— last-wins; the highest-precedence layer that sets the key replaces the entire list (see DNS resolution).
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 — created by sandcat init on first
run):
{
"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"]
},
"GITHUB_TOKEN": {
"value": "ghp_your-token-here",
"hosts": ["github.com", "*.github.com", "*.githubusercontent.com"]
}
},
"network": [
{"action": "allow", "host": "*.github.com"},
{"action": "allow", "host": "github.com"},
{"action": "allow", "host": "*.githubusercontent.com"},
{"action": "allow", "host": "*.anthropic.com"},
{"action": "allow", "host": "*.claude.ai"},
{"action": "allow", "host": "*.claude.com"}
]
}.sandcat/settings.json (project, committed):
{
"network": [
{"action": "allow", "host": "*", "method": "GET"}
]
}.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 wildcard GET rule, then the
user's GitHub/Anthropic rules. Env and secrets come from the user file since
neither project file defines them.
Warning: the default project settings allow all GET traffic, which means the agent can read arbitrary web content — a vector for prompt injection. Stricter settings would narrow this to known service domains. Note that the user-level settings 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 commands:
sandcat edit project-settings # project network rules (.sandcat/settings.json)
sandcat edit user-settings # API keys, git identity (~/.config/sandcat/settings.json)
sandcat edit dockerfile # container Dockerfile (.devcontainer/Dockerfile.app)
sandcat edit compose # Docker Compose file (.devcontainer/compose-all.yml)After editing a settings file, restart the proxy to apply changes:
sandcat restart-proxyNote that VS Code's Rebuild Container only rebuilds the agent service — it
does not restart mitmproxy or wg-client. Use sandcat restart-proxy to
apply settings changes.
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 init creates two settings files automatically:
- User settings (
~/.config/sandcat/settings.json) — allows full access to GitHub and Anthropic/Claude, with empty API key placeholders. This is a liberal default: the agent can read arbitrary GitHub content (prompt injection vector) and push data (exfiltration vector). - Project settings (
.sandcat/settings.json) — allows all GET traffic to any host. This means the agent can read arbitrary web content, which is a prompt injection vector.
For stricter configurations, edit the settings files to limit allowed domains. Common additions for specific stacks:
| Stack | Domains |
|---|---|
| VS Code | update.code.visualstudio.com, marketplace.visualstudio.com, *.vsassets.io, main.vscode-cdn.net |
| npm | registry.npmjs.org |
| PyPI | pypi.org, files.pythonhosted.org |
| 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)
Queries that aren't refused by the network rules are resolved by a small
dnsmasq running inside wg-client, which splits traffic two ways: sibling
containers go to Docker's embedded resolver locally, everything else goes
through the WireGuard tunnel to mitmproxy and out via the configured upstream.
Top-level optional array of IPv4/IPv6 addresses. Overrides the upstream DNS
servers used by the WireGuard tunnel. Point this at a corporate/intranet
resolver to make internal hostnames (e.g. *.corp.example.com) work inside
the sandbox. Empty list, omitted, or explicit null falls back to 1.1.1.1
and 8.8.8.8. Hostnames are not accepted (resolv.conf nameserver directives
require numeric IPs); invalid entries are skipped with a warning.
{
"dns_servers": ["10.20.0.10", "10.20.0.11"]
}Higher-precedence layers replace the entire list; they are not merged. Setting
"dns_servers": null in a higher layer resets back to defaults regardless of
what a lower layer set. Run sandcat restart-proxy after editing.
The agent can resolve sibling containers on the same Docker compose network by
name (e.g. a db: service in compose.yml is reachable as db). Queries
under the compose project's network (the search domain Docker assigns to
the container) go to Docker's embedded resolver at 127.0.0.11. No
configuration is required.
The agent shares wg-client's network namespace via network_mode but Docker
still gives each container its own /etc/resolv.conf in its own mount
namespace. wg-client publishes its resolv.conf onto a shared wg-runtime
volume mounted read-only at /run/sandcat in the agent, and app-init.sh
copies it into /etc/resolv.conf on startup so the agent's lookups also go
through the local dnsmasq.
To prevent the search-domain carve-out from becoming a DNS exfiltration
channel — where an attacker-crafted name like <payload>.<project>_default
would otherwise be forwarded by Docker's embedded resolver to the host's
upstream DNS, bypassing mitmproxy — wg-client is launched with a dns: sink
(RFC 5737 192.0.2.1). Sibling names still resolve locally; anything else
under the search domain fails fast without leaving the host.
DNS inside the sandbox goes through mitmproxy to public resolvers (or the
dns_servers you configured), so names that only live in your host's
/etc/hosts or on an unroutable internal network won't resolve. Use
extra_hosts in settings.json to map hostnames to IPs; the addon writes
them to a sidecar file which wg-client splices into its /etc/hosts
inside a # sandcat extra_hosts sentinel block. The agent inherits that
file via network_mode: service:wg-client, so getent hosts <name>,
curl, mvn, Java's InetAddress etc. resolve the name via NSS before
any DNS query.
{
"extra_hosts": {
"maven-proxy.corp": "192.168.1.100",
"jira.internal": "10.0.0.50"
},
"network": [
{"action": "allow", "host": "maven-proxy.corp"},
{"action": "allow", "host": "jira.internal"}
]
}A matching network allow rule is still required. Mitmproxy enforces
network policy on the HTTP Host / TLS SNI regardless of how the name
was resolved. Without an allow rule, requests to the internal host are
blocked with 403.
Merge rules. extra_hosts is a dict merged across the three settings
layers (user / project / local); higher precedence overwrites per-key.
Validation. Invalid entries are logged as warnings and skipped; the container still starts. Requirements:
- Hostname — RFC-1123 label (letters, digits, hyphens, max 253 chars).
A trailing dot (
nexus.corp.) is stripped before validation. - IP — IPv4 or IPv6 accepted by Python's
ipaddressmodule.
IPv6 caveat. Sandcat's kill-switch drops outbound IPv6 traffic
(ip6tables -A OUTPUT -o eth0 -j DROP) so mapping a name to an IPv6
address will resolve via NSS but the connection itself will time out.
The addon logs a warning for IPv6 entries but still writes them, in case
you're using them for tools that only need name-to-address lookup (e.g.
some healthchecks) rather than actual connectivity.
Applying changes. Run sandcat restart-proxy to re-read settings —
the sentinel block in /etc/hosts is replaced atomically on each start.
Processes already running inside the agent may cache DNS results (Java
sun.net.InetAddressCachePolicy, curl connection pool, etc.) and won't
pick up the change until they reconnect or the agent is restarted.
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.
Instead of storing secret values directly in settings files, you can reference
secrets stored in 1Password using op:// references:
{
"secrets": {
"ANTHROPIC_API_KEY": {
"op": "op://Private/Anthropic API Key/credential",
"hosts": ["api.anthropic.com"]
}
}
}Each secret entry must have either "value" (plain text) or "op" (1Password
reference), not both. You can mix both styles in the same settings file.
The mitmproxy addon resolves op:// references at startup using the op CLI.
To enable 1Password during project setup, select it from the optional features
prompt, or pass the flag:
sandcat init --secret-provider 1passwordThis switches the mitmproxy service to
ghcr.io/virtuslab/sandcat-mitmproxy-op, a pre-built image that includes the
op CLI.
Authentication. The op CLI inside the container authenticates via a
1Password service account.
To set one up:
- Go to 1Password Developer Tools > Service Accounts and create a new service account
- Grant it read access to the vault(s) containing your secrets
- Add the token to
~/.config/sandcat/settings.json:
{
"op_service_account_token": "ops_...",
"secrets": {
"ANTHROPIC_API_KEY": {
"op": "op://Private/Anthropic API Key/credential",
"hosts": ["api.anthropic.com"]
}
}
}The token is read from the settings file by the mitmproxy addon at startup. If
op_service_account_token is not set in settings, the addon falls back to the
OP_SERVICE_ACCOUNT_TOKEN environment variable (forwarded from the host shell
into the container).
Secret resolution happens once at mitmproxy startup — run sandcat restart-proxy after changing 1Password items.
- The mitmproxy container mounts
~/.config/sandcat/settings.json(read-only) and the project's.sandcat/directory (read-only) alongside the addon script. The addon comes in two agent-specific variants (mitmproxy_addon_claude.py,mitmproxy_addon_cursor.py) that share their common logic via themitmproxy_addon_common.pylibrary. - 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.
Multi-line prompts. Composing a multi-line prompt with ⌘+Enter does not
work on macOS — the terminal reserves the ⌘ modifier and never transmits it
over the PTY, so sandcat attach (and Claude Code) only ever receive a plain
Enter. This is not sandcat-specific and cannot be fixed inside the container.
Use one of these instead:
\thenEnter— inserts a newline in any terminal with no setup. The simplest option.Option+Enter— Claude Code's macOS default. In Apple Terminal, first enable Settings → Profiles → Keyboard → Use Option as Meta key; iTerm2 sends it out of the box.Shift+Enter— the most familiar combination, but Claude Code only receives whatever bytes the terminal chooses to send for it, so it needs a one-time mapping in the host terminal. Claude Code's/terminal-setupis meant to install this, but it has two traps in this setup: it configures the host terminal, so running it from Claude Code inside the sandbox does nothing; and it caches an "installed" flag, so a second run reports "already enabled" even when the terminal was never actually changed. The reliable route is to map the key by hand:-
iTerm2 — Settings → Keys → Key Bindings →
+, recordShift+Enter, choose Send Hex Codes and enter0x1b 0x0d(this isOption+Enter, which Claude Code treats as a newline). GUI bindings take effect immediately. To confirm it worked, runcat -vin the sandbox shell and pressShift+Enter: it should print^[instead of a blank line. -
VS Code integrated terminal — add to
keybindings.json:{ "key": "shift+enter", "command": "workbench.action.terminal.sendSequence", "args": { "text": "\u001b\r" }, "when": "terminalFocus" }
-
Cursor CLI support is available via sandcat init --agent cursor.
- The current template uses temporary compatibility defaults for auth/network:
-
Auth passthrough via placeholder substitution. The container sees only
SANDCAT_PLACEHOLDER_CURSOR_API_KEY; the realCURSOR_API_KEYis injected by the mitmproxy addon only for allowed Cursor hosts. -
HTTP/1 compatibility bootstrap. On startup, Sandcat forces
.network.useHttp1ForAgent = truein Cursor CLI config to avoid known proxy/TLS instability with HTTP/2 streaming through mitmproxy. -
Proxy command defaults tuned for Cursor. The generated proxy config uses the Cursor addon and keeps mitmproxy HTTP/2 enabled (
http2=true) (plus streaming-safe mitmproxy flags such asstream_large_bodies=1m,connection_strategy=lazy,anticomp=true, andtimeout_read=300).Those streaming-safe flags are Cursor-only — they are intentionally omitted on the Claude path (
sct_agent_mitm_streaming_flags). Withstream_large_bodiesunset, mitmproxy buffers request bodies up to ~1 MB before forwarding, which lets the addon's_substitute_secretsrun a body-content scan for placeholder leaks. Setting them on Claude would weaken that defence-in-depth check; on Cursor they are required to keep Connect/HTTP-2 streaming responses stable, and the body-leak check is instead enforced via header/URL scans plus the textual-only body-mutation gate (binary protobuf bodies are left untouched). -
Streaming detection is path-only. The Cursor addon decides whether a request is streaming purely from the request path (
/agent.v1.AgentService/Run*,/aiserver.v1.RepositoryService/...). A client-suppliedcontent-type: application/connect+protoheader alone is not sufficient — accepting it would let any request with the right header bypass body substitution and the placeholder leak check. These defaults are conservative and may be relaxed when Cursor proxy behavior is consistently stable across environments.
-
- Authentication: put the Cursor API key in
secrets.CURSOR_API_KEYin Sandcat settings (not incursor.cli). The agent container receives onlySANDCAT_PLACEHOLDER_CURSOR_API_KEYviasandcat.env; mitmproxy substitutes the real key on allowed Cursor hosts (see placeholder substitution above). Do not useagent loginin the sandbox unless you accept that Cursor may store session state under agent-home outside Sandcat's placeholder model. - Cursor CLI settings via Sandcat: add a
cursor.cliblock to~/.config/sandcat/settings.json(or project.sandcat/settings.json) using the same JSON shape as Cursor's globalcli-config.json(permissions, model, network flags — not API keys). Sandcat merges settings layers at mitmproxy startup, writes/mitmproxy-config/cursor-cli-config.json, and the agent deep-merges that fragment intocli-config.jsonin agent-home on each start. Sandcat-owned keys win; other Cursor-written keys in that file (model choice, permissions allow/deny lists, etc.) are preserved. The Cursor user template defaults includecursor.cli.network.useHttp1ForAgent: truefor mitmproxy stability. SANDCAT_MOUNT_CURSOR_CONFIG=truemounts host Cursor config into the agent container. Customization paths are read-only:AGENTS.md,rules/,skills/,commands/,hooks.json,hooks/,agents/, andmcp.json. Runtime state for this sandbox is read-write on the host underprojects/<workspace-id>/only (workspaces-<project-name>— agent transcripts, terminals, MCP session state).chats/,plugins/, andsubagents/are not host-mounted (they live inagent-home). Onsandcat init, missing bind sources are pre-created on the host (directories viamkdir, JSON files with minimal valid defaults, markdown files empty) so Docker mounts a file instead of materialising a root-owned directory.- Config precedence:
~/.config/sandcat/settings.jsongoverns network allowlists, secret substitution (mitmproxy), and Sandcat-managed Cursor CLI settings (cursor.cli— not credentials). Host Cursor customization mounts are read-only user config. The workspace-scopedprojects/<workspace-id>/mount is read-write on the host. MCP servers inmcp.jsonstill need matching mitmproxy allowlist entries before they can reach the network from the sandbox. - Cursor CLI TLS through mitmproxy. The Cursor CLI bundles its own Node.js
binary with compiled-in Mozilla CA roots. Sandcat sets
NODE_OPTIONS=--use-openssl-caso the bundled Node.js uses the system CA store (which includes the mitmproxy CA) instead of its built-in roots. When Cursor honors that environment setting, mitmproxy can intercept Cursor API traffic and performSANDCAT_PLACEHOLDER_CURSOR_API_KEYsubstitution transparently. - Provider-specific onboarding/bootstrap logic is intentionally minimal in this first iteration and can be extended in project-level Dockerfile/scripts.
flowchart LR
agent["<b>agent</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"))
agent -- "network_mode:<br/>shares net namespace" --- wg
wg -- "WireGuard<br/>tunnel" --> mitm
mitm -- "allowed<br/>requests" --> inet
style agent 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>agent-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
agent["agent"] -- "read-only" --> mc
agent -- "read-write" --> ah
settings -. "bind-mount" .-> mitm
projsettings -. "bind-mount" .-> mitm
claude -. "bind-mount" .-> agent
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.agent-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/) and Cursor host config (~/.cursor/*— see Cursor section above) are bind-mounted from the host when enabled incompose-all.yml. Per-path toggles are described in Customizing optional volume mounts.
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 agent
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.
The proxy mode is chosen during sandcat init via the optional features prompt,
or with --features tui / --proxy tui. Both modes run sandcat proxy from the host, in the project
directory.
Web UI (default, --proxy web) — opens a browser-based interface:
sandcat proxy # prints the mitmweb URL and password
sandcat compose port mitmproxy 8081 # or look up the port manuallyLog in with password mitmproxy.
Console (--proxy tui) — uses mitmdump to log flows as text:
sandcat proxy # tails the mitmdump log (Ctrl+C to stop)Useful in terminal-only environments (SSH sessions, remote servers) or when a browser adds overhead.
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 statusMitmproxy addon errors (network rule violations, secret resolution failures) are written to stderr, visible via:
sandcat compose logs mitmproxyIn web mode, the mitmweb UI event log (accessible from the browser) also shows addon warnings.
op read failures (1Password). If an op:// reference can't be resolved
(wrong vault name, missing item, auth error), the addon logs a warning and sets
the secret value to empty. The container continues running — other secrets still
work. Check the logs to see the specific error:
sandcat compose logs mitmproxy | grep WARNINGContainer won't start. If mitmproxy exits immediately, check its logs
first. Common causes:
- Settings JSON syntax error — the addon can't parse the file
- Missing settings file mount — verify
~/.config/sandcat/settings.jsonexists
Secrets not substituted. If requests fail with auth errors even though secrets are configured:
- Verify the secret has a non-empty value:
sandcat compose logs mitmproxyshows how many secrets were loaded at startup - Check that the destination host matches the secret's
hostsallowlist - Run
sandcat restart-proxyafter editing settings — the addon only reads settings at startup
No network inside the container on some Wi-Fi networks. If the sandbox has
no connectivity on one network but works on another, the network is likely
blocking outbound DNS (port 53) to the public resolvers sandcat uses by default
(1.1.1.1, 8.8.8.8). This is common on corporate, hotel, and guest Wi-Fi,
which force their own resolver. The symptom is DNS-only: name lookups fail
inside the container while the host still browses fine (the host uses the
network's resolver; the container does not).
Fix: point the container at the network's own resolver via
dns_servers. First find the resolver IP:
# macOS
scutil --dns | awk '/nameserver\[0\]/ {print $3; exit}'
# Linux (systemd-resolved)
resolvectl status | awk '/Current DNS Server/ {print $4; exit}'
# Fallback: your default gateway is often the resolver
# macOS: route -n get default | awk '/gateway/{print $2}'
# Linux: ip route | awk '/default/{print $3; exit}'Then set it in .sandcat/settings.local.json (local, not committed) — or
~/.config/sandcat/settings.json to cover all projects:
{ "dns_servers": ["10.0.0.1"] }The value is network-specific; update or remove it when you change networks.
Run sandcat restart-proxy to apply it (or sandcat run if the sandbox isn't
started yet).
CA certificate issues. If you see TLS errors inside the container, the mitmproxy CA may not be trusted. See TLS and CA certificates for runtime-specific configuration.
Python tests (mitmproxy addon):
pytest cli/test/mitmproxy/test_mitmproxy_addon.py -vBATS tests (CLI):
cd cli && ./run-tests.bashSandcat 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_CERTSandNODE_OPTIONS=--use-openssl-caautomatically. The--use-openssl-caflag is required for tools that bundle their own Node.js binary (e.g. Cursor CLI) whereNODE_EXTRA_CA_CERTSalone may not be honored. If you write a custom entrypoint, make sure to include both 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. The exception is
uv, which is itself a Rust binary and (like other
rustls-based tools) bundles its own root CAs. Thepythonstack setsUV_SYSTEM_CERTSon the agent service automatically souvreads the system store instead.
Start the container from the command line:
sandcat runAttach a shell to an already-running container:
sandcat attachTear 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.