aicontainer
v0.8.0
Published
Sandboxed devcontainer for running Claude Code, Codex, and OpenCode in bypass / auto-approve mode.
Downloads
1,029
Maintainers
Readme
aicontainer
A sandboxed devcontainer for running Claude Code, Codex, and OpenCode in bypass / auto-approve mode safely across multiple projects.
Why? Auto-approve is the only way these CLIs actually fly — but pointed at your real $HOME it also lets a prompt-injected dependency read .env, exfiltrate shell history, or push through your gh token. aicontainer puts the AI behind a devcontainer boundary so you can keep auto-approve on without rebuilding your machine each time.
What you get: filesystem isolation, no Docker object access by default through a digest-pinned socket proxy, a minimal shared tool hook, an always-on cloud-metadata/link-local block, and an opt-in outbound allowlist. Host preferences are reduced to fixed safe fields before the agent sees them; logins still carry across projects. Defaults are listed in What's in the box so you know exactly what you're adopting.
Adjacent work: same shape as the Trail of Bits devcontainer, with Codex and OpenCode added, three explicit Docker-access modes, and managed shell/config startup.
What crosses the boundary
At a glance, what the in-container agent can touch on your host. Run aic
preflight in any project to print this for your actual config (it's also
shown automatically at the end of every aic up).
| Surface | Crosses the boundary? |
|---|---|
| Project directory | Yes, read-write (/workspace) — the one writable host path. |
| Host Git / Claude / Codex / OpenCode config | The raw files are visible only to a fixed, root-only, networkless one-shot sanitizer. The agent receives root-owned JSON containing allowlisted preferences; executable Git config, inline credentials, MCP env/header secrets, and security-policy fields are removed. See Config seeding. |
| Host shell startup files (.zshrc, .bashrc, fish config, p10k) and Claude statusLine | Not automatically. Shells start from root-managed aicontainer profiles and the host statusLine command is dropped, so nothing host-side plants startup code. You can opt in per file by copying it into ~/.config/aicontainer/ (rc.zsh, p10k.zsh, statusline.*); those cross verbatim and land root-locked. See Personal shell config and statusline. |
| Host home, ~/.ssh, SSH-agent socket | No — not mounted, not forwarded. |
| Host credentials (API keys, gh token, keychain) | No — nothing auto-forwarded; you log in once inside the container. |
| Package-manager caches | No — container-local volumes, not your host caches. |
| Clipboard / browser | No — nothing bridged. |
| .env* files | Blocked from the agent by the PreToolUse hook. Your project's own .env is physically in /workspace, but the hook stops the agent from reading it — defense-in-depth at the tool layer, not a missing file. |
| Session transcripts, prompt history, user-level skills/plugins/instructions | Persist in a path-unique per-project volume, never written back to your host home or shared with another checkout. See Multi-project model. |
| Host Docker daemon | Object APIs off by default: only proxy ping/version remain. --docker-read opts into sensitive object/inspect/log/file APIs; --docker opts into read-write host-daemon control for testcontainers / in-container Compose. See Threat model. |
| Outbound network | Mostly open by default. Reaches the internet and your LAN; IPv4/IPv6 link-local and common cloud-provider metadata endpoints are blocked on every create. Opt in to the allowlist to restrict the rest. |
The full reasoning is in Threat model; the network row is the one most worth your attention.
Prerequisites
- Docker Engine 25+ with Compose 2.24+ (volume-subpath support): Docker Desktop, OrbStack, or Colima, or rootless Docker Engine, using a local Unix socket. aic discovers the selected context automatically.
- Node.js 18+ (for npm and the bundled
@devcontainers/cli). - A conventional Git checkout opened at its repository root (
.gitmust be a directory so its config/hooks can be protected with exact read-only mounts).
Run aic doctor for an exact, non-mutating compatibility check.
One-time install
npm install -g aicontainerThat puts aic on your PATH and pulls @devcontainers/cli in as a dependency. To upgrade later: npm update -g aicontainer.
Try-before-install: npx aicontainer init works too, but aic up / aic shell / etc. are repeated commands — install globally once and you won't pay the npx download tax every time.
Prefer a git checkout? git clone https://github.com/stefanoginella/aicontainer ~/.aicontainer && ~/.aicontainer/install.sh still works — aic resolves its templates relative to its own location, so both layouts behave the same. With a checkout you'll also need npm install -g @devcontainers/cli separately, and aic upgrade does the git pull.
Shell completion
Optional but recommended. aic completion <shell> emits a completion script for bash, zsh, or fish — pick the line for your shell:
echo 'eval "$(aic completion bash)"' >> ~/.bashrc
echo 'eval "$(aic completion zsh)"' >> ~/.zshrc
aic completion fish > ~/.config/fish/completions/aic.fishThen reopen your shell. You'll get tab-completion for subcommands (init,
sync, up, trust, …), Docker modes (--no-docker, --docker-read,
--docker), and the --with / --shell values.
First-time auth
Authenticate once. A networkless credential bridge synchronizes only the four
known tool credential JSON files into fixed subpaths of the global
aic-auth-global volume; GitHub, npm, signing, and Semgrep use their own fixed
subpaths. Prompt-bearing config, instructions, skills, plugins, and transcripts
stay in the current project's volume, so login convenience does not turn one
project's persistent instructions into another project's.
mkdir -p ~/sandbox/scratch && cd ~/sandbox/scratch
aic init
aic up
aic shell
# Inside the container:
claude /login # OAuth flow in your host browser
codex auth login # OpenAI auth
opencode auth login # pick a provider (Anthropic, OpenAI, OpenCode Zen, …)
gh auth login # GitHub (use a fine-grained PAT if you can)
npm login # only if you publish packages — token persists tooAfter this, every aic up in any project picks up the same credentials. You do not need to re-log in.
Per-project usage
cd my-project
aic init # writes a small managed .devcontainer/ that pulls the GHCR image
aic up # pulls the version-pinned image, validates config, then starts the sandbox
aic shell # opens the configured interactive shell (zsh by default)
claude # runs in bypass mode (permissions skip)
codex # runs in auto-approve mode (sandbox off)
opencode # runs with permissions set to allow (guardrail still on)aic init defaults to pull mode: it writes devcontainer.json,
docker-compose.yml, a managed guide/ignore file, and a gitignored .env with
the selected host socket and runtime UID/GID. aic initialize refreshes that
host-only file automatically, so rootless Docker, Docker Desktop, Colima, and
OrbStack need no aicontainer-specific flags. aic up pulls the prebuilt image
from GHCR (≈30s on a warm runtime, vs. several minutes to build from scratch).
Everything else — the Dockerfile, post-create.py, firewall, and hooks — is
baked into the image.
If you want to own the build (custom apt packages, air-gapped environments, hacking on the base image), run aic init --build instead. That copies the full template — Dockerfile, post-create.py, hooks, helper scripts — into .devcontainer/, and aic up builds the image locally.
Other commands: aic run CMD ... runs a one-shot inside the container without opening a shell, and aic down stops the container without removing its volumes (resume with aic up). Full list in aic help.
Every aic-launched startup validates the managed control files and fully
resolved Compose model before handing them to Dev Containers. The generated VS
Code initializer repeats the gate before Compose creates anything. Normal
generated projects stay silent. If a project intentionally adds a custom image,
host bind, device, capability, or namespace, review the findings and run aic
trust once; the exact-config approval lives outside the repository and expires
when the relevant config changes. For a single reviewed run, use aic up
--allow-unsafe or aic rebuild --allow-unsafe. Docker daemon visibility is a
separate opt-in: use aic init --docker-read / aic sync --docker-read or the
corresponding --docker form; matching consent also lives outside the
repository.
Direct VS Code bootstrap caveat: Dev Containers must read the repository's
devcontainer.jsonand execute its host-sideinitializeCommandbeforeaic initializecan validate it. Never click Reopen in Container on an arbitrary repository-supplied.devcontainer/. Runaic init --force(oraic syncfor an existing aicontainer project), inspect the resulting diff and project-owned override files, then useaic upor reopen in VS Code.
Diagnostics and review
Three read-only commands make the boundary inspectable without starting or changing the project:
aic doctor # host prerequisites + project wiring, concise OK/WARN/FAIL
aic status # identity, modes, runtime, credential bridge, and volumes
aic validate # managed provenance + fully resolved Compose security modeldoctor can run before init; it checks Node 18+, Docker Engine 25+ and
Compose 2.24+ (needed for volume subpaths), the selected local Unix socket,
the Dev Container CLI, Git/project-root shape, install integrity, and
control-path safety. Warnings remain exit 0; blockers exit nonzero. Standard,
rootless, Colima, OrbStack, and Docker Desktop Unix-socket contexts are
discovered automatically; remote TCP/SSH contexts, subdirectories below the
Git root, and worktree-style checkouts are reported explicitly rather than
failing later with an opaque Compose error.
status is a quick factual snapshot (including credential-bridge health) and
tolerates a stopped daemon. validate is suitable for CI: it never prompts or
records trust, honors an existing exact approval, and exits nonzero for managed
drift or an untrusted boundary expansion.
Choosing tools per project
By default aic init enables Claude Code, Codex, and OpenCode. To pick a subset, either answer the interactive checkbox prompt (↑/↓ move, space toggles, enter confirms) or pass --with:
aic init --with claude-code # claude only
aic init --with codex # codex only
aic init --with opencode # opencode only
aic init --with claude-code,codex,opencode # all three (same as the default)The selection is persisted as containerEnv.AIC_TOOLS in .devcontainer/devcontainer.json. post-create.py reads it to decide which tool's settings to seed, and the VS Code extensions list is filtered to match (the anthropic.claude-code, openai.chatgpt, and sst-dev.opencode extensions are dropped when their tool isn't selected). All three CLIs are still present in the image either way — you can re-enable a tool later with aic sync --with claude-code,codex,opencode. When stdin isn't a TTY (CI, piped installers), the prompt is skipped and all tools default to on.
Choosing a shell per project
aic init also asks which interactive shell to use (or pass --shell). All three are pre-installed in the image:
aic init --shell zsh # default: oh-my-zsh + powerlevel10k + MesloLGS NF
aic init --shell bash # barebones bash, history + fnm
aic init --shell fish # barebones fish, fnmThe choice is stored as containerEnv.AIC_SHELL in .devcontainer/devcontainer.json. aic shell launches that shell, and the VS Code terminal's default profile + font family are patched to match (zsh keeps 'MesloLGS NF', monospace for p10k icons; bash/fish use plain monospace so you don't need a nerd font on the host). Change it later with aic sync --shell bash. When stdin isn't a TTY, the prompt is skipped and zsh is used.
VS Code
If you work in VS Code, you can skip aic up and aic shell after aic has
generated the control files — the editor handles both:
- Install the Dev Containers extension
ms-vscode-remote.remote-containers aic initin your project (one time).- Open the project folder in the editor.
Cmd+Shift+P→ Dev Containers: Reopen in Container.
The generated initializer runs the same host validation and volume migration as
aic up, sanitizes the host preference seeds in a networkless one-shot service,
brings up the devcontainer and socket-proxy, and drops you into an integrated
terminal. claude, codex, and opencode are available immediately. The host
aic CLI is required; the initializer checks common npm/Node-manager paths and
prints one install command if a GUI-launched editor cannot find it. Step 2 is
the host-control-plane boundary: do not directly reopen a devcontainer supplied
by an untrusted repository before aic has replaced and you have reviewed it.
You can still use aic from a separate terminal at the same time — aic rebuild, aic destroy, etc. operate on the same compose project as the editor, so the two paths don't conflict.
Claude Code plugin
Wiring aicontainer to a specific project is a handful of project-owned files — an
LSP server on PATH, a writable .venv volume, a Dockerfile.project for
Playwright, host-service env (see Per-project overrides).
The optional aicontainer Claude Code plugin does that conversationally:
it detects your stack, checks whether a headless Linux devcontainer even fits,
then proposes and writes those files for you — and offers to install the aic
CLI first if it's missing. It also handles re-setup, auditing an existing
.devcontainer/ and updating only what's missing, stale, or drifted. It's a
convenience layer over aic init / aic sync, not a requirement.
Install it from inside Claude Code:
/plugin marketplace add stefanoginella/claude-code-plugins
/plugin install aicontainer@stefanoginella-pluginsThen run it from a Claude Code session on your host — not inside the sandbox,
which mounts .devcontainer/ read-only:
/aicontainer:setupIt shows you the plan, writes the files once you approve, and stops before
pulling the multi-gigabyte image — you run aic up (or "Reopen in Container")
yourself.
Uninstall the plugin and drop the marketplace:
/plugin uninstall aicontainer@stefanoginella-plugins
/plugin marketplace remove stefanoginella-pluginsWhat's in the box
aic init ships an opinionated image. Knowing the defaults up front beats discovering them by surprise.
Security-driven defaults (don't change casually — many are the actual sandbox boundary):
npm hardening:
NPM_CONFIG_IGNORE_SCRIPTS=trueblockspostinstallRCE, the most common supply-chain vector.NPM_CONFIG_MIN_RELEASE_AGE=1rejects any package published in the last 24h (mitigates fast-moving malicious releases — npm interprets the value in days).audit=true,fund=false.Root-managed Git and shell startup: a networkless sanitizer extracts only fixed, non-executable preferences (identity, line-ending, pull/push behavior, and signing intent) from the host Git config. A narrow helper validates the generated config and installs it once at
/etc/aic/user-config/gitconfigasroot:root 0444; credential helpers, aliases, includes, hooks, remotes, and command/path-bearing settings never cross. Zsh, Bash, and fish likewise start from root-owned aicontainer profiles, never writable user or host rc files.PreToolUse hook (Claude, Codex + OpenCode, fires even with bypass/auto-approve/allow on) blocks:
- reads of
.env*files — viaRead/Edit/Write/Grep/Globand in Bash commands (allowing.env.example|.sample|.template|.defaults), curl|sh/wget|bashfetch-and-execute in Bash (including| sudo bash,| tee | sh, andbash -c "$(curl …)"variants),- writes to
/etc/aic/,/workspace/.devcontainer/, and the login-shell rc files (~/.zshrc/~/.bashrc/ fish config + their.localincludes) — defense-in-depth on top of the RO mounts and root-locks above.
One script (
/etc/aic/hooks/pre-tool-use.sh) is the single source of truth for all three tools: Claude registers it insettings.json, Codex via a managed hook, and OpenCode via a small plugin (opencode-guardrail.js) that translates its tool calls and shells out to the same script.- reads of
Forced AI sandbox settings — root-managed policy, not writable user config, enforces Claude
permissions.defaultMode=bypassPermissions+ hook, Codexapproval_policy=never+sandbox_mode=danger-full-access+ managed hook, and OpenCodepermission."*"=allow+ guardrail plugin. See Config seeding for the preference allowlist.Reduced runtime privilege: the devcontainer starts with every Linux capability dropped, then receives only the small set required by UID/GID startup, the fixed ownership helper, and its own network firewall. The raw Docker socket is hidden behind a root-only path used solely to verify named volumes; the agent talks only to the socket proxy.
Container global gitignore covers
.env*,.claude/,.codex/,node_modules/,.venv/,__pycache__/,.DS_Store— fewer ways to accidentally commit a secret.No host credential forwarding: no SSH-agent socket, API-key passthrough, host
ghtoken, or literal secrets from seeded tool/MCP config. You log in once inside the container; tokens persist in hardened subpaths ofaic-auth-global. Because the SSH agent and~/.sshare absent, useaic signingfor a sandbox-only signing key.
Developer-experience defaults (personal taste; override in Dockerfile.project if you disagree):
- Shell: defaults to
zsh+oh-my-zsh+powerlevel10k+zsh-autosuggestions+zsh-syntax-highlighting.bashandfishare also baked into the image (barebones, with history + fnm) — pick one at init time via--shell zsh|bash|fish(see Choosing a shell per project). When using zsh, p10k expects MesloLGS NF on your terminal (see Troubleshooting); bash/fish use plainmonospace. - Editor:
$EDITOR=nano,$VISUAL=nano.vimis installed but isn't default. - Runtimes: Python 3.13 via
uv; Node 24 LTS viafnm(so projects can override per-.nvmrc). - CLI utilities:
ripgrep,fd-find,fzf,tmux,jq,gh,dockerCLI (+ buildx, compose),semgrep,git-delta(wired in ascore.pagerandinteractive.diffFilter). - VS Code extensions auto-installed when you open in the editor:
anthropic.claude-code,openai.chatgpt,sst-dev.opencode(each gated byAIC_TOOLS),eamodio.gitlens,pflannery.vscode-versionlens,BracketPairColorDLW.bracket-pair-color-dlw,vincaslt.highlight-matching-tag,yzhang.markdown-all-in-one. Add your own per project (e.g. the Python or TypeScript editor stack) via.devcontainer/vscode-extensionsandvscode-settings.json. - VS Code terminal settings: default profile + font family follow the project's
AIC_SHELL(zsh →MesloLGS NF; bash/fish →monospace), right-click pastes, onlyhttp/https/mailto/vscodelink schemes opened (file://OSC 8 links suppressed to dodge microsoft/vscode#211443). - Misc env:
PYTHONDONTWRITEBYTECODE=1,PIP_DISABLE_PIP_VERSION_CHECK=1, andGIT_CONFIG_GLOBAL=/etc/aic/user-config/gitconfig(the validated root-managed config).
If you want a different baseline, see Installing extra tools for the project-Dockerfile pattern — most of the dev-experience choices can be flipped in 2-3 lines there.
Installing extra tools
Two ways, depending on whether the tool is throwaway or part of the project.
(a) Ad-hoc inside the running container
For things you'll need for an hour:
aic shell
uv tool install <python-cli> # ruff, semgrep, ...
npm install -g <node-cli>These are wiped on aic destroy or aic rebuild. Fine for exploration; not for things your project depends on.
Note:
sudoinside the container is scoped to three fixed-purpose wrappers (aic-chown-volumes,aic-lock-user-config,aic-firewall) — bareapt-get,chown, etc. are denied. To install apt packages, use a reviewed project Dockerfile (below) andaic rebuild.
(b) Persistent, in a project Dockerfile
For tools your project depends on — language runtimes, DB clients, linters teammates need too.
Create .devcontainer/Dockerfile.project:
# Match the tag your .devcontainer/docker-compose.yml pins (set by `aic init`
# to your installed aic version). This tag is project-owned, so `aic sync`
# never rewrites it — after `npm update -g aicontainer`, sync re-pins
# docker-compose.yml and WARNS that this FROM has drifted; run
# `aic sync --bump-base` to rewrite it to match. (If it drifts and an override
# build: points here, the stale base is what actually runs — see below.)
FROM ghcr.io/stefanoginella/aicontainer:vX.Y.Z
USER root
RUN apt-get update && apt-get install -y --no-install-recommends \
postgresql-client redis-tools terraform \
&& rm -rf /var/lib/apt/lists/*
USER vscode
RUN uv tool install ruff \
&& uv tool install pre-commitPoint at it from the sync-safe
.devcontainer/docker-compose.override.yml; never edit the managed Compose
file:
services:
devcontainer:
# Required: give the build its own tag. Compose names a build's output
# after whatever `image:` resolves to, and the managed Compose file
# resolves it to the SHARED ghcr.io/stefanoginella/aicontainer:vX.Y.Z —
# so without this line the build silently re-tags the base image on your
# machine and hands this project's image to every other aic project.
image: myproject-devcontainer:vX.Y.Z
build:
context: .
dockerfile: Dockerfile.projectRun aic sync, inspect the resolved warning, then aic trust once and aic
rebuild. aic up is not enough here: it reuses the cached image
(pull_policy: missing) and never runs the build, so the container comes up on
the plain base image with none of your additions. A project Dockerfile executes
as root while the image is built and can
replace any sandbox helper, so aic intentionally treats every custom build as a
host-boundary expansion even when its FROM is the official base. The trust is
for the exact relevant config hash; changing the Dockerfile or override asks for
review again. The tools then survive rebuilds and are versioned with the project.
The example's context: . is deliberately bounded to .devcontainer/, so its
contents fit the persistent trust hash. A build context outside that directory
(for example context: ..) can change through arbitrary project files; aic
trust therefore refuses to persist it, and each reviewed aic up/rebuild
must use --allow-unsafe.
If you ran
aic init --build, useFROM aicontainer-base:latestand put onlybuild.dockerfile: Dockerfile.projectin the override; the same explicit trust step applies.
Rule of thumb: if you'd be annoyed to reinstall it after every container rebuild, put it in Dockerfile.project. If you're just trying something, install ad-hoc.
Recipe: Playwright (browser tests / automation / MCP)
A browser is the one tool you can't add ad-hoc here: Chromium needs apt system libraries (and runtime sudo apt is blocked), and if you've enabled the firewall the browser-download CDN isn't on the allowlist. Dockerfile.project sidesteps both — image builds run with full network, so Chromium is baked into the image and works offline afterward, even with the firewall on.
# .devcontainer/Dockerfile.project
FROM ghcr.io/stefanoginella/aicontainer:vX.Y.Z # match your pinned tag
# Pin to the Playwright version your project uses (the @playwright/test in
# package.json), so the baked browser revision matches what resolves at runtime.
ARG PLAYWRIGHT_VERSION=1.50.0
USER root
# Chromium's system libraries. apt is root-only and runtime `sudo apt` is
# blocked, so this step has to live in the image.
RUN export PATH="$FNM_DIR:$PATH" && eval "$(fnm env)" \
&& npx --yes playwright@${PLAYWRIGHT_VERSION} install-deps chromium
USER vscode
# Download Chromium into ~/.cache/ms-playwright, baked into the image layer.
# This explicit step is REQUIRED: the base image sets NPM_CONFIG_IGNORE_SCRIPTS
# =true, so `npm i @playwright/test` won't auto-download the browser via its
# postinstall — you have to run `playwright install` yourself.
RUN export PATH="$FNM_DIR:$PATH" && eval "$(fnm env)" \
&& npx --yes playwright@${PLAYWRIGHT_VERSION} install chromiumUse the project-owned override build: block shown above, then aic sync,
aic trust, and aic rebuild.
- If Chromium won't launch, disable its sandbox. Depending on your Docker runtime's capability/seccomp setup, Chromium's own sandbox may fail to start inside the container. If you hit launch errors, set
chromiumSandbox: falseinplaywright.config.ts(or pass--no-sandboxfor ad-hoc launches) — the standard Chromium-in-Docker fix. - Testing
localhostneeds no firewall change — loopback is always allowed, so driving your own dev server works even with the allowlist enabled. Only pointing the browser at the public internet (with the firewall on) means adding hosts to.devcontainer/firewall-allowlist. - Playwright MCP (
@playwright/mcp) reuses the same baked Chromium — just add it tomcpServers(it's seeded from your host config like any other MCP). One recipe covers bothplaywright testand MCP-driven browsing.
Per-project overrides that survive aic sync
devcontainer.json and docker-compose.yml are template-managed — aic
init writes them and aic sync overwrites them. Tool, shell, and explicitly
consented Docker modes are re-applied; hand edits are not.
aicalso drops a.devcontainer/README.mdinto every project spelling out this exact managed-vs-project-owned split — it's there mostly so an AI agent poking at the devcontainer edits the right files (and learns it can't editdevcontainer.json) instead of fighting the sync. That README is itself template-managed, so don't edit it either; the project-owned files below are where customization lives.
Instead, drop a .devcontainer/docker-compose.override.yml. It's
project-owned: aic never overwrites it, automatically re-wires it on sync, and
validates the fully resolved result before any startup command. Ordinary env
and named-volume data mounts under /workspace or /home/vscode/.cache remain
frictionless. Host binds, bounded custom images/builds, devices, capabilities,
namespaces, external resources, or non-loopback published ports require an
exact-config aic trust approval stored outside the repo. A build context
outside .devcontainer/ is unbounded and instead requires --allow-unsafe on
each reviewed up/rebuild.
Literal application environment values remain prompt-free. Active Compose
$HOST_VAR / ${HOST_VAR} expressions and bare pass-through entries such as
environment: [HOST_TOKEN] require the same one-time trust because they forward
data from the host shell; validation names the variable but never prints its
resolved value. Use $${VAR} when the container should receive literal template
text. Overrides of managed startup variables (PATH, tool homes, Git/shell
config, loader variables, or DOCKER_HOST) also require review because they can
bypass the sandbox's startup and isolation wiring.
This is the right home for anything you'd otherwise have put in containerEnv or the compose service — env vars, extra_hosts, extra mounts, ports:
# .devcontainer/docker-compose.override.yml
services:
devcontainer:
environment:
# Reach a dev stack running on the HOST (Compose env outranks the mounted
# .env for libraries like pydantic-settings, so these win in-container
# while the host keeps using .env's localhost).
DATABASE_URL: postgresql://user:[email protected]:5432/mydb
VALKEY_URL: redis://host.docker.internal:6379/0
# Docker Desktop (macOS/Windows) resolves host.docker.internal automatically.
# On a Linux host, add it explicitly:
# extra_hosts:
# - "host.docker.internal:host-gateway"Run aic sync after first creating the override, inspect git diff, then aic
rebuild. If the validator reports a deliberate boundary expansion, run aic
trust after reviewing it.
The same file is also the sync-safe place to point at a
Dockerfile.project: put thebuild:block in the override instead of editingdocker-compose.yml. Compose mergesbuild:onto the base service;aic rebuildrefreshes the base image, then rebuilds your layer on top.
Persisting a named volume (and fixing its ownership)
A common override is a named volume over a build artifact you don't want on the bind-mounted workspace — a Python .venv, node_modules, a language cache — so it persists across rebuilds and dodges the macOS bind-mount performance hit:
# .devcontainer/docker-compose.override.yml
services:
devcontainer:
volumes:
- myproject-venv:/workspace/.venv
- myproject-uv-cache:/home/vscode/.cache/uv
volumes:
myproject-venv:
myproject-uv-cache:There's a catch: Docker initializes a fresh named volume as root:root, and updateRemoteUserUID only remaps the user inside the container, not the daemon's volume-init UID. So vscode can't write into the mount and your uv / npm install fails. (You can't fix this with sudo chown from inside — in-container sudo is scoped to three aic helper scripts, not general chown.)
The sync-safe fix is a project-owned .devcontainer/chown-paths — one mountpoint per line; aic-chown-volumes re-owns each to vscode on container creation:
# .devcontainer/chown-paths — re-owned to vscode on container create.
# Only paths under /workspace/ or /home/vscode/.cache/ are honored.
/workspace/.venv
/home/vscode/.cache/uvLike firewall-allowlist, this file is opt-in, survives sync, and is read-only
inside the container. The privileged helper accepts only canonical, exact
Docker named-volume mountpoints under /workspace/ or
/home/vscode/.cache/; it asks the daemon through a root-only socket for the
current container's mount metadata and rejects host binds, symlinks, nested
binds, external/custom drivers, and local volumes with bind-capable options.
Keep tool caches under ~/.cache/ so they fit this boundary.
Already created the volume root-owned from an earlier run?
aic-chown-volumesfixes it on the nextaic rebuild. If it was populated by a partial install,docker volume rm <name>once and let it re-init clean.
Project-specific post-create steps
Need to run something on every container creation — lefthook install, pre-commit install, npm ci, seeding a local DB? Don't edit devcontainer.json's postCreateCommand (clobbered every sync) or post-create.py (it's baked into the image and isn't even in your repo in pull mode). Drop a .devcontainer/post-create.project.sh:
# .devcontainer/post-create.project.sh — runs as `vscode`, cwd /workspace,
# after all aic setup, on every container create. Opt-in by presence.
#!/usr/bin/env bash
set -euo pipefail
lefthook installThe base post-create.py runs it last, after the AI tools, git config, and volume ownership are all wired up, so your steps see a fully configured environment. Like firewall-allowlist and chown-paths, it's opt-in (run only if present), never touched by aic sync, and read-only inside the container — the PreToolUse hook blocks an in-container tool from editing anything under .devcontainer/, so the script stays host-only-editable. It's invoked via bash <file> (no executable bit needed), runs with no privilege the in-container agent doesn't already have, and a non-zero exit is logged as a warning during aic up without failing container creation — its output streams through so you can see what happened.
Personal shell config
Want your familiar zsh prompt and aliases inside the sandbox? The container's ~/.zshrc is deliberately root-managed (so an in-container agent can't tamper with shell startup), so you don't edit it directly — you drop an overlay that's installed root-owned and sourced last, so your prompt/aliases win over the managed baseline (history, fnm, PATH still run first). zsh only.
Two files, both opt-in by presence, both never touched by aic sync:
.devcontainer/shell-rc.zsh— personal zsh startup: aliases, functions, exports, prompt tweaks..devcontainer/p10k.zsh— a powerlevel10k config (the output ofp10k configure).
# .devcontainer/shell-rc.zsh
alias gs='git status'
alias ll='ls -lah'
export EDITOR=nvimPrefer your host config instead of committing one per repo? Copy it into the aic-owned seed dir once and every project picks it up:
mkdir -p ~/.config/aicontainer
cp ~/.p10k.zsh ~/.config/aicontainer/p10k.zsh # your host p10k prompt
cp ~/.zsh_aliases ~/.config/aicontainer/rc.zsh # or any personal rc snippetThe host seed is read read-only by the seed sanitizer and copied verbatim; a project-owned .devcontainer/ file wins over it when both exist. Run aic sync (host-side) then aic rebuild.
⚠️ These files are copied verbatim — they can't be sanitized like the JSON configs. They're shell code, executed inside a sandbox an autonomous agent can read. Don't put secrets in them (no tokens, no
export API_KEY=…). Keep them to prompt/alias cosmetics. They run only as the unprivilegedvscodeuser and, once installed, are root-locked so the agent can't modify them.
Personal Claude Code statusline
Your host statusLine setting is not seeded — it's an arbitrary command string pointing at a host path that doesn't exist in the container (see threat model). Bringing your statusline in works like the shell overlay above: you hand aicontainer the script, and aicontainer supplies the command.
Drop one file, named by the interpreter you want:
| File | Run as |
| --- | --- |
| statusline.sh | bash |
| statusline.mjs | node (ESM) |
| statusline.js | node (CommonJS) |
| statusline.py | python3 |
Host-global (every project on this machine):
mkdir -p ~/.config/aicontainer
cp ~/.claude/statusline/statusline.mjs ~/.config/aicontainer/statusline.mjs
aic rebuildOr per-project, as .devcontainer/statusline.mjs — the project file wins when both exist. Same filename in both places (unlike rc.zsh / shell-rc.zsh).
On the next create, post-create installs the script root:root 0444 at /etc/aic/user-config/statusline/, and sets Claude's statusLine.command to the fixed managed launcher /usr/local/bin/aic-statusline, which picks the interpreter from that filename. No string from your host settings is ever executed — only your script file crosses, and the command aic writes is its own constant.
Notes that matter in practice:
- One file, no bundle. A statusline that
imports sibling modules won't find them; vendor it into a single file (your script may still read/write its own state under~/.claude/statusline/, which is precreated and per-project). nodeandpython3are the container's. Anything your script shells out to must exist in the sandbox —gitand the usual CLI tools do; host-only binaries don't.- The single source of truth trick: keep the real script at
~/.config/aicontainer/statusline.mjson the host and point your host~/.claude/settings.jsonat that same path, so host and sandbox never drift. - Claude Code only — Codex and OpenCode have no equivalent.
⚠️ Same rule as the shell overlay: copied verbatim, readable by the agent, no secrets. It runs as the unprivileged
vscodeuser, and once installed it's root-locked so the agent can't modify it — but its bytes (including any host paths or usernames baked into it) are visible inside the sandbox.
Project-specific VS Code extensions & settings
devcontainer.json is the only place that auto-installs editor extensions and applies machine-scope settings, but it's regenerated wholesale on every aic init/aic sync — so hand-editing its customizations.vscode block doesn't survive (and an in-container agent can't edit anything under .devcontainer/ at all). Two project-owned files are merged in instead, both opt-in by presence and never touched by sync:
.devcontainer/vscode-extensions— one extension id (publisher.name) per line,#comments allowed. Merged intocustomizations.vscode.extensions, so they auto-install when you reopen in the container..devcontainer/vscode-settings.json— a strict JSON object, parsed and re-serialized before merging. Invalid input is warned and skipped, and keys owned by aic are ignored with a warning; put intentional overrides in the repo's.vscode/settings.json, whose workspace scope wins.
# .devcontainer/vscode-extensions
ms-python.python
ms-python.vscode-pylance// .devcontainer/vscode-settings.json
{
"python.defaultInterpreterPath": "/workspace/.venv/bin/python"
}Run aic sync (host-side) then aic rebuild; verify with grep ms-python .devcontainer/devcontainer.json. Invalid extension lines (anything that isn't a publisher.name id) are warned about and skipped, so a stray line can't smuggle JSON into devcontainer.json.
Conflict rule: the merge lands your entries before aic's own, so on a key collision aic's default wins inside
devcontainer.json. The common case (adding new keys likepython.*) never collides. To override a key aic sets (e.g. aterminal.integrated.*value), use a standard workspace.vscode/settings.json— workspace settings beat devcontainer machine-scope settings, survive sync on their own, and aren't aic-managed.
Recipe: Python LSP (editor and agent)
There are two LSP surfaces. The editor's IntelliSense comes from the extensions + settings below. The agent's LSP tool (Claude Code's go-to-def / find-refs) is separate: it needs the pyright-langserver binary on PATH, which you install from post-create.project.sh so it survives rebuilds:
# .devcontainer/vscode-extensions
ms-python.python
ms-python.vscode-pylance # editor IntelliSense / LSP
charliermarsh.ruff # if the project uses ruff
ms-python.mypy-type-checker # if mypy is your type gate// .devcontainer/vscode-settings.json
{
"python.defaultInterpreterPath": "/workspace/.venv/bin/python",
"ruff.importStrategy": "fromEnvironment",
"mypy-type-checker.importStrategy": "fromEnvironment",
"python.analysis.typeCheckingMode": "off" // mypy is the type gate; Pylance for nav only
}# .devcontainer/post-create.project.sh — give the agent's LSP tool a Python server
command -v pyright-langserver >/dev/null || npm i -g pyright || echo "[post-create] pyright install failed"Install
pyright, notbasedpyright— Claude Code's LSP tool looks for thepyright-langserverbinary, which thepyrightpackage provides (basedpyrightshipsbasedpyright-langserver).
Recipe: Stop VS Code auto-activating a .venv in the terminal
With the Python extension installed (e.g. from the recipe above) and a project .venv, VS Code auto-activates it by typing and running source /workspace/.venv/bin/activate in every new integrated terminal. In this container the terminal mostly exists to launch AI CLIs, so that injected line lands at the prompt and clobbers the command you're starting (claude, opencode, codex). Turn it off — the first key covers the classic ms-python.python, the second the newer Python Environments extension (whose default command mode is the usual culprit):
// .devcontainer/vscode-settings.json
{
"python.terminal.activateEnvironment": false,
"python-envs.terminal.autoActivationType": "off"
}Then aic sync (host-side) and aic rebuild. This is the editor injecting and running the line — distinct from the harmless Claude Code prompt pre-fill covered in Troubleshooting, which never executes.
Recipe: TypeScript / JavaScript LSP (editor and agent)
VS Code bundles the TypeScript language service with the editor, so the editor half is mostly lint/format extensions. The agent's LSP tool wants the typescript-language-server binary on PATH:
# .devcontainer/vscode-extensions
dbaeumer.vscode-eslint
esbenp.prettier-vscode// .devcontainer/vscode-settings.json
{
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"eslint.format.enable": true
}# .devcontainer/post-create.project.sh — give the agent's LSP tool a TS server
command -v typescript-language-server >/dev/null || npm i -g typescript typescript-language-server || echo "[post-create] ts language server install failed"Claude Code's built-in LSP tool reads the binary off
PATH(on older Claude releases it's gated behindENABLE_LSP_TOOL=1). The baked Node toolchain plus a globalnpm i -gis enough — no Dockerfile change needed.
Updating AI tools
Claude Code, Codex and OpenCode refresh to their latest release on every aic
rebuild, in both pull and build mode: post-create.py runs claude update,
reruns Codex's official unattended installer, and runs opencode upgrade each
time the container is (re)created. The pinned image is just the baseline they're
layered on.
aic rebuild # in a project: recreate the container → all enabled CLIs update to latestThe refresh is fail-soft — with no network it keeps the version baked into the image
and the container still comes up. For a fully reproducible sandbox, pin the tools too
by setting AIC_FREEZE_TOOLS=1 in .devcontainer/docker-compose.override.yml.
Codex and OpenCode install via their official standalone installers (not npm), mirroring Claude's native installer. Codex refreshes by rerunning its installer noninteractively; OpenCode uses
opencode upgrade. Neither CLI is subject to theNPM_CONFIG_MIN_RELEASE_AGEnpm quarantine (which still governs npx-based MCP servers).
To update aicontainer itself — the aic CLI, the template, and the pinned base
image (base OS, Node, semgrep, hooks, …):
npm update -g aicontainer # latest aic + template
aic sync # in each project: re-pin compose to the new aic version
aic rebuild # in each project: pull the new imageThe pull-mode compose file pins ghcr.io/stefanoginella/aicontainer:vX.Y.Z to whatever aic version did aic init (or the last aic sync) — not :latest. This keeps the CLI and the in-container filesystem layout (hooks, sudoers, helper scripts) from drifting apart. To pick up a new image, bump aic and aic sync first; aic rebuild alone won't change the pinned tag.
aic nudges you when those versions drift, so a stale container doesn't go unnoticed:
aic up/aic shell/aic rebuildprint a warning (offline, no network) when this project's pinned image tag and your installedaicdisagree — exactly the state you land in afternpm update -g aicontainerbefore you've re-synced. The fix it points at isaic sync && aic rebuild. Onaic rebuildit fires before the pull — handy, becauserebuildre-pulls the currently pinned tag, so you can abort andaic syncfirst instead of re-fetching the stale image. (Build-mode projects have no pinned tag, so they aren't checked.)- VS Code "Reopen / Rebuild in Container" uses the aic-generated host-side initializer for the same drift check, fail-closed config validation, seed preparation, and legacy-session migration. Output lands in the Dev Containers output channel. A missing host CLI stops with an actionable install command because silently skipping it would also skip the security boundary. The direct-bootstrap caveat above still applies to repo-supplied control files.
aic version/aic upgradetell you when a neweraicontainerhas been published to npm. This check is cached for a day, fails silently when offline, and is skipped in CI.
Set AIC_NO_UPDATE_CHECK=1 to silence both.
:vX.Y.Z tags are immutable once published — they capture the exact image
built at release time. CI separately rebuilds the floating :latest and a
weekly tag with refreshed base layers and no stale build cache; managed
projects stay on immutable version tags. If you consciously prefer the floating
track, use a reviewed Dockerfile.project based on :latest rather than
editing the managed Compose file. In --build mode aic rebuild performs a
no-cache local build too.
The managed files under .devcontainer/ are not refreshed by aic rebuild
alone. After upgrading, run aic sync and review the diff, then rebuild. Sync
preserves tool/shell selections and a previously consented Docker mode, leaves
all project-owned files untouched, and switches modes cleanly without stale
build artifacts.
The same sync migrates old basename-scoped projects to a deterministic Compose
name derived from the canonical project path. On the next sync/up/VS Code open,
aic automatically copies the legacy transcript volume only when an existing
container's exact workspace/project labels and session mount prove ownership,
with no differently owned container also mounting it; it likewise stops only a
legacy stack with an exact owned primary and no conflicting owner or unknown
service (known unlabeled aic sidecars are allowed; arbitrary orphans are never
removed). If no container remains to disambiguate a historically shared
basename volume, sync leaves it untouched and the next interactive aic up
asks once whether to import it (default no starts fresh and preserves the old
volume). A direct VS Code or other non-interactive start stops with that same
aic up hint rather than risking cross-project transcript disclosure.
Ownership-proven upgrades need no re-login or manual volume command, and two
checkouts with the same folder name no longer collide. The legacy container-side
~/.claude-sessions layout is likewise migrated to the tool-neutral
~/.aic-sessions volume path.
Upgrade friction: for an ordinary or ownership-proven project the whole migration is still
npm update -g aicontainer && aic sync && aic rebuild. Existing logins, transcripts, and tool/shell choices carry forward. Older releases had no outside-repository Docker consent record, so their inherited Docker exposure resets once tonone; only users who need it re-enable it withaic sync --docker-readoraic sync --docker. An ambiguous historical basename volume adds the other one-time decision; clean generated projects add no prompt.
Multi-project model
Your work survives without crossing projects. Claude/Codex transcripts and prompt history, OpenCode's database/checkpoints, and user-level instruction, skill, command, rule, prompt, and plugin surfaces live in a path-unique per-project named volume (
aic-<folder>-<path-hash>_aic-sessions). They surviveup/rebuild, are never written into the host home, and are removed only by the explicitly confirmedaic destroy.
| Data | Scope | Storage |
|---|---|---|
| Claude/Codex/OpenCode login state; gh, npm and Semgrep login; sandbox signing key | Global | Fixed subpaths of aic-auth-global |
| Shell command history entered inside aicontainer | Global | aic-shell-history (host history is never mounted) |
| Claude/Codex transcripts and prompt history | Per-project | <path-unique-project>_aic-sessions |
| Claude/Codex user config, memory, skills, agents/commands, rules/prompts, plugins | Per-project | Whole tool homes below the same sessions volume |
| OpenCode config/instructions, session db, storage, and snapshots | Per-project | Same sessions volume; its two credential files are synchronized globally |
| Sanitized host preferences | Per-project, regenerated | <path-unique-project>_aic-sanitized-seed, read-only to the agent |
| Project source | Host bind | The current canonical project root at /workspace |
The unrestricted devcontainer never mounts the global Claude, Codex, or
OpenCode directories. Instead, each canonical tool home is a symlink into
~/.aic-sessions/tool-homes/ in the current project's volume. A dedicated
networkless, capability-free sidecar is the only service that sees both those
homes and the three global tool-auth subpaths. It continuously reconciles four
exact files — Claude .credentials.json, Codex auth.json, and OpenCode
auth.json / account.json — so login, logout, and token refresh propagate
without sharing any other filename.
The bridge accepts only owner-matched, single-link, bounded JSON objects,
copies them atomically as mode 0600, and ignores unsafe/malformed paths. Its
initial newer-wins reconciliation completes before the devcontainer starts.
GitHub/npm/signing/Semgrep use their own fixed global subpath mounts; the broad
auth-volume root is never exposed.
This preserves the easy part — log in once, work on twenty projects — while
removing cross-project prompt and transcript bleed. The deliberate remaining
trade-off is credentials: a compromised session in any project can use the
shared tokens you logged into inside aicontainer. Prefer least-privilege OAuth
grants and a fine-grained gh PAT. Global shell history is also readable by
every project; do not type secrets into shell commands.
Config seeding from the host
Raw host config is never mounted into the unrestricted devcontainer. Before it
starts, a fixed root-only service with no network, no workspace, a read-only
root filesystem, and no auth/session mounts reads exactly four files:
~/.gitconfig, ~/.claude/settings.json, ~/.codex/config.toml, and
~/.config/opencode/opencode.json. It emits four root-owned JSON objects into a
per-project volume; the long-running agent receives that volume read-only.
It also reads the opt-in ~/.config/aicontainer/ directory, whose fixed
filenames (rc.zsh, p10k.zsh, statusline.{sh,mjs,js,py}) are code and so
are copied verbatim rather than allowlisted — they exist only because you put
them there, and they land root-owned and read-only. Nothing else in that
directory is read.
The sanitizer copies only known preference fields and recursively removes
literal credential-bearing keys such as env, headers, authorization, API
keys, tokens, secrets, and passwords. Security-critical behavior is enforced
separately at root-managed precedence:
| Field | Container always sets |
|---|---|
| Claude permissions.defaultMode | bypassPermissions |
| Claude hook/policy | /etc/claude-code/managed-settings.json |
| Codex approval_policy | never |
| Codex sandbox_mode | danger-full-access |
| OpenCode permission | { "*": "allow" } |
| OpenCode policy/plugin | /etc/opencode/opencode.json + the shared guardrail |
Codex's hook lives in /etc/codex/requirements.toml with a root-managed hook
directory; a user hook would be trust-gated and skipped in autonomous mode.
Claude and OpenCode use their supported system-managed configuration. All three
dispatch to the same root-owned guardrail script.
Seeded preferences include Claude model/theme/editor/effort and MCP/plugin
metadata; Codex model/personality plus [features], [notice], [projects.*],
and [mcp_servers.*]; OpenCode provider/model/MCP/agent/instruction/theme/
keybind/formatter/LSP definitions; and a fixed non-executable set of Git
identity/workflow preferences.
Dropped fields include Claude permissions/hooks/auth helpers; Codex approval,
sandbox, and hooks; OpenCode permission/plugin and inline provider secrets; MCP
literal env/header credentials across all three; and every executable or
path-bearing Git key (include, aliases, credential helpers, hooks paths,
SSH commands, remotes, pagers/editors, signing-key paths). Git identity and safe
preferences are validated again before a fixed helper installs them once as
root:root 0444.
MCPs: metadata is seeded for all three tools. Host-only binaries will not exist in Linux; URL-based and npm-installed servers generally work. Literal MCP env/header credentials are deliberately removed, so authenticate inside the sandbox or reference an in-container environment variable. Claude, Codex, and OpenCode user config is project-scoped and reconciled from the sanitized seed on recreate; root-managed policy remains separate from those writable homes.
Providers (OpenCode): custom provider/model definitions carry over, but
secret-looking provider keys are stripped recursively. Run opencode auth
login inside once; that credential persists globally.
Command-bearing config is never seeded: Claude's statusLine, tool hooks,
apiKeyHelper, and the gitconfig credential.helper / aliases / includes are
all dropped rather than rewritten — an allowlist that admits one arbitrary
command string is not an allowlist. A personal statusline comes in through the
opt-in verbatim overlay instead (see Personal Claude Code
statusline): you provide the script, aic
installs it root-locked and supplies its own fixed command.
Host paths not exposed to the agent: none of the raw config files above,
host tool history/transcripts/credentials, shell rc files, ~/.ssh, or the SSH
agent. The container creates its own login state and project data in Docker
volumes. The only host files that cross verbatim are the ones you deliberately
place in ~/.config/aicontainer/ (rc.zsh, p10k.zsh, statusline.*), which
land root-owned and read-only.
Commit signing
If you sign commits on your host (commit.gpgsign=true, SSH or GPG), that won't work inside the sandbox out of the box: your signing key lives on the host, and aicontainer deliberately does not forward ~/.ssh or the SSH agent (see the threat model). Git would otherwise fail every commit with Couldn't find key in agent.
So when a signing host has no sandbox key set up, aicontainer turns signing off inside the container (commits succeed, unsigned) and prints a one-line notice — rather than letting git commit fail cryptically.
To get signed commits without weakening the boundary, provision a sandbox-only signing key with aic signing:
aic up # the container must be running
aic signing auto # mint an ed25519 signing key in the aic-auth-global volume
# (add --register to push its pubkey to GitHub via gh)
aic rebuild # apply — post-create wires the key into the container gitconfigaic signing auto prints the public key; register it on GitHub as a Signing Key (Settings → SSH and GPG keys → New → Signing Key, not Authentication), or pass --register to have gh do it (needs the write:ssh_signing_key scope). Other modes:
aic signing byok— install a separate signing key you provide (aic signing byok < your_key, or drop it at~/.config/aic-auth/signing/id_ed25519).aic signing disable— keep commits unsigned in the sandbox (silences the notice).aic signing status— show the current state.
The key lives only in the aic-auth-global volume (never on your host), is
shared across all your projects (register once), and survives rebuilds. The
choice is applied on the next aic rebuild, when
/etc/aic/user-config/gitconfig is validated and created as root-owned 0444 —
there is no live unlock/update primitive.
What this means for "Verified". The signing key lives where the AI runs, so commits the agent makes will show as Verified on GitHub. That's exactly right if your goal is satisfying a branch-protection "require signed commits" rule — but it is not a statement that a human reviewed the commit. If you want sandbox commits to stay distinguishable and independently revocable, use a distinct signing key (and optionally a distinct committer identity) for it.
Threat model
Sandboxed:
- Filesystem: the project is the only writable host path. Raw host tool/Git
config appears only in the isolated one-shot sanitizer; the agent sees its
fixed allowlisted JSON output read-only. Host home, shell startup files,
~/.ssh, agent sockets, package caches, and tool history are absent. - Process namespace: container processes don't see host processes.
- Runaway limit: the devcontainer runs with
pids_limit: 4096so a fork bomb or a stuck agent loop can't exhaust host PIDs. Raise it (or addmem_limit/cpus) indocker-compose.override.ymlif a workload needs more. - Docker daemon: by default the digest-pinned proxy exposes only ping and
version — no container/image/network/volume list, inspect, logs, archive
reads, create, or build.
--docker-readdeliberately exposes object read APIs (which can reveal unrelated container metadata, logs, and files);--dockeradds write/build and therefore permits host escape through a privileged sibling. Both opt-ins require matching consent stored outside the repository and survive sync until--no-dockerrevokes them. - Runtime privilege: all Linux capabilities are dropped before adding the
narrow helper/firewall set;
NET_RAWstays absent. The raw Docker socket is reachable only below a root-owned0700directory and is used by the fixed ownership helper for bounded GET-only mount/volume inspection. The unrestricted user uses the proxy, never that socket. - Control plane:
.devcontainer/,.git/config, and.git/hooksare read-only inside. Managed hooks, tool policy, Git config, and shell startup live under root-owned system paths. Scoped sudo exposes only fixed-purpose wrappers with hardcoded destinations; the volume helper additionally proves each target is an ordinary named volume on the current container. - Cloud metadata / link-local: every create adds strengthen-only drops for
IPv4 link-local (
169.254.0.0/16), Alibaba metadata (100.100.100.200), IPv6 link-local (fe80::/10), and AWS IMDSv6 (fd00:ec2::254). These apply independently of the optional full allowlist. - Untrusted-repo guard: aic-launched paths reject symlinked control inputs,
verify every managed artifact, resolve all Compose layers without trusting a
grep, and check services, mounts, builds, capabilities, namespaces, devices,
ports, networks/volumes, includes/providers, configs/secrets, and protected
path overlays before invoking Dev Containers. The generated initializer
repeats this gate before Compose creation. Managed drift requires
aic sync; an intentional boundary expansion needsaic trustor one-run--allow-unsafe, and repository edits cannot write either approval.
Not sandboxed (unless you opt in):
- Direct VS Code bootstrap: Dev Containers parses the repository's
devcontainer.jsonand executes itsinitializeCommandon the host beforeaic initializecan run. aic cannot prevalidate an arbitrary repo-supplied initializer. Generate/review the managed files first; for an untrusted clone, start withaic init --force, inspect overrides, and preferaic upfor the first launch. - Network: outbound is otherwise open by default — anything inside the container can reach
api.openai.com,api.anthropic.com, and your LAN (cloud metadata excepted, see above). To restrict the rest, opt in to the iptables allowlist below. - Git identity: safe identity/workflow preferences are copied from the host,
so the agent can commit as that identity and push with credentials you log
into inside. Host credential helpers and signing keys are not forwarded; use
aic signingfor a sandbox-only key. - Host credentials: nothing is auto-forwarded. The AI only has access to what you explicitly
claude /login,codex auth login,opencode auth login,gh auth loginfor inside the container. Note these tokens live in the sharedaic-auth-globalvolume, so a compromised session in any project can use every token you've logged in with — prefer a fine-grainedghPAT.
Don't run this on a network where reaching internal services is a concern — or enable the allowlist below.
Hardening the host daemon
The container's isolation is only as strong as the Docker daemon underneath it. Two choices raise the floor, especially if you enable Docker write access for a project:
- Rootless Docker (docs) or
userns-remap(docs) make container-rootmap to an unprivileged host user, so even a--privileged/host-mount escape (the thing opt-in Docker write access would expose) doesn't land as real host root. OrbStack and Colima already run the daemon in a VM, which gives you a similar boundary on macOS. - Keep Docker access at none (the default) for untrusted or prompt-injectable work. Opt into reads only when host object visibility is acceptable, and write mode only where testcontainers or host-daemon Compose is essential.
Opt-in network allowlist
For projects where you want stricter containment (reviewing untrusted code, working on a corporate LAN, paranoid about exfiltration), enable the bundled iptables allowlist from inside the container:
aic shell
sudo aic-firewall enable # apply DROP-default policy with curated allowlist
sudo aic-firewall status # inspect rules + resolved IPsThe default allowlist covers Anthropic / OpenAI / OpenCode (opencode.ai,
models.dev) / GitHub / npm / PyPI / Docker registries. Per-project extras go
in .devcontainer/firewall-allowlist (one validated domain per line, #
comments allowed). Re-run sudo aic-firewall enable after editing.
Design notes:
- The script is enable-only. Re-enabling builds inactive IPv4/IPv6 rule generations, rejects zero-result or prohibited metadata resolutions, and atomically switches them into use; the prior DROP policy remains active on any failure. Metadata chains are never flushed.
- To turn the firewall off,
aic rebuildfrom the host (this script doesn't survive container recreation). - Only
NET_ADMINis needed for the firewall;NET_RAWremains dropped. If IPv6 is configured but cannot be filtered,enablerefuses an IPv6-bypassable policy.
FAQ
Doesn't Claude Code's new auto mode make this unnecessary? No — they're complementary. Claude Code's auto mode runs a classifier that reviews each action before it executes and blocks the obviously destructive ones, which is great, but Anthropic themsel
