aidc MCP server
Commit Nov 8, 2026Provides an isolated, sandboxed development container for running Claude Code in high-risk modes with enforced network egress and local-only git.
Also known as pacepace/aidc
- Stars
- 2
- Tools
- —
- Downloads, 30 days
- —
- Latest version
- —
- Last commit
- Nov 8, 2026
- License
- MIT
What is aidc MCP?
aidc provides an isolated, sandboxed development container for running Claude Code in high-risk modes with enforced network egress and local-only git.
Summary generated from the project's README
About aidc MCP server
Developers running Claude Code in high-risk modes can use aidc to create isolated, disposable development containers. This tool provides a sandboxed environment specifically designed for the --dangerously-skip-permissions mode, ensuring that even if an agent performs unexpected actions, your host machine remains protected. It enforces strict security boundaries by using a Docker internal network with a Squid sidecar to control network egress through blocklists and state-actor TLD policies.
To maintain isolation, the server ensures git operations are local-only, preventing any pushes to remote repositories via SSH keys or GitHub tokens. It also prevents the sandboxed agent from accessing the MCP control plane. While the environment is highly restricted, it bridges essential project data like memory, scratchpads, and settings. Users manage these sessions through a CLI that supports creating, attaching, and killing containers, as well as managing port forwarding and network attachments to reach external services like databases.
What you can do with it
- 1Run high-risk agent commandsExecute Claude Code with full permissions in a container that cannot reach your host machine.
- 2Prevent accidental code pushesEnsure that an AI agent cannot push code to remote repositories during a session.
Written from the project's README. Every use case is checked against a quote from it.
README
View on GitHubaidc
A disposable, isolated dev container for running Claude Code in --dangerously-skip-permissions ("yolo") mode. Claude can do whatever it wants in here. The host can't be reached.
What's enforced
- Git is local-only. No SSH keys. No GitHub tokens. No
ghCLI. Pre-push hook hard-fails any push attempt. The container has full local git — branches, commits, rebases, stashes — but nothing leaves. - Network egress is enforced. The session bridge is a Docker
internalnetwork — no NAT, so there is no route out except through the Squid sidecar. Blocklist (URLhaus + HaGeZi-TIF, refreshed every 6h), state-actor TLD policy, Quad9 upstream. Not justHTTP_PROXY: a privileged process that strips the proxy vars and adds its own route still getsNetwork is unreachable. - Docker is isolated. DinD inside the sandbox. The host Docker daemon is unreachable. Claude can build and run images; they live and die with the session.
- The MCP control plane is off limits. A session's Squid denies the
aidc mcpserver's bind address and port (frommcp.bind_address/mcp.portwhen the session is created), so a sandboxed agent cannot reach the server that drives every session, even with a stolen token. Sessions created before this change get the rule onaidc kill+aidc create. That rule is the whole enforcement, so it holds for a session whose traffic goes through its Squid: one created with--egress direct, or attached to another network with--network, can route around it — as those options already warn at create time. - Compromise is detected and recovered. A policy sidecar tails the proxy log. A malware-list hit writes a taint flag, optionally pauses the container. Recovery is
aidc kill+aidc create, not "clean up." - Everything is audited. Squid access log, shell history, Claude session transcript, policy events — all preserved on host after
aidc kill.
What's bridged from your host
- Per-project memory (
~/.claude/projects/<encoded>/) — your conversations and memory follow the repo, read-write, the same directory the host uses. - Per-project scratchpad (
/tmp/claude-<uid>/<encoded>/) — the working files that go with those conversations (scratchpad/andtasks/), read-write at the identical path on both sides, so a session popped out to the host and back still finds them. Only this repo's subdirectory is bridged, never the whole scratchpad root — and it is a live read-write host path by design, since a one-way copy could not carry work back in. Skipped when your host uid isn't the container'svscode(1000), which currently includes macOS. See tearing down for what stays behind. settings.json— theme, model, env vars, plugins, hooks: copied into the session when it is created, not shared live. That file can carry hooks your host Claude Code runs, so a session must never be able to write it. A preference you change inside a session stays there; one you change on the host reaches sessions created afterwards. The status-line script~/.claude/statusline-command.sh, if your settings name it, is bridged read-only so the same line shows inside, with that session's own context, cost and limits.- Plugins (
~/.claude/plugins/, read-only) — what you have installed resolves and is enabled inside. - Onboarding state, seeded once from
~/.claude.json— theme, output style, and this project's trust and allowed-tools entry, so the first launch goes straight to the login prompt. Your account, API keys, MCP server definitions, and prompt history are never copied.
Claude auth is deliberately not bridged. The container owns its Claude config directory and you log in inside it once (or configure a long-lived token). See Claude auth.
So inside the container you're still you. Just sandboxed.
Install
One command (Linux and macOS). Installs the latest release after verifying its sha256 against the checksum published in the Homebrew tap:
curl -fsSL https://github.com/pacepace/aidc/releases/latest/download/install.sh | bash
Pin a version with ... | bash -s -- --version v1.0.0; --list shows what's
installable. Prefer not to pipe to bash? Download install.sh,
read it (it's short), and run it — same thing. It installs to
~/.local/share/aidc/aidc-<version>/ and symlinks ~/.local/bin/aidc;
upgrading keeps the previous version on disk for rollback (install.sh --prune
cleans up). Yes, the sandbox blocks curl | bash — for the agent inside. On
your host, you're the trusted party; the script verifies what it installs.
From source — what you want for hacking on aidc itself. make install just
symlinks scripts/aidc into ~/.local/bin; there is nothing to compile:
git clone https://github.com/pacepace/aidc.git
cd aidc
make install # symlinks scripts/aidc into ~/.local/bin
Upgrade with git pull + aidc rebuild (then aidc upgrade <session> per
session).
Or via Homebrew (macOS, or Linux if you already run Linuxbrew):
brew tap pacepace/aidc
brew install aidc
Or fully manually from a release tarball (what install.sh automates — the
sha256 to check against is in the tap's formula):
curl -fsSLO https://github.com/pacepace/aidc/archive/refs/tags/v1.0.0.tar.gz
sha256sum v1.0.0.tar.gz # compare against the formula's sha256
tar xzf v1.0.0.tar.gz && cd aidc-1.0.0
make install
However you installed it, aidc update moves the CLI to the latest release the
same way: it re-runs the installer, runs brew upgrade on the installed
formula, or fast-forwards the checkout, whichever applies. aidc update --check only reports. The CLI
and the images are separate: after an update, aidc rebuild bakes images at
the new version, then aidc upgrade <session> swaps each running session onto
them.
Requirements
- Docker (Docker Desktop on macOS/Windows, native Docker on Linux)
bash,jq,docker composeyqfor full config support (brew install yq/apt install yq)- macOS only: grant Docker Desktop access to your
~/Documentsfolder if your~/.claude/symlinks through there. System Settings → Privacy & Security → Files and Folders → Docker → Documents Folder.
Uninstall
Kill any running sessions first (aidc list then aidc kill <name> for each) so no containers or volumes are left behind, then:
# 1. Remove the CLI symlink (`make install` created it).
# Homebrew installs: `brew uninstall aidc` instead.
rm ~/.local/bin/aidc
# 2. Remove the built images
docker images --filter=reference='aidc/*' -q | xargs docker image rm
# 3. Remove preserved audit dirs (forensics kept after `aidc kill`)
rm -rf ~/aidc-audit ~/aidc-mcp-audit
If you moved audit_dir elsewhere in your config, remove that path instead of ~/aidc-audit. Deleting the cloned repo removes everything else.
Use
One repo, no siblings
aidc create my-feature --repo ~/code/some-project
aidc attach my-feature
# ... claude is already running. work happens here.
# Ctrl-b d to detach (claude keeps running)
aidc kill my-feature
Sibling repos in a shared parent
When the repo you're launching from needs to reach sibling repos — say ~/code/myorg/service-a plus everything else under ~/code/myorg/ — use --workspace:
aidc create service-a \
--profile multi \
--repo ~/code/myorg/service-a \
--workspace ~/code/myorg
The whole workspace directory is mounted at the same path inside the container. Working directory and Claude's memory dir are scoped to the repo. Sibling repos are one cd .. away.
--repo must be a subdirectory of --workspace. Without --workspace, only the repo itself is mounted.
Reaching what's running inside
Dev work happens inside the container. Testing what you built — pointing a host browser at the web app, hitting an API from a host CLI tool — that's where you need a path from your host through to the container. Two ways:
Declared ports (set at create time, ride in the compose stack, survive restart):
aidc create my-feature --port 3000 --port 8080:80
--port N shorthand publishes localhost:N → dev:N. --port H:C lets you remap. Repeatable. You can also list them in your ~/.config/aidc/config.yaml (a repo's .aidc/config.yaml needs --trust-repo-config for this; see Config):
ports:
- 3000
- "8080:80"
Adhoc ports (turn on / off live, no restart needed) — recommended for the "I just decided to expose this" case:
aidc proxy my-feature add 3000 # localhost:3000 -> dev:3000
aidc proxy my-feature add 8080:80 # host 8080 -> container 80
aidc proxy my-feature ls # show active adhoc forwards
aidc proxy my-feature rm 3000 # stop one
aidc proxy my-feature clear # stop all adhoc forwards
A tiny aidc/forwarder (alpine + socat) sidecar handles each adhoc forward. Lifecycle is session-scoped: aidc restart and aidc kill both wipe adhoc forwards (re-add explicitly after restart). Declared ports come back automatically.
aidc status <name> shows both kinds.
Reaching another stack's services (databases, queues, caches)
Port forwards go host → container. The other direction — the session needs to reach a postgres, a NATS, a redis that's already running in another compose project on the same host — is a network attachment. Point the session at that project's bridge and it resolves those containers by name.
Declared (survives restart, upgrade, and recreate):
docker network ls # find the network
aidc create api --network webapp_default # attach at create time
# or in ~/.config/aidc/config.yaml (a repo's .aidc/config.yaml needs --trust-repo-config)
networks:
- webapp_default
Repeatable, and CLI merges with config (unlike --dns, which overrides) — more sources
just means more networks. Everything is validated before any image build, so a typo costs
you a message, not a dev-base rebuild.
Adhoc (attach a session that's already running, no recreate):
aidc network api add webapp_default
aidc network api ls # marks declared vs adhoc
aidc network api rm webapp_default
Then, from inside the session:
psql -h webapp-postgres-1 -U webapp # resolves over docker DNS
aidc restart |
aidc upgrade |
aidc kill + create |
|
|---|---|---|---|
declared (--network / config) |
survives | survives | survives |
adhoc (aidc network … add) |
survives | lost | lost |
Adhoc attachments survive a restart — unlike adhoc port forwards, which aidc restart
sweeps. The difference is real: a port forward is a separate sidecar container pointed at a
container that's going away, while a network endpoint is part of the dev container's own
config and comes back with it.
This widens the sandbox, and you should size that honestly. Everything on an attached
network is reachable from the session on every port; that traffic does not go through
squid, so the blocklist doesn't apply and taint detection can't see it; and the
attachment is bidirectional. Attach the narrowest network that does the job. host, none,
and Docker's default bridge are refused outright. aidc status <name> lists current
attachments so you can see what a session can reach.
Only the dev container is attached — squid, refresher, policy, and audit stay isolated on
the session's own network, which also keeps the default route, so ordinary egress still
leaves through the proxied path instead of silently rerouting through the network you
attached. (That last part needs gw_priority, so declared attachments require Docker
Compose 2.34+; aidc create checks and tells you if yours is older.)
Reaching a database on another network (ZeroTier, VPN, LAN)
A proxied session has no route out, and squid only carries HTTP. When the session needs a postgres, a redis or an SSH host that the host can reach — over ZeroTier, a VPN, the office LAN — name that one destination:
# ~/.config/aidc/config.yaml
egress_tcp:
- db.internal.example:5432
- cache.internal.example:6379
aidc create api --egress-tcp db.internal.example:5432 # same thing, per session
aidc egress api add db.internal.example:5432 # a session that's already running
aidc egress api ls
aidc egress api rm db.internal.example:5432
Inside the session nothing changes about how you connect:
psql "postgresql://app@db.internal.example:5432/app?sslmode=require"
Each destination gets a small relay (aidc/forwarder, socat) that answers to that name on
the session network and forwards to that one address and port. The name is resolved on
the host when the relay is made, so overlay and split-horizon DNS work; if the address
changes later, rm and add it again (or recreate). TLS is end to end — the relay never
sees inside it, so sslmode=verify-full checks the real hostname as usual (if your server's
certificate names that host). Every connection is logged to a file named after the host and port (e.g. egress-db-internal-example-5432.log) in
the session's audit dir. A bare IP works too; the session then reaches it by the relay's
name, which aidc egress <s> ls shows.
aidc restart |
aidc upgrade |
aidc kill |
|
|---|---|---|---|
declared (--egress-tcp / egress_tcp:) |
survives | survives | removed; comes back on create |
live (aidc egress … add) |
survives | survives | removed |
What it opens, precisely: that address, those ports, nothing else — much narrower than
--network or --egress direct. That traffic doesn't pass through squid, so the blocklist
and taint detection don't see it; the connection log is the record. The relay refuses
aidc-mcp's own address and port, and loopback.
egress_tcp is operator-only: a repo's own .aidc/config.yaml can't open it (see
Config). A session created by an orchestrator through the MCP's session_create
never trusts the repo's config either; the names in your own egress_tcp: are then resolved
inside the aidc-mcp container, which uses the host's upstream DNS servers but not a per-link
resolver (systemd-resolved routing a ZeroTier domain, say). Such a name may not resolve
there, or may resolve to a different, public address than it does on the host. Check with
aidc egress <s> ls (it shows the address each relay forwards to), and list the IP address
instead when they differ. For a clustered database whose driver discovers other nodes (YugabyteDB
smart drivers, Cassandra), list each node, or turn discovery off (load_balance=false).
Egress is enforced, not requested
The session bridge is a Docker internal network. Docker installs no NAT for
it, so there is no route to the internet at all — squid, dual-homed onto a
separate egress network, is the only way out.
That distinction matters. Before v1.3.0 the proxy was advisory: HTTP_PROXY
pointed at squid, but env -u HTTP_PROXY curl https://example.com returned 200
and left no trace in access.log, so the blocklist didn't apply and taint
detection couldn't see it. The dev container is --privileged (it needs DinD),
so any rule inside it can be flushed by whatever is running there — enforcement
has to live where the agent can't reach. Now a privileged process that adds its
own default route, enables ip_forward, and installs its own MASQUERADE still
gets Network is unreachable.
Only squid, refresher (fetches threat feeds), policy (POSTs the taint
webhook) and the one-target socat sidecars (port forwards, TCP egress relays) sit
on the egress network. dev and audit never do.
Two deliberate ways out, both visible:
aidc create myproj --egress direct # restores the old NATed bridge
egress: direct # or in ~/.config/aidc/config.yaml
Use it only when a session needs reachability nothing narrower provides. To reach a
few services over ZeroTier/Tailscale/a VPN, name them with egress_tcp instead (above):
it keeps enforcement on. aidc create tells you plainly when enforcement is off.
The other is attaching a network (below): it grants whatever that network grants, and most compose bridges are NATed, so attaching one restores general internet egress as a side effect. aidc can't prevent that — it doesn't own that network. Detaching closes it again.
Existing sessions are not converted. aidc upgrade reuses the compose file rendered
at create time, so a session created before v1.3.0 keeps its NATed bridge — upgraded, but
still bypassable. aidc status <name> reports which posture a session is in, and
aidc upgrade warns before preserving an unenforced one. Recreate to convert:
aidc kill <name> && aidc create <name> ....
Declared --port forwards and aidc proxy still work: each becomes a small
dual-homed aidc/forwarder sidecar that publishes the host port and reaches dev
across the internal bridge, since an internal network can't publish ports itself.
Per-session DNS (overlay networks like ZeroTier / Tailscale)
By default every session resolves through Quad9 (9.9.9.9, 149.112.112.112) — a threat-intel resolver that blocks known-malware domains at the DNS layer. But sessions that need to reach hosts on an overlay network (ZeroTier-managed DNS names, Tailscale MagicDNS) need that network's resolver instead.
# CLI (order matters; first listed is tried first):
aidc create myproj --dns 10.147.17.1 --dns 9.9.9.9
# Or in ~/.config/aidc/config.yaml (a repo's .aidc/config.yaml needs --trust-repo-config):
dns_servers:
- 10.147.17.1 # ZeroTier-managed DNS
- 9.9.9.9 # fall back to Quad9
Override semantics, not merge: if you specify any DNS (CLI wins over config), it replaces the Quad9 default entirely. Resolver order must be deterministic — silently appending Quad9 behind your overlay DNS would give flaky resolution for overlay names. Want the fallback? List it explicitly, as above.
The override applies to both resolution paths: the dev container's direct lookups (database connections, ssh, dig) AND squid's resolution of proxied HTTP(S) traffic (squid does its own DNS via dns_nameservers, rewritten at container start from the same list).
Security note: overriding DNS trades away Quad9's DNS-level malware blocking for that session. The squid blocklist — the primary enforcement layer — still applies in full. You're removing one layer of defense-in-depth, not opening a hole.
Container-only directories (venv / node_modules / target)
Your host is probably one OS+arch (macOS arm64, WSL2 x86_64, whatever). The dev container is always Linux of some arch. If the container writes its Python venv into the repo's .venv/, the host then sees Linux wheels for a Linux Python interpreter — and vice versa. One side ends up broken any time you switch.
Fix: declare which paths should live inside the container only. The host's same-named directory is left untouched and invisible from the container; the container gets its own empty directory to populate.
Configure per-project in <repo>/.aidc/config.yaml, OR for a workspace with many sibling repos sharing the same needs, configure once at <workspace>/.aidc/config.yaml (both lists aggregate — workspace defines the common set, repos extend).
Python project (api/pyproject.toml with uv or poetry):
# api/.aidc/config.yaml
container_only_paths:
- .venv
- .pytest_cache
- .mypy_cache
- .ruff_cache
The container's first uv sync populates /.../api/.venv/ with linux/arm64 wheels into an overlay volume. Your host's api/.venv/ (if any) is invisible from the container; if you've never run uv sync on the host, the dir doesn't exist there at all.
Node project (web/package.json):
# web/.aidc/config.yaml
container_only_paths:
- node_modules
npm install / pnpm install inside the container writes to its own node_modules/ overlay. Native addons like better-sqlite3, sharp, esbuild get the right architecture; host's node_modules/ (if any) stays alone.
Mixed Python+Node project (Django-style: api/ has Python, frontend/ has Node, both at the top level of one repo):
# .aidc/config.yaml
container_only_paths:
- api/.venv
- frontend/node_modules
- api/.pytest_cache
- api/.mypy_cache
Monorepo with N parallel packages (e.g. packages/web/, packages/api/, packages/lib/ each with their own node_modules/):
# .aidc/config.yaml
container_only_paths:
- packages/*/node_modules
Glob patterns supported. The expansion happens at aidc create time. ** (recursive glob) is not supported — too easy to accidentally match .git/objects/. Use explicit packages/* / services/* / apps/* paths.
How it works: each path becomes a session-scoped Docker named volume (aidc-sovl-<session>-<path-slug>) mounted at that path inside the dev container. The volume shadows whatever the host has at the same path. Volumes are cleaned up by aidc kill. A fresh aidc create against the same repo gets a fresh empty overlay — so the first uv sync / npm install repopulates from upstream. (Session-scoped, not project-scoped, so concurrent sessions for the same repo never race on the same venv.)
Cleanup: if overlay volumes accumulate from botched sessions, aidc clean-env <session> (after aidc kill) or aidc clean-env --project <repo-path> clears them.
Committed venvs are your problem: if your repo somehow has .venv/ checked into git (don't do this), the container's overlay shadows the committed files from the container's view. Either gitignore properly or remove the path from container_only_paths.
Claude auth — the container logs in on its own
Each session owns its Claude config directory: CLAUDE_CONFIG_DIR=/home/vscode/.claude, on the session's dev-home volume, the same layout Anthropic's reference devcontainer uses. Credentials, .claude.json and session state live there. Nothing auth-related is bind-mounted from the host.
Why not share the host's login? Claude Code replaces .credentials.json and .claude.json by writing a new file and renaming it into place, on every refresh and every /login. Many Claude processes on one host coexist because they share the same directory: a write lock serialises refreshes and each process re-reads the file when it changes. A single-file bind mount breaks both halves. After the host's first refresh the container is left holding the old inode (anthropics/claude-code#18443), and the container's own writes fail because you cannot rename over a mount point. That is what made in-container logins expire after a few hours and account switches not stick. Sharing the whole ~/.claude directory would keep the renames working, but it hands the sandboxed container every project's memory and history, and ~/.claude.json sits at the home root outside any directory that could be shared, so the container gets its own.
Two ways in. Both are supported; pick per host.
1. Log in inside the session (default). The first launch of a new session shows Claude's login prompt:
aidc attach my-feature
aidc MCP questions
How is network security enforced in the sandbox?
Network egress is managed via a Docker internal network and a Squid sidecar that uses blocklists like URLhaus and HaGeZi-TIF to restrict outbound traffic.
Can the agent access my host's Git credentials?
No, Git is restricted to being local-only within the container, meaning no SSH keys, GitHub tokens, or the gh CLI are available.
Is the Claude authentication shared with the host?
No, Claude authentication is not bridged; the container maintains its own Claude configuration directory and requires a separate login.
What happens if malware is detected in the session?
A policy sidecar monitors proxy logs, and if a malware-list hit occurs, it writes a taint flag and can optionally pause the container.