https://github.com/stefanoginella/aicontainer
Sandboxed devcontainer for running Claude Code and Codex in bypass / auto-approve mode.
https://github.com/stefanoginella/aicontainer
ai claude-code codex devcontainer docker sandbox vibe-coding vibecoding
Last synced: 1 day ago
JSON representation
Sandboxed devcontainer for running Claude Code and Codex in bypass / auto-approve mode.
- Host: GitHub
- URL: https://github.com/stefanoginella/aicontainer
- Owner: stefanoginella
- License: mit
- Created: 2026-05-22T14:30:43.000Z (10 days ago)
- Default Branch: main
- Last Pushed: 2026-05-25T21:24:49.000Z (7 days ago)
- Last Synced: 2026-05-25T23:06:39.917Z (7 days ago)
- Topics: ai, claude-code, codex, devcontainer, docker, sandbox, vibe-coding, vibecoding
- Language: Shell
- Homepage: https://www.npmjs.com/package/aicontainer
- Size: 257 KB
- Stars: 6
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Agents: AGENTS.md
Awesome Lists containing this project
README
# aicontainer
[](https://www.npmjs.com/package/aicontainer)
[](https://github.com/stefanoginella/aicontainer/actions/workflows/release.yml)
[](https://github.com/stefanoginella/aicontainer/actions/workflows/rebuild.yml)
[](LICENSE)
[](https://github.com/stefanoginella/aicontainer/pkgs/container/aicontainer)
[](https://containers.dev/)
A sandboxed devcontainer for running [Claude Code](https://claude.ai/code) and [Codex](https://github.com/openai/codex) 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, a filtered Docker socket via [Tecnativa's docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy), a minimal PreToolUse hook, and an **opt-in** iptables outbound allowlist. No AI-generated config, no per-project re-login. Defaults are listed in [What's in the box](#whats-in-the-box) so you know exactly what you're adopting.
> Adjacent work: same shape as the [Trail of Bits devcontainer](https://github.com/trailofbits/claude-code-devcontainer), with Codex added, Docker access turned on by default, and host shell look-and-feel preserved.
## 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. |
| `~/.gitconfig`, `~/.p10k.zsh`, `~/.zshrc.local` | Read-only (shell look-and-feel). |
| `~/.claude/settings.json`, `~/.codex/config.toml` | Read-only **seed** — an allowlisted subset of fields; security-critical ones are force-overridden. See [Config seeding](#config-seeding-from-the-host). |
| 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](#whats-in-the-box). 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 | Persist in a **per-project named volume**, never written back to your host home. See [Multi-project model](#multi-project-model). |
| **Outbound network** | **Yes — fully open by default.** Reaches the internet, your LAN, and cloud metadata (`169.254.169.254`). Opt in to the [iptables allowlist](#opt-in-network-allowlist) to restrict it. |
The full reasoning is in [Threat model](#threat-model); the network row is the
one most worth your attention.
## Prerequisites
- Docker runtime: [Docker Desktop](https://docker.com/products/docker-desktop), [OrbStack](https://orbstack.dev/), or [Colima](https://github.com/abiosoft/colima).
- Node.js 18+ (for npm and the bundled `@devcontainers/cli`).
## One-time install
```bash
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 ` emits a completion script for **bash**, **zsh**, or **fish** — pick the line for your shell:
```bash
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`, …), their flags (`--build`, `--force`, `--with`, `--pull`, `--shell`), and the `--with` / `--shell` values (`claude-code`, `codex`, `claude-code,codex` and `zsh`, `bash`, `fish`).
## First-time auth
Authenticate once. Tokens land in the global `aic-auth-global` volume and are reused across every project.
```bash
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
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
```bash
cd my-project
aic init # writes a 2-file .devcontainer/ that pulls the GHCR image
aic up # pulls ghcr.io/stefanoginella/aicontainer:vX.Y.Z (pinned to your aic version), starts container + socket-proxy
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)
```
`aic init` defaults to **pull mode**: it drops in only `devcontainer.json` and `docker-compose.yml`, and `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`, the firewall script, 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`.
### Choosing tools per project
By default `aic init` enables both Claude Code and Codex. To pick a subset, either answer the interactive checkbox prompt (↑/↓ move, space toggles, enter confirms) or pass `--with`:
```bash
aic init --with claude-code # claude only
aic init --with codex # codex only
aic init --with claude-code,codex # both (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` and `openai.chatgpt` extensions are dropped when their tool isn't selected). Both CLIs are still present in the image either way — you can re-enable a tool later with `aic sync --with claude-code,codex`. When stdin isn't a TTY (CI, piped installers), the prompt is skipped and both 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:
```bash
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` entirely — 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+P` → **Dev Containers: Reopen in Container**.
The editor builds the image, brings up the compose stack (devcontainer + socket-proxy), runs `postCreateCommand`, and drops you into an integrated terminal that's already inside the container. `claude` and `codex` are available immediately.
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.
## 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`.
- **Locked config + shell rc**: `~/.gitconfig.local` is chowned `root:root 0444` after first run, so a compromised AI session can't inject `credential.helper` or `core.sshCommand` to capture tokens during in-container `git push` / `gh` flows. The baked login-shell rc files (`~/.zshrc`, `~/.bashrc`, fish config) are root-locked the same way, so a session can't plant a payload that runs on the next `aic shell`. Host `~/.gitconfig` is included read-only.
- **PreToolUse hook** (Claude + Codex, fires even with bypass/auto-approve 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.
- **Forced AI sandbox settings** — host config can't loosen these: Claude `permissions.defaultMode=bypassPermissions` + hook registration, Codex `approval_policy=never` + `sandbox_mode=danger-full-access` + hook registration. See [Config seeding from the host](#config-seeding-from-the-host) for the full allowlist/dropped fields.
- **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, no `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` passthrough, no host `gh` token. You log in once *inside* the container; tokens persist in `aic-auth-global`.
**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](#choosing-a-shell-per-project)). When using zsh, p10k expects [MesloLGS NF](https://github.com/romkatv/powerlevel10k#meslo-nerd-font-patched-for-powerlevel10k) 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`](https://github.com/astral-sh/uv); Node 24 LTS via [`fnm`](https://github.com/Schniz/fnm) (so projects can override per-`.nvmrc`).
- **CLI utilities**: `ripgrep`, `fd-find`, `fzf`, `tmux`, `jq`, `gh`, `docker` CLI (+ buildx, compose), `semgrep`, [`git-delta`](https://github.com/dandavison/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` (each gated by `AIC_TOOLS`), `eamodio.gitlens`, `pflannery.vscode-versionlens`, `BracketPairColorDLW.bracket-pair-color-dlw`, `vincaslt.highlight-matching-tag`, `yzhang.markdown-all-in-one`.
- **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](https://github.com/microsoft/vscode/issues/211443)).
- **Misc env**: `PYTHONDONTWRITEBYTECODE=1`, `PIP_DISABLE_PIP_VERSION_CHECK=1`, `GIT_CONFIG_GLOBAL=/home/vscode/.gitconfig.local` (so the host gitconfig stays read-only).
If you want a different baseline, see [Installing extra tools](#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:
```bash
aic shell
uv tool install # ruff, semgrep, ...
npm install -g
```
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 security wrappers (`aic-chown-volumes`, `aic-lock-gitconfig`, `aic-firewall`) — bare `apt-get`, `chown`, etc. are denied (this is what blocks an in-container AI from escalating to root). To install apt packages, put them in a 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`:
```dockerfile
# 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
```
Edit `.devcontainer/docker-compose.yml` and swap the `image:` line for a build block pointing at the new file:
```yaml
services:
devcontainer:
# image: ghcr.io/stefanoginella/aicontainer:vX.Y.Z # was this
build:
context: .
dockerfile: Dockerfile.project
```
Then `aic rebuild`. The tools survive `aic destroy` and are versioned with the project — teammates get the same environment.
> If you ran `aic init --build` instead of the default, your `Dockerfile.project` should use `FROM aicontainer-base:latest` (the locally-built tag) and the compose file already has a `build:` block — just change `dockerfile: Dockerfile` to `dockerfile: Dockerfile.project`.
**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](#opt-in-network-allowlist) 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.
```dockerfile
# .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
```
Swap the compose `image:` line for the `build:` block shown above, then `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](https://github.com/microsoft/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-managed** — `aic init` writes them and `aic sync` overwrites them (only your `AIC_TOOLS` / `AIC_SHELL` choices are carried across). So don't hand-edit those two files for project-specific tweaks; your edits get reset on the next sync/upgrade.
Instead, drop a **`.devcontainer/docker-compose.override.yml`**. It's project-owned: `aic` never copies over it, and `aic init` / `aic sync` auto-append it to `dockerComposeFile` in `devcontainer.json`, so the wiring is re-applied every sync. Docker Compose merges it on top of the base file (override wins).
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:
```yaml
# .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:pass@host.docker.internal: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 rebuild` after editing. Verify the wiring landed with `grep dockerComposeFile .devcontainer/devcontainer.json` (you should see both files in the array).
> The same file is also the sync-safe place to point at a [`Dockerfile.project`](#b-persistent-in-a-project-dockerfile): 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:
```yaml
# .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 (read only if present), never touched by `aic sync`, and read-only inside the container (an in-container tool can't edit it). The prefix allowlist is a hard security boundary baked into the image — paths outside `/workspace/` and `/home/vscode/.cache/` are refused, so the re-own can't be pointed at sudoers, the hooks, or `~/.gitconfig.local`. Keep tool caches under `~/.cache/` (e.g. `CARGO_HOME=/home/vscode/.cache/cargo` in the override) so they fall inside the allowlist.
> 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 ` 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`**:
```bash
# .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](#threat-model) blocks an in-container tool from editing anything under `.devcontainer/`, so the script stays host-only-editable. It's invoked via `bash ` (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.
## Updating AI tools
```bash
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.
`:vX.Y.Z` tags are immutable once published — they capture the exact image built at release time. CI separately rebuilds and pushes a floating `ghcr.io/stefanoginella/aicontainer:latest` on a weekly schedule and on every template change merged to main, for users who prefer base-layer freshness over reproducibility; that tag isn't referenced by default, but you can opt in by editing `.devcontainer/docker-compose.yml`. In `--build` mode `aic rebuild` does a no-cache local build that re-runs the `claude` and `codex` installers.
The 2 files (pull mode) or full set (build mode) under `.devcontainer/` are not refreshed by `aic rebuild` on their own — they're created once by `aic init`. If a new template version changes them (e.g. a docker-compose mount), run `aic sync` to re-copy from the installed template into `./.devcontainer/`, then `aic rebuild`. `aic sync` auto-detects pull vs. build mode, preserves the project's `AIC_TOOLS` and `AIC_SHELL` selections (pass `--with` / `--shell` to change them), and leaves project-owned files (`Dockerfile.project`, `firewall-allowlist`, `chown-paths`, `post-create.project.sh`, `docker-compose.override.yml`) untouched — re-wiring an existing [`docker-compose.override.yml`](#per-project-overrides-that-survive-aic-sync) into `dockerComposeFile` so per-project compose tweaks survive the sync.
## Multi-project model
> **Your transcripts survive.** Session history is a first-class part of the
> model, not an afterthought. `~/.claude/projects/` and `~/.codex/sessions/`
> live in a **per-project named volume** (`_aic-sessions`), so they
> survive container recreation (`aic rebuild`, `aic up`) without ever being
> written back to your host home — the host's `~/.claude/projects/` is *not*
> mounted. The only thing that clears them is `aic destroy` (which says so
> before it does). So you get isolation *and* durable decision history.
What's shared across all aicontainer projects on your host vs. what's per-project:
| | Scope | Volume |
|---|---|---|
| `~/.claude`, `~/.codex`, `~/.config/gh`, `~/.config/npm` (auth + plugins + recent-session metadata), `semgrep` login token | **Global** | `aic-auth-global` (subpath mounts + `SEMGREP_SETTINGS_FILE`) |
| Shell history (`.zsh_history`) | **Global** | `aic-shell-history` |
| Claude session JSONLs (`~/.claude/projects/`) | **Per-project** | `_aic-sessions` |
| Codex session history (`~/.codex/sessions/`, `history.jsonl`) | **Per-project** | `_aic-sessions` |
| Project source code | Bind mount | `${PWD}` |
| p10k theme, host gitconfig | Bind mount RO | host `~/.p10k.zsh`, host `~/.gitconfig` |
| Claude / Codex global config (seed) | Bind mount RO | host `~/.claude/settings.json`, `~/.claude/statusline/`, `~/.codex/config.toml` |
The per-project rows are dir-level symlinks pointing out of `aic-auth-global` into `_aic-sessions`, so atomic-rename writes to files *inside* those directories stay project-scoped.
Two consequences:
- Log in once, work on twenty projects.
- Per-project chat history (`~/.claude/projects/`, `~/.codex/sessions/`) is isolated — a compromised AI in project A can't read project B's transcripts. **But** anything else under `~/.claude` / `~/.codex` — recent-session metadata, plugins, caches, `history.jsonl` — is shared across projects via `aic-auth-global`, alongside the auth tokens. Accept this trade-off knowingly.
### Config seeding from the host
On first container creation, `post-create.py` reads `~/.claude/settings.json` and `~/.codex/config.toml` from the read-only seed mounts above and copies an **allowlisted subset** of fields into the container's config. Security-critical fields are then force-overwritten:
| Field | Container always sets |
|---|---|
| Claude `permissions.defaultMode` | `bypassPermissions` |
| Claude `hooks` | the aicontainer PreToolUse hook |
| Codex `approval_policy` | `never` |
| Codex `sandbox_mode` | `danger-full-access` |
Claude's PreToolUse hook is forced into `~/.claude/settings.json`. Codex's isn't seeded into `config.toml` (a hook there is *untrusted* and skipped in autonomous mode) — it's baked as a **managed** hook in `/etc/codex/requirements.toml`, which Codex auto-trusts and the in-container user can't disable.
Seeded (when present on the host): Claude `env`, `statusLine`, `enabledPlugins`, `mcpServers` / `enabledMcpjsonServers`, `theme`, `model`, `effortLevel`, `editorMode`, `verbose`, `fileCheckpointingEnabled`, `outputStyle`, plus a handful of other preference fields. Codex `model`, `model_reasoning_effort`, `personality`, `[features]`, `[notice]`, `[projects.*]`, `[mcp_servers.*]`.
**Dropped from the host (never seeded):** Claude `permissions.allow/deny/ask`, Claude `hooks`, Claude `apiKeyHelper` / `awsAuthRefresh` / `awsCredentialExpiration`, Codex top-level `approval_policy` / `sandbox_mode`, Codex `[hooks.*]`. These either defeat the in-container sandbox or carry host-specific auth secrets.
**MCPs:** seeded for both Claude and Codex. An MCP that references a host-only binary (e.g. `/Applications/Foo.app/...`) won't start in the container — the agent logs the failure and continues. URL-based MCPs (`context7`, `openaiDeveloperDocs`, etc.) and npm-installed MCPs work as on the host. If you want a different MCP set in the container than on the host, edit `~/.claude/settings.json` or `~/.codex/config.toml` inside the container (both are writable by the dev user).
**Statusline:** if your host `statusLine.command` references a script under `~/.claude/`, the path is rewritten to `/host-seed/claude/...` and the script is run from the RO mount. Scripts that live elsewhere on the host need a custom bind mount added to `.devcontainer/docker-compose.yml`.
**Host paths NOT mounted:** `~/.claude/projects/`, `~/.claude/.credentials.json`, `~/.claude.json`, `~/.codex/sessions/`, `~/.codex/auth.json`, `~/.codex/history.jsonl`. Chat history and auth tokens stay on the host; the container builds its own via `claude /login` etc. on first run.
## Threat model
**Sandboxed:**
- **Filesystem**: host is inaccessible except for the project directory (RW) and a handful of read-only mounts: shell look-and-feel (`~/.gitconfig`, `~/.p10k.zsh`, `~/.zshrc.local`) and the AI-config seeds (`~/.claude/settings.json`, `~/.claude/statusline/`, `~/.codex/config.toml`) covered in [Config seeding from the host](#config-seeding-from-the-host).
- **Process namespace**: container processes don't see host processes.
- **Docker daemon**: API surface reduced via socket-proxy. `EXEC`, `AUTH`, `SECRETS`, `SWARM`, `SYSTEM` (and friends) are blocked. `POST` to `/containers` and `/build` is **enabled** so testcontainers, `docker compose up`, and sibling-container tooling work from inside the devcontainer — but this also means anyone with shell access here can `docker run --privileged -v /:/host` against the host daemon. Treat the proxy as a footgun reducer, not a host-isolation boundary; don't run untrusted code inside.
- **AI guardrails**: `.devcontainer/`, `.git/config`, `.git/hooks` are mounted **read-only** so the AI cannot rewrite its own configuration. The PreToolUse hook lives at `/etc/aic/hooks/` and is root-owned, not writable by the dev user. The scoped sudoers entry only exposes hardcoded-target wrappers (`aic-chown-volumes`, `aic-lock-gitconfig`, `aic-firewall`) — no bare `chown`, so AI cannot take ownership of `/etc/sudoers.d/` or `/etc/aic/` to escalate.
**Not sandboxed (unless you opt in):**
- **Network**: full outbound access by default. Anything inside the container can reach `api.openai.com`, `api.anthropic.com`, your LAN, and cloud metadata services (`169.254.169.254`). To restrict this, opt in to the [iptables allowlist](#opt-in-network-allowlist) below.
- **Git identity**: your `~/.gitconfig` is read-only mounted, so the AI can commit and push as you (via `gh auth` or stored credentials).
- **Host credentials**: nothing is auto-forwarded. The AI only has access to what you explicitly `claude /login`, `codex auth`, `gh auth login` for inside the container.
Don't run this on a network where reaching internal services or cloud metadata is a concern — or enable the allowlist below.
## 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:
```bash
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 / GitHub / npm / PyPI / Docker registries. Per-project extras go in `.devcontainer/firewall-allowlist` (one domain per line, `#` comments allowed). Re-run `sudo aic-firewall enable` after editing.
Design notes:
- The script is **enable-only**. There is no `disable` or `pause` subcommand and the scoped sudoers entry only allows this single script — so an AI that gets shell access can call it, but only to *strengthen* the policy, never to remove it.
- To turn the firewall off, `aic rebuild` from the host (this script doesn't survive container recreation).
- `NET_ADMIN` and `NET_RAW` are granted to the container so the script can manage iptables. The caps are confined to the container's network namespace — they do not affect the host's networking.
## FAQ
**Doesn't Claude Code's new auto mode make this unnecessary?** No — they're
complementary. Claude Code's [auto mode](https://www.anthropic.com/engineering/claude-code-auto-mode)
runs a classifier that reviews each action before it executes and blocks the
obviously destructive ones, which is great, but Anthropic themselves recommend
running it *in an isolated environment* because it "reduces risk but doesn't
eliminate it." That isolated environment is exactly what aicontainer provides.
Auto mode also isn't available on every plan yet, and it doesn't cover
`--dangerously-skip-permissions` or Codex's `--full-auto` / sandbox-off, which
have no classifier at all. The sandbox is the boundary; auto mode is a smarter
agent *inside* it. Run both.
## Troubleshooting
**Docker not running.** Start Docker Desktop / OrbStack / Colima. `aic up` won't even try without it.
**`devcontainer: command not found`.** Normally bundled with `npm install -g aicontainer`. If you installed via git checkout, run `npm install -g @devcontainers/cli`.
**Powerlevel10k glyphs look wrong.** Install the [Meslo Nerd Font](https://github.com/romkatv/powerlevel10k#meslo-nerd-font-patched-for-powerlevel10k) and set it as your terminal font.
**Powerlevel10k prints "Type `p10k configure` to customize" on every shell.** Your host doesn't have a `~/.p10k.zsh` yet. The container bind-mounts that file **read-only**, so configuration has to happen on the host:
```bash
# On the host (outside the container):
p10k configure # if you have p10k installed on the host
# or, install p10k briefly to generate the config:
brew install powerlevel10k && p10k configure
```
After `~/.p10k.zsh` exists on the host, the next `aic shell` picks it up automatically. Running `p10k configure` *inside* the container won't work — the mount is read-only by design (so AI can't rewrite your shell).
**`aic shell` succeeds but `claude` errors with permission issues.** `post-create.py` runs during `aic up`, not on shell entry — scroll the `aic up` output for `[post-create]` warnings (volume ownership, hook setup, settings write). `aic rebuild` re-runs it cleanly.
**Tools installed ad-hoc disappeared.** That's expected — see "Installing extra tools" above. Move them to `Dockerfile.project`.
**Codex prompts for approval despite auto-approve.** Make sure `~/.codex/config.toml` exists (it's written by `post-create.py`). Re-run `aic rebuild` if the file is missing.
**Codex VS Code sidebar asks for approval, or a command fails with `bwrap: No permissions to create a new namespace` / "The sandbox cannot create a namespace here".** `post-create.py` already forces `~/.codex/config.toml` to `sandbox_mode = danger-full-access` + `approval_policy = never`, so the **main** Codex agent runs without prompts (verify with `cat ~/.codex/config.toml`). Two things can still surface a prompt:
- **Sidebar mode.** The extension's built-in *Full access* preset tries to start Codex's own sandbox. Pick **Custom (config.toml)** in the mode menu so the sidebar uses the forced `danger-full-access` — the only mode that skips the inner sandbox.
- **Review / sub-agent workflows.** Codex's built-in *Code Review* feature (and spawned sub-agents) currently don't inherit `danger-full-access` and fall back to `workspace-write` ([openai/codex#15305](https://github.com/openai/codex/issues/15305), [#5090](https://github.com/openai/codex/issues/5090)). Inside the container that sandbox can't create a namespace, so Codex asks to run the command *outside the sandbox*. **That's safe to allow here** — "outside the [inner] sandbox" just means "normally inside the container," which is the isolation aicontainer already provides — so click **Yes** (or *Yes, and don't ask again…*). Workaround: run the review as a **normal turn** (e.g. invoke the review skill/command directly in the chat) instead of via Codex's *Code Review* entry point — a main-loop turn inherits `danger-full-access` and won't prompt. It's an upstream Codex bug, not an aicontainer misconfiguration.
Either way, don't "fix" bwrap by granting `SYS_ADMIN` / `seccomp=unconfined` / unprivileged user namespaces — that lets the AI nest a sandbox by weakening the container's own boundary, which is the whole point of running here.
## Uninstall
```bash
# In each project:
aic destroy # shows the session-transcript volume size and confirms
# first (irreversible); add --yes to skip the prompt
# Globally:
docker volume rm aic-auth-global aic-shell-history
npm uninstall -g aicontainer # or, for a git checkout:
# rm -rf ~/.aicontainer ~/.local/bin/aic
```
## Releasing
For maintainers. Releases are tag-driven — pushing a `v*` git tag is the only thing that publishes a new `:vX.Y.Z` image to GHCR or a new version to npm.
From a clean `main`:
```bash
git checkout main && git pull
# Release notes should already be under ## [Unreleased] in CHANGELOG.md (add
# them as you work). `npm version` promotes that section to the new version.
npm version patch # or: minor / major. Promotes the changelog, bumps,
# commits, and creates the v* tag.
git push --follow-tags # pushes both the commit and the tag
```
`release.yml` then fires on the tag and ships, in one atomic flow:
- `ghcr.io/stefanoginella/aicontainer:vX.Y.Z` (immutable)
- `ghcr.io/stefanoginella/aicontainer:latest` (floats forward)
- `aicontainer@X.Y.Z` on npm with provenance attestation
- a GitHub Release for the tag, with notes pulled from `CHANGELOG.md`
A guard step rejects the run if the `v*` tag doesn't match `package.json`'s `version` — so `npm version` is the only sane way to mint a release tag.
`CHANGELOG.md` is **hand-maintained** ([Keep a Changelog](https://keepachangelog.com/) format) — you write the prose; nothing is auto-generated from commits. Add notes under `## [Unreleased]` as you work. At release time `npm version` does the mechanical promotion for you: a `version` lifecycle script (`scripts/promote-changelog.mjs`) relabels `## [Unreleased]` to `## [X.Y.Z] - `, opens a fresh empty `[Unreleased]`, and fixes the compare links — all from notes you authored. A `preversion` check aborts the bump if `[Unreleased]` is empty, so you can't release nothing. The GitHub Release notes are the resulting `## [X.Y.Z]` section, pulled verbatim, and `release.yml` greps for that section before any publish as a backstop. The `.githooks/pre-push` hook enforces the same check locally; enable it once per clone with `git config core.hooksPath .githooks`.
### Day-to-day pushes vs. releases
| | What triggers it | What ships |
| --- | --- | --- |
| Feature PR | merge to `main`, touches `template/**` | `:latest` + weekly tag refresh. Nothing on npm. |
| Feature PR | merge to `main`, only touches `aic`/README | Nothing. |
| Release | `npm version && git push --follow-tags` | `:vX.Y.Z` (immutable) + `:latest` + npm publish + GitHub Release. |
| Weekly cron | Mondays 06:00 UTC | `:latest` + `:weekly-YYYY-VV` refresh. No npm activity. |
Because `aic init` pins users to `:v{installed-aic-version}`, **only a release reaches pinned users**. Template-only merges to `main` refresh `:latest` (an opt-in track), not anyone's pinned image. Lean toward small, frequent patch releases when you fix something users should pick up — there is no "hidden" template change for pinned users.
### Picking the bump
- **patch**: security/freshness rebuild, internal Dockerfile cleanup, hook fix that doesn't change behavior, docs important enough to ship. Most releases.
- **minor**: added an `aic` command/flag, added a tool, added a template field. Backwards-compatible.
- **major**: removed a flag, changed default behavior, restructured `.devcontainer/` files in a way that breaks `aic sync` for existing projects.
### Hotfix / rollback
`:vX.Y.Z` is immutable; you cannot republish under the same tag. To fix a bad release, ship another patch (revert or fix-forward, your choice):
```bash
git revert # or just fix forward
npm version patch
git push --follow-tags
```
Users on a bad version can pin to a known-good earlier release:
```bash
npm install -g aicontainer@
cd my-project && aic sync && aic rebuild
```
### Things not to do
- **Don't `npm publish` from your laptop.** CI uses `--provenance`; manual publishes skip the supply-chain attestation users get to verify.
- **Don't push a `v*` tag without bumping `package.json` first.** Use `npm version`, which keeps the two in lockstep. The CI guard will fail the release otherwise.
- **Don't bump `package.json` as part of a feature PR.** Version bumps are their own commit (created by `npm version`) so the tag points at a clean release commit, not a multi-purpose merge.
- **Don't force-push or rewrite tags on `main`.** GHCR already received whatever the tag was bound to; rewriting history creates ghost tags and confused users.
- **Don't tag a release without a `CHANGELOG.md` entry for it.** CI refuses to publish a version that has no `## [X.Y.Z]` section, so you'd just burn a tag. Update the changelog *before* `npm version`.
## Contributing & security
Bugs, ideas, and PRs welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) for ground rules, the development loop, and what won't be merged. By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md).
Security findings: please **don't** open a public issue. Use GitHub's [private security advisory flow](https://github.com/stefanoginella/aicontainer/security/advisories/new) instead.
## License
[MIT](./LICENSE) © 2026 Stefano Ginella