npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

aicontainer

v0.8.0

Published

Sandboxed devcontainer for running Claude Code, Codex, and OpenCode in bypass / auto-approve mode.

Downloads

1,029

Readme

aicontainer

npm version release rebuild License: MIT Container: GHCR Devcontainer spec

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 (.git must 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 aicontainer

That 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.fish

Then 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 too

After 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.json and execute its host-side initializeCommand before aic initialize can validate it. Never click Reopen in Container on an arbitrary repository-supplied .devcontainer/. Run aic init --force (or aic sync for an existing aicontainer project), inspect the resulting diff and project-owned override files, then use aic up or 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 model

doctor 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, fnm

The 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:

  1. Install the Dev Containers extension ms-vscode-remote.remote-containers
  2. aic init in your project (one time).
  3. Open the project folder in the editor.
  4. Cmd+Shift+PDev 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-plugins

Then run it from a Claude Code session on your host — not inside the sandbox, which mounts .devcontainer/ read-only:

/aicontainer:setup

It 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-plugins

What'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=true blocks postinstall RCE, the most common supply-chain vector. NPM_CONFIG_MIN_RELEASE_AGE=1 rejects 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/gitconfig as root: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 — via Read/Edit/Write/Grep/Glob and in Bash commands (allowing .env.example|.sample|.template|.defaults),
    • curl|sh / wget|bash fetch-and-execute in Bash (including | sudo bash, | tee | sh, and bash -c "$(curl …)" variants),
    • writes to /etc/aic/, /workspace/.devcontainer/, and the login-shell rc files (~/.zshrc / ~/.bashrc / fish config + their .local includes) — 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 in settings.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.

  • Forced AI sandbox settings — root-managed policy, not writable user config, enforces Claude permissions.defaultMode=bypassPermissions + hook, Codex approval_policy=never + sandbox_mode=danger-full-access + managed hook, and OpenCode permission."*"=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 gh token, or literal secrets from seeded tool/MCP config. You log in once inside the container; tokens persist in hardened subpaths of aic-auth-global. Because the SSH agent and ~/.ssh are absent, use aic signing for 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. bash and fish are 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 plain monospace.
  • Editor: $EDITOR=nano, $VISUAL=nano. vim is installed but isn't default.
  • Runtimes: Python 3.13 via uv; Node 24 LTS via fnm (so projects can override per-.nvmrc).
  • CLI utilities: ripgrep, fd-find, fzf, tmux, jq, gh, docker CLI (+ buildx, compose), semgrep, git-delta (wired in as core.pager and interactive.diffFilter).
  • VS Code extensions auto-installed when you open in the editor: anthropic.claude-code, openai.chatgpt, sst-dev.opencode (each gated by AIC_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-extensions and vscode-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, only http/https/mailto/vscode link schemes opened (file:// OSC 8 links suppressed to dodge microsoft/vscode#211443).
  • Misc env: PYTHONDONTWRITEBYTECODE=1, PIP_DISABLE_PIP_VERSION_CHECK=1, and GIT_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: sudo inside the container is scoped to three fixed-purpose wrappers (aic-chown-volumes, aic-lock-user-config, aic-firewall) — bare apt-get, chown, etc. are denied. To install apt packages, use a reviewed project Dockerfile (below) and aic 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-commit

Point 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.project

Run 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, use FROM aicontainer-base:latest and put only build.dockerfile: Dockerfile.project in 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 chromium

Use 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: false in playwright.config.ts (or pass --no-sandbox for ad-hoc launches) — the standard Chromium-in-Docker fix.
  • Testing localhost needs 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 to mcpServers (it's seeded from your host config like any other MCP). One recipe covers both playwright test and MCP-driven browsing.

Per-project overrides that survive aic sync

devcontainer.json and docker-compose.yml are template-managedaic init writes them and aic sync overwrites them. Tool, shell, and explicitly consented Docker modes are re-applied; hand edits are not.

aic also drops a .devcontainer/README.md into 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 edit devcontainer.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 the build: block in the override instead of editing docker-compose.yml. Compose merges build: onto the base service; aic rebuild refreshes 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/uv

Like 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-volumes fixes it on the next aic 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 install

The 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 of p10k configure).
# .devcontainer/shell-rc.zsh
alias gs='git status'
alias ll='ls -lah'
export EDITOR=nvim

Prefer 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 snippet

The 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 unprivileged vscode user 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 rebuild

Or 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).
  • node and python3 are the container's. Anything your script shells out to must exist in the sandbox — git and the usual CLI tools do; host-only binaries don't.
  • The single source of truth trick: keep the real script at ~/.config/aicontainer/statusline.mjs on the host and point your host ~/.claude/settings.json at 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 vscode user, 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 into customizations.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 like python.*) never collides. To override a key aic sets (e.g. a terminal.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, not basedpyright — Claude Code's LSP tool looks for the pyright-langserver binary, which the pyright package provides (basedpyright ships basedpyright-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 behind ENABLE_LSP_TOOL=1). The baked Node toolchain plus a global npm i -g is 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 latest

The 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 the NPM_CONFIG_MIN_RELEASE_AGE npm 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 image

The 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 rebuild print a warning (offline, no network) when this project's pinned image tag and your installed aic disagree — exactly the state you land in after npm update -g aicontainer before you've re-synced. The fix it points at is aic sync && aic rebuild. On aic rebuild it fires before the pull — handy, because rebuild re-pulls the currently pinned tag, so you can abort and aic sync first 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 upgrade tell you when a newer aicontainer has 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 to none; only users who need it re-enable it with aic sync --docker-read or aic 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 survive up/rebuild, are never written into the host home, and are removed only by the explicitly confirmed aic 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 gitconfig

aic 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: 4096 so a fork bomb or a stuck agent loop can't exhaust host PIDs. Raise it (or add mem_limit / cpus) in docker-compose.override.yml if 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-read deliberately exposes object read APIs (which can reveal unrelated container metadata, logs, and files); --docker adds 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-docker revokes them.
  • Runtime privilege: all Linux capabilities are dropped before adding the narrow helper/firewall set; NET_RAW stays absent. The raw Docker socket is reachable only below a root-owned 0700 directory 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/hooks are 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 needs aic trust or 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.json and executes its initializeCommand on the host before aic initialize can run. aic cannot prevalidate an arbitrary repo-supplied initializer. Generate/review the managed files first; for an untrusted clone, start with aic init --force, inspect overrides, and prefer aic up for 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 signing for 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 login for inside the container. Note these tokens live in the shared aic-auth-global volume, so a compromised session in any project can use every token you've logged in with — prefer a fine-grained gh PAT.

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-root map 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 IPs

The 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 rebuild from the host (this script doesn't survive container recreation).
  • Only NET_ADMIN is needed for the firewall; NET_RAW remains dropped. If IPv6 is configured but cannot be filtered, enable refuses 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