GitNexus/.devcontainer/devcontainer.json
Gergo Magyar bfdd183c95 fix(devcontainer): resolve adversarial review findings (pins, RO mounts, tests)
Resolves the blocking + actionable findings from the PR #1875 review:

- Pin base image by digest as bare name@digest [#1]. The :tag@digest form
  trips the @devcontainers/cli image-name parser (which builds this image
  in CI and in VS Code "Reopen in Container"); bare name@digest is the
  parser-compatible form. Verified by a full local build.
- Pin Cursor by version + per-arch sha256 and fetch the artifact directly
  instead of executing cursor.com/install; fail-closed on mismatch [#2].
- Mount ~/.config/gh and ~/.docker read-only so a compromised dep can't
  rewrite the host GitHub token / Docker credHelper [#4].
- Pin @devcontainers/cli@0.87.0 in the CI smoke [#5].
- chown via find -xdev in install-deps.sh (symlink-safe; matches
  post-create.sh) [#6].
- Add filesystem-I/O tests (translate/readHostConfig/seed main/ensurePaths)
  and refactor ensure-host-config-dirs to be unit-testable [#7].
- Stop pre-creating settings.json/config.toml on the host; only the real
  single-file bind source (.claude.json) is touched [#10].
- Add a prominent top-of-README security callout for the RW write-through
  trade-off and reframe the deferred egress firewall as the key missing
  compensating control [#3, #9].

Full devcontainer build verified locally (digest pull + pinned Cursor
download/extract/symlink). 24/24 config-transform tests pass.
2026-05-29 07:03:15 +01:00

316 lines
18 KiB
JSON

// Devcontainer for GitNexus. Pre-installs Claude Code, OpenAI Codex CLI,
// and Cursor CLI alongside the Node.js native build chain. Cross-platform
// across macOS, Linux, Windows-via-WSL2, and Windows-native (the latter
// needs a one-time HOME setup performed automatically by
// initializeCommand — see .devcontainer/README.md § Windows 11 setup).
// Opens via the VS Code Dev Containers extension.
//
// First-time setup, auth flows, and troubleshooting: .devcontainer/README.md.
{
"name": "GitNexus AI CLI Devcontainer",
"build": {
"dockerfile": "Dockerfile",
"context": ".",
"args": {
"CLAUDE_CODE_VERSION": "2.1.153",
"CODEX_VERSION": "0.134.0",
// Cursor: pinned version + per-arch tarball sha256, verified at build
// time in the Dockerfile (no remote install script is executed). Bump
// all three together; re-hash each arch with:
// curl -fSL https://downloads.cursor.com/lab/<ver>/linux/<x64|arm64>/agent-cli-package.tar.gz | sha256sum
"CURSOR_VERSION": "2026.05.28-a70ca7c",
"CURSOR_SHA256_X64": "7f8b6a09393e0b84b288cc6952b292fc98d15775f644cc01b0b9aa4f04b268df",
"CURSOR_SHA256_ARM64": "05a0ab361e038729aba25fe7f407531b3e8432912e499d0bffdf1dda0e7833e9",
"TZ": "${localEnv:TZ:UTC}"
}
},
// Runs on the HOST (not the container) before container create. The
// single-string form is the spec-canonical shape for cross-platform
// command-property dispatch; the object form is "named parallel tasks",
// not OS dispatch. We use Node so the same command works in cmd.exe on
// Windows and bash/zsh on Linux/macOS/WSL — the script reads `os.homedir()`
// (which respects $HOME on POSIX and %USERPROFILE% on Windows) and creates
// the host-side bind mount sources idempotently. Host prerequisite: Node
// on PATH (the only host-side toolchain dependency beyond Docker Desktop
// and the VS Code Dev Containers extension).
"initializeCommand": "node .devcontainer/ensure-host-config-dirs.cjs",
"features": {
"ghcr.io/devcontainers/features/github-cli:1": {}
},
"remoteUser": "node",
"updateRemoteUserUID": true,
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
// Mount topology, by group:
//
// 1. AI CLI host config — TWO roles. (a) A read-only stage at /host/.<cli>
// that `post-create.sh` COPIES credentials + identity + single config
// files from on container-create (read-only so the credential snapshot
// can't be written back to host). (b) Direct READ-WRITE bind mounts of
// the shareable subdirs (Claude plugins/skills/agents/memory/commands;
// Codex plugins/prompts/memories/skills; Cursor plugins/rules/commands/
// agents/skills) overlaid onto the named volume at their sub-paths.
// Those RW binds are BIDIRECTIONAL: installing a plugin in the container
// writes straight to the host, and vice versa. This is a deliberate
// write-through trade-off — a compromised npm dep CAN drop files into
// your host plugin/agent/skill dirs that the next host session loads.
// See README § "Trust boundary, concretely". Credentials never join
// this surface — they stay in the volume (role 2).
//
// 2. AI CLI container config — per-devcontainer named volumes. CODEX_HOME
// points here; CLAUDE_CONFIG_DIR is intentionally unset so it resolves
// to the default ~/.claude (same path). Credentials + identity +
// single config files (.credentials.json, ~/.claude/.claude.json,
// settings.json, config.toml, cli-config.json, mcp.json) live here with
// proper Linux perms and are NOT bind-mounted (single-file binds trip
// EXDEV on Docker Desktop Windows). Container-managed state (sessions,
// history, caches, IDE locks) stays isolated per devcontainer, so two
// GitNexus checkouts on the same host don't corrupt each other.
//
// 3. Other host config — READ-ONLY bind mounts for credential/identity
// dirs that don't have the perm-flattening / onboarding-state complexity
// Claude Code does (ssh, aws, azure, git config, and gh + docker — the
// latter two RO so a compromised dep can't rewrite the GitHub token or
// Docker credHelper; see the inline note at those mounts). `~/.gitconfig`
// is handled separately by VS Code's auto-copy mechanism (not mounted).
//
// 4. Per-instance state — `${devcontainerId}`-scoped: shell history,
// npm cache. Survive rebuilds, isolated between sibling instances.
//
// 5. Per-workspace-name AND per-instance state — workspace `node_modules`
// volumes use both `${localWorkspaceFolderBasename}` (debuggable in
// `docker volume ls`) and `${devcontainerId}` (collision-free between
// sibling instances of the same repo). Keeps tree-sitter native
// binaries and onnxruntime off the workspace bind mount (Win/Mac perf).
"mounts": [
// Per-container named volumes for credentials + identity state. Each
// CLI's actual `~/.<cli>` config dir lives in a volume so credentials
// (with proper Linux 600 perms) and per-container session state stay
// isolated from the host. Login in container vs login on host =
// independent. Bind mounts BELOW these volumes override the volume's
// contents at the bound sub-paths — Docker mount precedence: more
// specific path wins.
"source=claude-config-${devcontainerId},target=/home/node/.claude,type=volume",
"source=codex-config-${devcontainerId},target=/home/node/.codex,type=volume",
"source=cursor-config-${devcontainerId},target=/home/node/.cursor,type=volume",
// Read-only host stage for post-create.sh to copy credentials +
// identity from on container-create. Kept read-only so a container
// process can't write back to host CLI state files (the write-through
// attack vector). Only the credential/identity files are READ here;
// shareable content is bind-mounted directly RW below, not staged.
"source=${localEnv:HOME}/.claude,target=/host/.claude,type=bind,readonly",
"source=${localEnv:HOME}/.codex,target=/host/.codex,type=bind,readonly",
"source=${localEnv:HOME}/.cursor,target=/host/.cursor,type=bind,readonly",
// Direct RW bind mounts for shareable subdirs + files. These OVERLAY
// the named volume at their target paths, so reads/writes from inside
// the container go straight to host. `/plugin marketplace add` in
// container = installed on host. New skill on host = visible in
// container next read. Trade-off accepted: a compromised npm dep can
// write into host plugin/skill/agent/memory/command dirs.
// Plugins are split: the SOURCE (marketplaces/ git clones + cache/
// extracted plugin files) is path-independent and bind-mounted
// bidirectionally. The REGISTRY (known_marketplaces.json,
// installed_plugins.json, plugin-catalog-cache.json) carries absolute
// OS-specific paths (`C:\Users\X\.claude\plugins\...` on Windows,
// `/Users/x/...` on macOS) so it CANNOT be shared as-is — stays in
// the named volume, generated by post-create.sh from host's versions
// with paths translated to the container's Linux paths.
// DIRECTORY binds — bidirectional, atomic writes inside the dir work
// fine (same filesystem). Sub-path writes from container go to host
// immediately; host changes are visible to container immediately.
//
// Claude: plugins SOURCE dirs (marketplaces/ git clones + cache/
// extracted files) are path-independent; the path-bearing registry
// JSONs at plugins/ root stay in the volume and are translated by
// post-create.sh (see SINGLE-FILE note below).
"source=${localEnv:HOME}/.claude/plugins/marketplaces,target=/home/node/.claude/plugins/marketplaces,type=bind",
"source=${localEnv:HOME}/.claude/plugins/cache,target=/home/node/.claude/plugins/cache,type=bind",
"source=${localEnv:HOME}/.claude/skills,target=/home/node/.claude/skills,type=bind",
"source=${localEnv:HOME}/.claude/agents,target=/home/node/.claude/agents,type=bind",
"source=${localEnv:HOME}/.claude/memory,target=/home/node/.claude/memory,type=bind",
"source=${localEnv:HOME}/.claude/commands,target=/home/node/.claude/commands,type=bind",
//
// Codex: bind the WHOLE plugins/ dir (parent of cache/). `codex plugin
// add` stages installs INSIDE plugins/cache/<marketplace>/ and renames
// intra-dir (verified by strace), so under a single 9p bind of plugins/
// the rename stays intra-9p — no EXDEV. Unlike Claude, Codex has NO
// path-bearing registry file under plugins/ (enablement lives in
// config.toml at the .codex root as git URLs, not FS paths), so no
// translation is needed and the whole dir can be bound. `.tmp/` (the
// ext4 marketplace-clone staging area) is deliberately NOT bound — it
// must stay on the volume as the cross-fs source side.
"source=${localEnv:HOME}/.codex/plugins,target=/home/node/.codex/plugins,type=bind",
"source=${localEnv:HOME}/.codex/prompts,target=/home/node/.codex/prompts,type=bind",
"source=${localEnv:HOME}/.codex/memories,target=/home/node/.codex/memories,type=bind",
"source=${localEnv:HOME}/.codex/skills,target=/home/node/.codex/skills,type=bind",
//
// Cursor: cursor-agent (CLI, not just the IDE) shares the Cursor 2.5
// plugin/rules/commands/agents/skills surface on disk. Bind the SOURCE
// dirs (path-independent). plugins/installed_plugins.json carries
// absolute Windows paths like Claude's registry, so it stays in the
// volume and is translated by post-create.sh — that's why plugins/
// sub-dirs are bound individually rather than the whole plugins/ dir.
// mcp.json + hooks.json are single files (EXDEV-unsafe as binds) and are
// handled as copy-on-create. hooks.json is additionally NOT synced at
// all: Cursor hooks run shell commands on a timer/event with no user
// action, so a poisoned host hooks.json would auto-execute in the
// container. NOTE this is a partial mitigation, not a clean boundary —
// the RW-bound commands/agents/skills/rules dirs (here and for Claude)
// are also instruction/shell-executing surfaces a compromised dep can
// write through to host. The hooks.json exclusion just removes the one
// surface that fires WITHOUT an agent deciding to invoke it; the broader
// write-through trade-off is accepted + documented in README § Trust
// boundary. To fully close it, switch these dirs to copy-on-create too.
"source=${localEnv:HOME}/.cursor/plugins/marketplaces,target=/home/node/.cursor/plugins/marketplaces,type=bind",
"source=${localEnv:HOME}/.cursor/plugins/local,target=/home/node/.cursor/plugins/local,type=bind",
"source=${localEnv:HOME}/.cursor/rules,target=/home/node/.cursor/rules,type=bind",
"source=${localEnv:HOME}/.cursor/commands,target=/home/node/.cursor/commands,type=bind",
"source=${localEnv:HOME}/.cursor/agents,target=/home/node/.cursor/agents,type=bind",
"source=${localEnv:HOME}/.cursor/skills,target=/home/node/.cursor/skills,type=bind",
//
// SINGLE-FILE binds for settings.json / .claude.json / config.toml
// are deliberately ABSENT. On Docker Desktop Windows the named-volume
// is ext4 (/dev/sdd) and a single-file bind from host is 9p drvfs —
// different filesystems. Atomic config writes (write `foo.tmp` ->
// rename onto `foo`) trip EXDEV and fail with `Device or resource
// busy` / `inter-device move failed`. Codex's TUI hits this as
// "config/batchWrite failed in TUI"; Claude silently loses writes
// through the same mechanism. Instead: host stage at /host/.claude
// (RO) + post-create.sh copies these files into the named volume on
// every container-create. Host changes propagate on rebuild;
// container changes stay container-local until rebuild.
"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 + docker are READ-ONLY. The container reads your EXISTING host login
// (the common case), but a compromised in-container dependency can't
// rewrite ~/.config/gh/hosts.yml (your GitHub token) or
// ~/.docker/config.json (registry credHelper -> arbitrary binary). The
// trade-off: `gh auth login` / `docker login` run INSIDE the container
// won't persist back to the host — re-run them on the host, or drop
// `,readonly` on 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",
"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",
"source=commandhistory-${devcontainerId},target=/commandhistory,type=volume",
"source=npm-cache-${devcontainerId},target=/home/node/.npm,type=volume",
"source=${localWorkspaceFolderBasename}-root-node-modules-${devcontainerId},target=/workspace/node_modules,type=volume",
"source=${localWorkspaceFolderBasename}-gitnexus-node-modules-${devcontainerId},target=/workspace/gitnexus/node_modules,type=volume",
"source=${localWorkspaceFolderBasename}-gitnexus-web-node-modules-${devcontainerId},target=/workspace/gitnexus-web/node_modules,type=volume",
"source=${localWorkspaceFolderBasename}-gitnexus-shared-node-modules-${devcontainerId},target=/workspace/gitnexus-shared/node_modules,type=volume"
],
// Interactive login is the default auth path for all three CLIs.
// Credentials live in the per-container named volumes (claude-config /
// codex-config / cursor-config), NOT in the host bind mounts — they are
// copied from the read-only /host/.<cli> stage into the volume on
// container-create (single-file binds would trip EXDEV on Docker Desktop
// Windows). Only shareable content (plugins, skills, agents, memory,
// commands) is RW-bound to the host ~/.claude / ~/.codex / ~/.cursor
// dirs declared in the mounts block above.
// API keys (ANTHROPIC_API_KEY / OPENAI_API_KEY / CURSOR_API_KEY) are NOT
// injected via containerEnv — `${localEnv:VAR}` resolves an unset host var
// to an empty string, and Cursor in particular treats `CURSOR_API_KEY=""`
// as "use this empty key" rather than "fall back to stored login", which
// would silently break `cursor-agent login`. Users who need API key auth
// should `export` the var in their container shell or carry it via their
// VS Code dotfiles repo (see .devcontainer/README.md).
// CLAUDE_CONFIG_DIR is intentionally NOT set. The Claude default is
// `$HOME/.claude` (= `/home/node/.claude`), which is exactly where the
// claude-config named volume mounts — setting the env var explicitly
// changes which file Claude reads `hasCompletedOnboarding` from: with
// the var set, Claude reads `$CLAUDE_CONFIG_DIR/.claude.json` (the
// small identity file); without it, Claude reads `$HOME/.claude.json`
// (the big onboarding-state file that actually carries
// `hasCompletedOnboarding`, MCP user-scope config, and per-project
// trust). Leaving the var unset matches host behavior and lets
// post-create.sh's host-sync of `$HOME/.claude.json` skip the wizard
// on every container-create.
//
// CODEX_HOME is kept (even though it matches the Codex default) as a
// canary: if we ever move the Codex named volume target, the env var
// makes the dependency explicit instead of silently following the
// default.
"containerEnv": {
"CODEX_HOME": "/home/node/.codex",
"DISABLE_AUTOUPDATER": "1",
// post-create.sh strips `installMethod` from the seeded ~/.claude.json so
// the npm-global binary auto-detects its own install method. This is
// belt-and-suspenders for Claude Code issue #17289: the install-checks
// routine probes ~/.local/bin/claude purely because the directory EXISTS
// (it does here — Cursor drops agent/cursor-agent symlinks there) even
// when installMethod is non-native, surfacing a false "claude command not
// found at ~/.local/bin/claude". DISABLE_AUTOUPDATER does NOT gate that
// routine — DISABLE_INSTALLATION_CHECKS is its dedicated kill switch.
"DISABLE_INSTALLATION_CHECKS": "1",
"HISTFILE": "/commandhistory/.zsh_history"
},
"customizations": {
"vscode": {
"extensions": [
"anthropic.claude-code",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"eamodio.gitlens"
],
"settings": {
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"files.eol": "\n",
"terminal.integrated.defaultProfile.linux": "zsh",
"terminal.integrated.profiles.linux": {
"bash": { "path": "bash", "icon": "terminal-bash" },
"zsh": { "path": "zsh" }
}
}
}
},
// 4747 (gitnexus serve) must not be remapped: gitnexus-web hardcodes
// http://localhost:4747 as the default backend URL.
"forwardPorts": [5173, 4747, 4173],
"portsAttributes": {
"5173": {
"label": "Vite dev (gitnexus-web)",
"onAutoForward": "notify"
},
"4747": {
"label": "gitnexus serve HTTP API",
"onAutoForward": "notify",
"requireLocalPort": true
},
"4173": {
"label": "Static web (Vite preview)",
"onAutoForward": "silent"
}
},
// Lifecycle split (per Dev Container spec):
// - `updateContentCommand` runs on container-create AND whenever
// workspace content changes (e.g. lockfile updates). It owns
// workspace dependency installation — re-installing on every
// container-create wastes time when nothing changed, but it must
// re-run when deps shift.
// - `postCreateCommand` runs once on container-create. It owns AI CLI
// credential + identity sync from the host — work that should
// happen exactly once per container instance, not on every content
// update.
// Run both via explicit `bash` so they don't depend on the script's
// executable bit surviving the workspace bind mount.
"updateContentCommand": "bash .devcontainer/install-deps.sh",
"postCreateCommand": "bash .devcontainer/post-create.sh"
}