Devcontainer Parity: Lessons from anthropics/claude-code
Status: Proposed — research captured, no implementation committed
Problem
Section titled “Problem”The upstream Claude Code repository ships a working VS Code devcontainer that runs Claude Code inside an isolated Docker environment. Its design predates jackin’s, solves several problems jackin also has, and makes a few different choices that are worth comparing directly:
- upstream bakes a network-egress allowlist into the container image via
iptables+ipsetand apostStartCommandthat runs it withNET_ADMIN/NET_RAWcapabilities - upstream grants
sudoonly to a single setup script, not to the whole session - upstream is itself a devcontainer spec, so it works in VS Code, Cursor, and GitHub Codespaces out of the box
- upstream persists shell history and the
~/.claudedirectory in named Docker volumes parameterized bydevcontainerId
jackin’ already does things the upstream devcontainer can’t — Docker-in-Docker, per-agent networks, workspace mounting, namespaced role repos, DinD TLS, plugin marketplaces, construct/derived-layer split — but several tactical pieces of the upstream setup are genuinely useful and map directly onto existing open items (network policy controls and construct user creation in particular).
This page captures the comparison so those lessons aren’t lost.
Why It Matters
Section titled “Why It Matters”- Network egress is currently unrestricted inside jackin agents. The
network egress policy roadmap
item now owns this product gap; the upstream
init-firewall.shremains a ready-made reference implementation with a sensible default allowlist. - jackin images are not usable as devcontainers. Operators who prefer VS
Code / Cursor / Codespaces for code navigation cannot point those tools at a
jackin-generated image without authoring their own
devcontainer.json. Shipping (or generating) one would widen jackin’s surface without changing the runtime. - The construct’s
claudeuser has blanketNOPASSWD:ALLsudo. Upstream restricts sudo to a single script (init-firewall.sh). jackin’s model is intentionally more permissive because the agent runs under--dangerously-skip-permissions, but scoping sudo is still worth a design pass — especially if a firewall-init hook lands. - Shell history is not persisted across agent recreations. Upstream mounts
a named volume at
/commandhistoryand redirectsHISTFILEthere.
Current State
Section titled “Current State”jackin construct
Section titled “jackin construct”Source: docker/construct/Dockerfile
FROM debian:trixie-20260421@sha256:...— pinned by digest- Installs:
bash,ca-certificates,curl,fd-find,fzf,git,git-lfs,jq,openssh-client,ripgrep,sudo,tree,yq,zsh - Pulls in
mise, Docker CLI + Compose plugin, GitHub CLI - Builds and copies
tirithandshellfirmbinaries from a Rust builder stage - Creates
claudeuser (UID/GID 1000) withclaude ALL=(ALL) NOPASSWD:ALL - oh-my-zsh + zsh-autosuggestions + starship
- Zshrc enables
tirithandshellfirmshell hooks
jackin runtime
Section titled “jackin runtime”Source: docker/runtime/entrypoint.sh
- Git identity from env,
gh auth setup-git - Dispatches on
JACKIN_AGENT(claudeorcodex):- Claude branch: registers
tirithandshellfirmMCP servers, thenexec claude --dangerously-skip-permissions --verbose - Codex branch:
exec codex(config.toml is mounted RW from host; no in-container generation)
- Claude branch: registers
- Optional runtime hooks under
/jackin/runtime/hooks/run (setup-once.sh,source.sh,preflight.sh) before the per-agentexec
jackin agent layer (e.g. jackin-agent-smith, jackin-the-architect)
Section titled “jackin agent layer (e.g. jackin-agent-smith, jackin-the-architect)”- Final stage must be
FROM projectjackin/construct:trixie(validated contract) - Agents layer in their own toolchains via
mise(Rust for the-architect, Node LTS for agent-smith, plus OpenTofu for the-architect) - Plugins declared in
jackin.role.toml, installed in the derived image
Upstream Claude Code devcontainer
Section titled “Upstream Claude Code devcontainer”FROM node:20- Installs:
git,zsh,vim,nano,jq,iptables,ipset,dnsutils,gh,fzf,man-db - Non-root
nodeuser; owns/workspaceand/home/node/.claude;sudoscoped to a single command:/usr/local/bin/init-firewall.sh - zsh + powerline10k, git-delta 0.18.2
- Claude Code installed globally via
npm i -g @anthropic-ai/claude-code devcontainer.json:runArgs: ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"]- Named volumes per
devcontainerId:claude-code-bashhistory-${devcontainerId}→/commandhistoryclaude-code-config-${devcontainerId}→/home/node/.claude
postStartCommand: "sudo /usr/local/bin/init-firewall.sh"waitFor: "postStartCommand"- Extensions:
anthropic.claude-code, ESLint, Prettier, GitLens - Env:
NODE_OPTIONS=--max-old-space-size=4096
init-firewall.sh:- Default DROP on INPUT, FORWARD, OUTPUT
- Allows loopback, established/related, DNS (53), SSH (22), host subnet
ipset allowed-domains(hash:net) seeded from:- GitHub IP meta ranges (aggregated from
api.github.com/meta) registry.npmjs.orgapi.anthropic.comsentry.io,statsig.anthropic.com,statsig.commarketplace.visualstudio.com,vscode.blob.core.windows.net,update.code.visualstudio.com
- GitHub IP meta ranges (aggregated from
- Self-test:
example.commust fail,api.github.commust succeed
Side-by-Side
Section titled “Side-by-Side”| Aspect | Upstream devcontainer | jackin construct + agent |
|---|---|---|
| Base image | node:20 | debian:trixie (pinned by digest) |
| Language runtime in base | Node 20 baked in | None; agents add toolchains via mise |
| Non-root user | node (UID 1000); sudo scoped to firewall script | claude (UID/GID remapped to host); NOPASSWD:ALL |
| Shell | zsh + powerline10k | zsh + oh-my-zsh + zsh-autosuggestions + starship |
| Diff viewer | git-delta 0.18.2 | none in construct |
| Search / fuzzy tools | fzf | fzf, fd-find, ripgrep |
| Security tools | none | tirith, shellfirm (MCP + shell hooks) |
| Docker-in-sandbox | not available | DinD sidecar, per-agent network, TLS |
| Network egress policy | iptables/ipset allowlist via postStartCommand | none at container layer |
| Devcontainer spec | yes (is the product) | no — images not discoverable by VS Code/Cursor/Codespaces |
| Shell history persistence | named volume per devcontainerId | not persisted (inside container layer) |
| Claude config persistence | named volume claude-code-config-* | host bind mount ~/.jackin/data/{name}/.claude |
| GitHub auth persistence | not persisted explicitly | host bind mount ~/.jackin/data/{name}/.config/gh |
| Plugin marketplace | none | jackin.role.toml + derived-image install commands |
| Multi-agent | single environment | namespaced agents, per-workspace selection |
Findings
Section titled “Findings”Finding 1 — Egress allowlist for agent containers
Section titled “Finding 1 — Egress allowlist for agent containers”Gap: Agents have unrestricted outbound network access by default. A compromised plugin, malicious dependency, or prompt-injected instruction can talk to any endpoint on the internet.
Reference implementation: upstream init-firewall.sh is self-contained,
~80 lines, and covers the 90% case (GitHub, npm, Anthropic, statsig, VS Code
update endpoints). Capabilities required: --cap-add=NET_ADMIN,
--cap-add=NET_RAW. Packages required: iptables, ipset, dnsutils,
curl, jq.
Options:
- Port upstream script as an opt-in runtime hook. Land the script under
docker/runtime/(ordocker/construct/so it’s available to every agent) and wire a new env var likeJACKIN_ENABLE_EGRESS_ALLOWLIST=1that flips the entrypoint to run it. Zero default behavior change; operators opt in per launch. - Make it a per-agent declaration. Add a
[network]section tojackin.role.tomlwhere agent authors can declare the required egress list. jackin merges the declared list with an operator-level allowlist inconfig.toml. Cost: schema + merge logic. - Make it a per-workspace declaration. Same mechanism but keyed on workspace instead of agent. Closer to how Docker Sandboxes scopes network policy.
- Host-side proxy (further out). Intercept outbound HTTPS at the DinD network boundary instead of inside the guest. This is the Docker Sandboxes approach and closes the credential-injection gap too, but it is a significantly larger project. Covered in the selectable sandbox backends design.
Recommendation: Start with Option 1 as a single-ship feature, then promote to Option 2 once the shape settles. Option 4 belongs to the larger microvm track.
Finding 2 — Generate a devcontainer.json alongside the derived image
Section titled “Finding 2 — Generate a devcontainer.json alongside the derived image”Gap: jackin builds and runs the image itself; there is no way to point VS Code, Cursor, or Codespaces at the same image for IDE features like Go-to- Definition, debugging, or extension auto-install.
Options:
- Emit
devcontainer.jsonwhen jackin builds the derived image. Write it into the workspace’s.devcontainer/directory (or a jackin-managed shadow dir) so IDEs can consume it. Fields: build target (use the jackin image directly),runArgs, mounts matching what jackin would mount,postStartCommandfor per-start jackin runtime hooks,postCreateCommandonly for one-time initialization, an extensions list curated per agent. - Ship a static reference
devcontainer.jsonin the docs. Operators copy it into their project. Zero product change, lowest cost. Loses coupling to jackin’s actual runtime config. - Skip IDE integration. jackin stays a terminal-first product. Users who want IDE integration use the standalone upstream devcontainer.
Recommendation: Option 1 is the most aligned with jackin’s positioning as a managed runtime, but it is not urgent. Option 2 is a cheap way to validate demand first.
Generated devcontainer.json should use postStartCommand for per-start
runtime setup that must rerun when the container restarts, and reserve
postCreateCommand for one-time image/container initialization. It should set
waitFor to the command that gates a usable agent environment so VS Code and
Cursor do not attach before firewall setup, mounted homes, and runtime
bootstrap are ready. The parity check should compare jackin launch readiness
against devcontainer waitFor semantics, not only generated fields.
Finding 3 — Scope the claude user’s sudo
Section titled “Finding 3 — Scope the claude user’s sudo”Gap: The claude user has NOPASSWD:ALL. Upstream scopes sudo to one
explicit command (/usr/local/bin/init-firewall.sh). jackin’s choice is
defensible because the agent runs under --dangerously-skip-permissions and
is already free to do anything inside the container — but if Finding 1
lands, a firewall-init script is a new reason to allow sudo for one
specific command rather than globally.
Options:
- Leave as-is. Tracks jackin’s “agent is fully autonomous inside the box” model.
- Scope sudo to a curated allowlist (
apt-get,init-firewall.sh, and a small handful of others). Breaks agents that expect to install arbitrary system packages at runtime. - Scope sudo only to firewall init (matches upstream). Most restrictive; likely breaks current agents.
Recommendation: Defer until Finding 1 is implemented. Revisit as part of a broader hardening pass and document the tradeoff in Security model.
Finding 4 — Persist shell history
Section titled “Finding 4 — Persist shell history”Gap: ~/.zsh_history lives in the container’s writable layer and is lost
when the agent is recreated. Upstream mounts a named volume at
/commandhistory and points HISTFILE at it.
Options:
- Bind mount
~/.jackin/data/{name}/.zsh_historyto/home/agent/.zsh_history. Consistent with how other state is persisted. Single line insrc/runtime/launch.rs. - Set
HISTFILEinside the container to a subdir of/home/agent/.jackin(already bind-mounted) and let the existing persistence carry it.
Recommendation: Option 2 is the cheapest. It is a ~5-line change in
zshrc plus a small entrypoint nudge to ensure the directory exists.
Finding 5 — Small construct additions worth considering
Section titled “Finding 5 — Small construct additions worth considering”None of these are gating; they’re quality-of-life notes.
git-delta: upstream uses it; would improve the defaultgit diffexperience inside agents. Low cost.man-db: useful when the agent or operator looks up tool docs. Low cost but meaningful disk impact (man-db+ pages).vim,nano: jackin construct currently has neither. Operators dropping into a running container to inspect state have no editor. Installing at least one is a small ergonomics win.- Deliberate omissions: upstream installs none of
tirith,shellfirm,ripgrep,fd-find,yq,git-lfs,tree, Docker CLI,mise, GitHub CLI. jackin adds all of these on purpose. No change recommended — they are part of what makes jackin’s baseline more useful than a bare devcontainer.
Finding 6 — VS Code extension kit
Section titled “Finding 6 — VS Code extension kit”Gap: upstream bundles anthropic.claude-code, ESLint, Prettier, and
GitLens extensions in devcontainer.json. Any operator who wants the same
kit under jackin has to configure it themselves.
This is downstream of Finding 2 — if jackin emits a devcontainer.json, the
extensions list becomes an obvious payload for it.
Non-Goals
Section titled “Non-Goals”- Replacing jackin’s construct/derived-layer model with a monolithic Node- based image. The split is one of jackin’s core advantages.
- Bundling IDE-specific logic into the core CLI beyond a generated
devcontainer.json. - Claiming parity with Docker Sandboxes’ credential-injection model. That work is tracked under selectable sandbox backends.
Related Files
Section titled “Related Files”docker/construct/Dockerfile— base image, user creation, shell setupdocker/construct/zshrc— shell hooks (touched by Finding 4)docker/runtime/entrypoint.sh— runtime orchestration (touched by Finding 1)src/derived_image.rs— derived image generation and Claude plugin install commandssrc/runtime/launch.rs— mount and capability wiring (touched by Findings 1 and 4)src/derived_image.rs— derived Dockerfile generation (touched by Finding 2)- Security model — security posture (touched by Findings 1 and 3)
- Upstream reference:
anthropics/claude-code/.devcontainer