diff --git a/.devcontainer/README.md b/.devcontainer/README.md index eef88bc9c..8817c1773 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -4,7 +4,7 @@ A cross-platform Dev Container that pre-installs Claude Code, OpenAI Codex CLI, > ### ⚠️ Read this before using it on a work machine > -> This devcontainer **does not write to your host AI-CLI config.** Your skills, agents, commands, plugins, memory, prompts, and rules are **copied once** from a read-only host stage into a per-container volume on first create; the container edits its own copy and can never write back. So a compromised workspace dependency running in the container **cannot** drop a malicious agent, command, skill, or plugin onto your host for your next host CLI session to load — the write-through vector earlier versions had is closed. Your **credentials** (Claude/Codex/Cursor logins) likewise stay in per-container volumes and are never written back, and `~/.ssh`, `~/.aws`, `~/.azure`, `~/.config/gh`, and `~/.docker` are mounted **read-only**. +> This devcontainer **does not write to your host AI-CLI config.** Your skills, agents, commands, plugins, memory, prompts, and rules are **copied once** from a read-only host stage into a per-container volume on first create; the container edits its own copy and can never write back. So a compromised workspace dependency running in the container **cannot** drop a malicious agent, command, skill, or plugin onto your host for your next host CLI session to load — the write-through vector earlier versions had is closed. Your **credentials** (Claude/Codex/Cursor logins, plus `gh`) likewise stay in per-container volumes and are never written back, and `~/.ssh`, `~/.aws`, `~/.azure`, and `~/.docker` are mounted **read-only**. > > What is **still** exposed: the read-only host stages (`/host/.claude`, `/host/.codex`, `/host/.cursor`, `/host/.claude-mem`) and the read-only credential mounts are all **readable** inside the container. A compromised dependency can therefore READ your host CLI config, memory, SSH/cloud credentials, and GitHub token — and there is **no egress firewall yet**, so it has the network to exfiltrate what it reads. Read-only protects you from tampering and write-back, not from disclosure. > @@ -93,6 +93,7 @@ The three AI CLIs use a **copy-from-read-only-stage topology**: the host's `~/.< | Container Claude config dir | _named volume_ `claude-config-${devcontainerId}` | `/home/node/.claude` | rw | Per-container credentials + identity | | Container Codex config dir | _named volume_ `codex-config-${devcontainerId}` | `/home/node/.codex` | rw | Per-container credentials | | Container Cursor config dir | _named volume_ `cursor-config-${devcontainerId}` | `/home/node/.cursor` | rw | Per-container credentials | +| Container gh config dir | _named volume_ `gh-config-${devcontainerId}` | `/home/node/.config/gh` | rw | Per-container `gh` auth (`hosts.yml`/`config.yml`) seeded from host stage; in-container login persists | | **Claude sessions** (overlay on the config volume) | _named volume_ `…-claude-sessions-${devcontainerId}` | `/home/node/.claude/projects` | rw | `--resume` transcripts; survives the `-config` volume wipe — see [Session resume](#session-resume-across-container-recreation) | | **Codex sessions** | _named volume_ `…-codex-sessions-${devcontainerId}` | `/home/node/.codex/sessions` | rw | `codex resume` rollouts; SQLite index backfills on recreation | | **Cursor sessions** | _named volumes_ `…-cursor-sessions-${devcontainerId}`, `…-cursor-projects-${devcontainerId}` | `/home/node/.cursor/chats`, `/home/node/.cursor/projects` | rw | `cursor-agent resume` store (best-effort — layout reverse-engineered) | @@ -166,12 +167,12 @@ Because these are **separate** volumes from `-config-${devcontainerId}`, th | --------------- | ------------------- | ------------- | -------------------------------------------------------------------------------------- | | `~/.config/git` | `$HOME/.config/git` | **read-only** | XDG-style git config / ignore / attributes | | `~/.ssh` | `$HOME/.ssh` | **read-only** | SSH commit signing + git push over SSH | -| `~/.config/gh` | `$HOME/.config/gh` | **read-only** | `gh` CLI auth (PR/issue create, checks) — container reads your existing host login | +| `~/.config/gh` | `$HOME/.config/gh` | **copy → volume** | `gh` CLI auth (PR/issue create, checks) — seeded from your host login on create into a per-container volume; in-container `gh auth login` persists across rebuilds and never writes back to the host | | `~/.docker` | `$HOME/.docker` | **read-only** | Container registry auth + buildx config (inert until you add Docker CLI via a Feature) | | `~/.aws` | `$HOME/.aws` | **read-only** | AWS CLI / SDK credentials (forward-compat — empty by default) | | `~/.azure` | `$HOME/.azure` | **read-only** | Azure CLI credentials (forward-compat — empty by default) | -**Why everything here is read-only — including `gh`/`docker`:** `ssh`/`aws`/`azure` are consumed read-only by their clients (the SSH client and the AWS/Azure SDKs only read their credential files), so a one-way mount loses nothing. `gh` and `docker` _can_ write their own state (`gh auth login` / `gh auth refresh` rewrite `hosts.yml`; `docker login` / buildx write `config.json`), so they were originally read-write — but that also lets a compromised in-container dependency rewrite your host `~/.config/gh/hosts.yml` (swap your GitHub token) or `~/.docker/config.json` (point a `credHelper` at an attacker-controlled binary), which is a credential-takeover vector, not just a read. Since the common case is _reading_ an existing host login, both are mounted **read-only**: `gh pr create`, `gh pr checks`, and registry pulls/pushes using your host creds all still work — only a `gh auth login` / `docker login` run **inside** the container won't persist back to the host. Re-run those on the host, or, if you specifically want in-container logins to stick, drop `,readonly` from the `~/.config/gh` and `~/.docker` mounts in `devcontainer.json`. +**Why `ssh`/`aws`/`azure`/`docker` are read-only, and why `gh` is copied into a volume:** `ssh`/`aws`/`azure` are consumed read-only by their clients (the SSH client and the AWS/Azure SDKs only read their credential files), so a one-way mount loses nothing. `docker` _can_ write its own state (`docker login` / buildx write `config.json`), but a read-write host bind would let a compromised in-container dependency rewrite your host `~/.docker/config.json` (point a `credHelper` at an attacker-controlled binary) — a credential-takeover vector. The common case is _reading_ an existing host login, so `docker` stays **read-only**: registry pulls/pushes using your host creds work, only a `docker login` inside the container won't persist back. `gh` used to be read-only for the same reason, but that meant an in-container `gh auth login` had nowhere to write and silently failed. So `gh` now uses the **copy-into-volume** model (the same one the AI-CLI credentials use): the host `~/.config/gh` is a read-only _stage_ at `/host/.config/gh`, and `post-create.sh` copies `hosts.yml`/`config.yml` out of it into the per-container `gh-config` volume on create. The container gets a **writable** copy — `gh auth login` / `gh auth refresh` inside the container now work and persist across rebuilds — while the read-only stage guarantees nothing is ever written back to the host's token. If you want `docker` to behave the same way, give it the same treatment (a `/host/.docker` stage + a docker-config volume + a copy step in `post-create.sh`). `~/.gitconfig` is **not** bind-mounted — VS Code's Dev Containers extension auto-copies the host's gitconfig into the container at attach time (this is built-in behavior, not something this devcontainer configures). The bind-mount approach conflicts with that auto-copy mechanism, so we let VS Code own it. The end result is the same: your host's `user.name` / `user.email` are available inside the container. @@ -202,7 +203,7 @@ That means: - **Plugins, skills, agents, memory, and commands are seeded from the host once, then container-private.** On first create the container copies your host's plugins/skills/agents/memory/commands (and Codex prompts/memories, Cursor rules) into its own volume. After that they're independent: install or edit inside the container and it stays in the container (persists across rebuilds); add a plugin or agent on the host and the container won't see it until you wipe the config volume and rebuild. Nothing the container does reaches the host. (`settings.json` and the user-scope `~/.claude.json` are copy-on-create the same way; `~/.claude/projects/` is container-local by design.) - **Git identity comes from the host.** Commits from inside the container use your host's `user.name` / `user.email` — VS Code's Dev Containers extension auto-copies your `~/.gitconfig` into the container at attach time. Any XDG-style config under `~/.config/git/` flows through via the read-only bind mount. To change git identity, edit `~/.gitconfig` on the host (container-side `git config --global` writes to a container-local file that's discarded on rebuild). - **SSH keys flow through (read-only).** Push over SSH remotes and SSH commit signing work inside the container using your host keys. The mount is read-only so container code can't exfiltrate or modify private keys — agent-perspective, this means you get git operations but the keys stay vendor-side. -- **`gh` auth is shared.** `gh pr create`, `gh pr checks`, `gh issue create` work inside the container without re-authenticating. +- **`gh` auth is shared, and in-container logins persist.** If you're logged in on the host, `gh pr create`, `gh pr checks`, `gh issue create` work inside the container without re-authenticating. If you're not, run `gh auth login` inside the container once — because `gh` config lives in a writable per-container volume (seeded from the host stage), that login persists across rebuilds and never touches the host's token. - **No per-workspace duplication.** All your devcontainers across all your projects see the same host CLI state, just like all your host shells do. The bind mount source directories are guaranteed to exist by the `initializeCommand` (`node .devcontainer/ensure-host-config-dirs.cjs`), which runs on the host before container create. It's a Node script (not a shell one-liner) so the same command works on Windows `cmd.exe` and POSIX shells. It creates the top-level bind-mount source dirs — `~/.claude`, `~/.codex`, `~/.cursor`, `~/.claude-mem`, plus `~/.ssh`, `~/.docker`, `~/.aws`, `~/.azure`, `~/.config/{gh,git}`. It deliberately does **not** pre-create the shareable subdirs (skills/agents/plugins/…): those are no longer bind sources (they're copied out of the whole-`~/.` read-only stage), and pre-creating empty ones would needlessly write into the host of someone who never used that CLI. @@ -222,7 +223,7 @@ Host and container share a single trust boundary by design — fine for personal It does **not** have write-through to the host's CLI config. The shareable dirs are copied out of the read-only stage into the container's own volume, so a compromised in-container dep **cannot** write into your host `~/.claude/{plugins,agents,skills,commands,memory}/`, `~/.codex/{plugins,prompts,memories,skills}/`, or `~/.cursor/{plugins,rules,commands,agents,skills}/`. The persistence vector earlier versions had — drop a malicious auto-loaded agent/command/skill/rule onto the host, have it run in your next **host** session — is closed: there is no writable path from the container to those host folders. (Cursor's `hooks.json` is still additionally withheld from even the _container's_ copy, because hooks fire without an agent invoking them.) The boundary is now one-way for **all** of the host CLI config, not just credentials. -**What stays one-way (genuinely protected):** everything. Credentials never flow back to host — `.credentials.json` / `auth.json` / `cli-config.json` live only in the per-container named volumes, and the `/host/.` stage they're copied from is mounted **read-only**, so the snapshot can't be overwritten back. The shareable AI-CLI dirs (skills/agents/plugins/memory/commands/prompts/rules) are now copy-on-create from that same read-only stage, so they have the one-way property too — readable for the copy, never writable back. `~/.ssh`, `~/.config/git`, `~/.aws`, `~/.azure`, **`~/.config/gh`, and `~/.docker`** are read-only binds with the same property — a compromised dep can _read_ your `gh`/registry tokens but cannot _rewrite_ them to hijack your future host auth. **Session transcripts** live in per-workspace named volumes (mount group 6) and are never seeded from or written back to the host, and the container can't see any _other_ project's transcripts. The opt-in host-bind block in `devcontainer.json` reverses that for sessions only — enable it only if you accept transcripts on host disk; see [Session resume across container recreation](#session-resume-across-container-recreation). +**What stays one-way (genuinely protected):** everything. Credentials never flow back to host — `.credentials.json` / `auth.json` / `cli-config.json` live only in the per-container named volumes, and the `/host/.` stage they're copied from is mounted **read-only**, so the snapshot can't be overwritten back. The shareable AI-CLI dirs (skills/agents/plugins/memory/commands/prompts/rules) are now copy-on-create from that same read-only stage, so they have the one-way property too — readable for the copy, never writable back. `~/.ssh`, `~/.config/git`, `~/.aws`, `~/.azure`, and **`~/.docker`** are read-only binds with the same property — a compromised dep can _read_ your registry tokens but cannot _rewrite_ them to hijack your future host auth. **`~/.config/gh`** is now a read-only _stage_ copied into a per-container volume, so it keeps that same one-way property: the container reads it once to seed its own writable copy, and the read-only stage means an in-container `gh auth login` can never overwrite your host token. **Session transcripts** live in per-workspace named volumes (mount group 6) and are never seeded from or written back to the host, and the container can't see any _other_ project's transcripts. The opt-in host-bind block in `devcontainer.json` reverses that for sessions only — enable it only if you accept transcripts on host disk; see [Session resume across container recreation](#session-resume-across-container-recreation). **The egress firewall is the key compensating control that is still missing.** It's deferred (see "What's not included (yet)" below), so a compromised package currently has unrestricted outbound network to exfiltrate anything in the read list above. Until it lands, treat that read surface as exposed to any code you run in the container — don't use this devcontainer on a machine whose host credentials you couldn't afford to rotate. The isolated-volume setup below removes host AI-CLI config/credentials from that surface entirely. @@ -321,7 +322,7 @@ VS Code's Ports panel shows forwarded ports once their listener starts. - **To force a re-login / clear an `EACCES`** — remove the per-container _config_ volumes and rebuild. As of the session-volume change this **no longer drops your `--resume` history** (sessions are on separate volumes — see [Session resume](#session-resume-across-container-recreation)): ```bash docker volume ls | grep -- -config- # the credential / identity volumes - docker volume rm claude-config- codex-config- cursor-config- + docker volume rm claude-config- codex-config- cursor-config- gh-config- ``` ⚠️ Since the shareable dirs are now seeded into the config volume (not bind-mounted), wiping `-config` **also discards any plugin/skill/agent/command you installed _inside_ the container** and re-seeds those dirs from the host on the next rebuild. That is the intended way to pull host-side config changes in, but if you have in-container-only plugins you want to keep, reinstall them after the rebuild (or install them on the host first so the re-seed brings them along). - **To also wipe session history** (a true clean slate) — remove the session volumes too (`` is your workspace folder name): @@ -360,4 +361,4 @@ Bump the version pins in `.devcontainer/devcontainer.json` `build.args` and rebu | Integration tests fail with `database busy` | LadybugDB single-writer constraint | Don't run host-side `gitnexus analyze` while the container is also analyzing the same repo; choose one writer | | API key env vars not visible inside the container | They are intentionally not auto-propagated from the host (so an empty/stale host var can't silently break `*-login` for everyone else) | `export ANTHROPIC_API_KEY=...` / `OPENAI_API_KEY=...` / `CURSOR_API_KEY=...` inside the container shell, or carry it via your VS Code [dotfiles repo](https://code.visualstudio.com/docs/devcontainers/containers#_personalizing-with-dotfile-repositories) for persistence | | `git commit` produces commits with empty author | `~/.gitconfig` is missing or empty on the host (VS Code's auto-copy had nothing to copy) | Set `git config --global user.name "Your Name"` and `git config --global user.email "you@example.com"` from the host shell, then rebuild the container | -| `gh: not logged in` inside the container | Not logged in on the host, or `~/.config/gh/` source path missing on the host | Run `gh auth login` **on the host** — the `~/.config/gh` mount is read-only, so an in-container login won't persist; the host auth flows into the container on next attach | +| `gh: not logged in` inside the container | Not logged in on the host (nothing to seed), or the `gh-config` volume is empty | Just run `gh auth login` **inside the container** — `gh` config lives in a writable per-container volume, so the login persists across rebuilds. (Logging in on the host instead also works: it seeds in on the next container create.) | diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 75069d97c..b2772d614 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -133,6 +133,15 @@ "source=codex-config-${devcontainerId},target=/home/node/.codex,type=volume", "source=cursor-config-${devcontainerId},target=/home/node/.cursor,type=volume", + // gh CLI config as a per-container named volume, same model as the AI CLI + // configs above: post-create.sh COPIES hosts.yml/config.yml out of the + // read-only /host/.config/gh stage into this volume on container-create. + // The container then owns a WRITABLE copy, so `gh auth login` / + // `gh auth refresh` run INSIDE the container persist across rebuilds — and + // still never write back to the host (the stage is read-only). If the host + // is logged in, that login seeds in; if not, an in-container login sticks. + "source=gh-config-${devcontainerId},target=/home/node/.config/gh,type=volume", + // claude-mem store. UNLIKE the shareable dirs below (skills/agents/memory), // this is NOT a host bind. $HOME/.claude-mem is a large, multi-GB SQLite + // Chroma vector store (claude-mem.db + -wal/-shm, chroma/chroma.sqlite3, HNSW @@ -261,15 +270,18 @@ "source=${localEnv:HOME}/.claude.json,target=/host/.claude.json,type=bind,readonly", "source=${localEnv:HOME}/.config/git,target=/home/node/.config/git,type=bind,readonly", "source=${localEnv:HOME}/.ssh,target=/home/node/.ssh,type=bind,readonly", - // gh and docker are READ-ONLY. The container reads your EXISTING host login, - // which is the common case. But a compromised in-container dependency can't - // rewrite ~/.config/gh/hosts.yml (your GitHub token) or - // ~/.docker/config.json (the registry credHelper, which points at a - // binary). The trade-off: `gh auth login` or `docker login` run INSIDE the - // container won't persist back to the host. Re-run them on the host, or - // remove `,readonly` from the next two lines if you want in-container logins - // to stick. See README § Trust boundary. - "source=${localEnv:HOME}/.config/gh,target=/home/node/.config/gh,type=bind,readonly", + // gh uses the COPY-INTO-VOLUME model (read-only host stage at + // /host/.config/gh + the gh-config named volume above). post-create.sh seeds + // hosts.yml/config.yml from this stage into the volume on create, so the + // container has a writable copy: an in-container `gh auth login` persists + // across rebuilds, and nothing is ever written back to the host because this + // stage is read-only. docker stays a direct READ-ONLY bind: the container + // reads your EXISTING host login (the common case), and a compromised + // in-container dependency can't rewrite ~/.docker/config.json (the registry + // credHelper, which points at a binary). A `docker login` run inside the + // container won't persist back to the host — re-run it on the host, or give + // docker the same copy-into-volume treatment as gh. See README § Trust boundary. + "source=${localEnv:HOME}/.config/gh,target=/host/.config/gh,type=bind,readonly", "source=${localEnv:HOME}/.docker,target=/home/node/.docker,type=bind,readonly", "source=${localEnv:HOME}/.aws,target=/home/node/.aws,type=bind,readonly", "source=${localEnv:HOME}/.azure,target=/home/node/.azure,type=bind,readonly", diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 08ac77e4c..58c9f8892 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -47,6 +47,7 @@ DIRS=( /home/node/.cursor /home/node/.cursor/chats /home/node/.cursor/projects + /home/node/.config/gh /home/node/.local /commandhistory ) @@ -172,6 +173,16 @@ sync_from_host \ sync_from_host \ /host/.cursor/mcp.json /home/node/.cursor/mcp.json 644 +# gh CLI auth + settings. Same copy-into-volume model as the credentials above: +# hosts.yml holds the GitHub token (mode 600), config.yml holds settings (644). +# Copied from the read-only /host/.config/gh stage into the gh-config named +# volume on create. Because the volume is writable, an in-container +# `gh auth login` / `gh auth refresh` persists across rebuilds; because the +# stage is read-only, nothing flows back to the host. If the host had no login, +# both copies quietly no-op and whatever the container wrote is kept. +sync_from_host /host/.config/gh/hosts.yml /home/node/.config/gh/hosts.yml +sync_from_host /host/.config/gh/config.yml /home/node/.config/gh/config.yml 644 + echo "[post-create] 3/4: seed shareable config dirs from host (first create only)" # The shareable dirs (Claude skills/agents/memory/commands/plugins; Codex # plugins/prompts/memories/skills; Cursor rules/commands/agents/skills/plugins)