ResearchRuntime

Devcontainer Parity: Lessons from anthropics/claude-code

Status: Proposed — research captured, no implementation committed

Host-affordance note: the construct image now includes xclip, xauth, and wl-clipboard because Amp and other Linux CLIs expect standard clipboard helper binaries. These packages do not make a macOS host clipboard available inside the container by themselves; host clipboard image paste remains part of the Host Affordance Bridge and requires an explicit host-side bridge.

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, 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 + ipset and a postStartCommand that runs it with NET_ADMIN/NET_RAW capabilities
  • upstream grants sudo only 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 ~/.claude directory in named Docker volumes parameterized by devcontainerId

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

  • Network egress is currently unrestricted inside jackin agents. The network egress policy roadmap item now owns this product gap; the upstream init-firewall.sh remains 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 surface without changing the runtime.
  • The construct's claude user has blanket NOPASSWD:ALL sudo. Upstream restricts sudo to a single script (init-firewall.sh). jackin 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 /commandhistory and redirects HISTFILE there.

Current State

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 tirith and shellfirm binaries from a Rust builder stage
  • Creates claude user (UID/GID 1000) with claude ALL=(ALL) NOPASSWD:ALL
  • oh-my-zsh + zsh-autosuggestions + starship
  • Zshrc enables tirith and shellfirm shell hooks

jackin runtime

Source: crates/jackin-capsule/src/runtime_setup.rs and docker/runtime/entrypoint.sh

  • jackin-capsule runtime-setup performs git identity / GitHub setup, per-agent durable-home seeding, auth handoff refreshes, and Claude tirith / shellfirm MCP registration.
  • The shell entrypoint dispatches on JACKIN_AGENT, runs optional runtime hooks under /jackin/runtime/hooks/ (setup-once.sh, source.sh, preflight.sh), and then execs the selected agent.

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

  • FROM node:20
  • Installs: git, zsh, vim, nano, jq, iptables, ipset, dnsutils, gh, fzf, man-db
  • Non-root node user; owns /workspace and /home/node/.claude; sudo scoped 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}/commandhistory
      • claude-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.org
      • api.anthropic.com
      • sentry.io, statsig.anthropic.com, statsig.com
      • marketplace.visualstudio.com, vscode.blob.core.windows.net, update.code.visualstudio.com
    • Self-test: example.com must fail, api.github.com must succeed

Side-by-Side

AspectUpstream devcontainerjackin construct + agent
Base imagenode:20debian:trixie (pinned by digest)
Language runtime in baseNode 20 baked inNone; agents add toolchains via mise
Non-root usernode (UID 1000); sudo scoped to firewall scriptclaude (UID/GID remapped to host); NOPASSWD:ALL
Shellzsh + powerline10kzsh + oh-my-zsh + zsh-autosuggestions + starship
Diff viewergit-delta 0.18.2none in construct
Search / fuzzy toolsfzffzf, fd-find, ripgrep
Security toolsnonetirith, shellfirm (MCP + shell hooks)
Docker-in-sandboxnot availableDinD sidecar, per-agent network, TLS
Network egress policyiptables/ipset allowlist via postStartCommandnone at container layer
Devcontainer specyes (is the product)no — images not discoverable by VS Code/Cursor/Codespaces
Shell history persistencenamed volume per devcontainerIdnot persisted (inside container layer)
Claude config persistencenamed volume claude-code-config-*host bind mount ~/.jackin/data/{name}/.claude
GitHub auth persistencenot persisted explicitlyhost bind mount ~/.jackin/data/{name}/.config/gh
Plugin marketplacenonejackin.role.toml + derived-image install commands
Multi-agentsingle environmentnamespaced agents, per-workspace selection

Findings

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:

  1. Port upstream script as an opt-in runtime hook. Land the script under docker/runtime/ (or docker/construct/ so it's available to every agent) and wire a new env var like JACKIN_ENABLE_EGRESS_ALLOWLIST=1 that flips the entrypoint to run it. Zero default behavior change; operators opt in per launch.
  2. Make it a per-agent declaration. Add a [network] section to jackin.role.toml where agent authors can declare the required egress list. jackin merges the declared list with an operator-level allowlist in config.toml. Cost: schema + merge logic.
  3. Make it a per-workspace declaration. Same mechanism but keyed on workspace instead of agent. Closer to how Docker Sandboxes scopes network policy.
  4. 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

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:

  1. Emit devcontainer.json when 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, postStartCommand for per-start jackin runtime hooks, postCreateCommand only for one-time initialization, an extensions list curated per agent.
  2. Ship a static reference devcontainer.json in the docs. Operators copy it into their project. Zero product change, lowest cost. Loses coupling to jackin actual runtime config.
  3. 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 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

Gap: The claude user has NOPASSWD:ALL. Upstream scopes sudo to one explicit command (/usr/local/bin/init-firewall.sh). jackin 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:

  1. Leave as-is. Tracks jackin "agent is fully autonomous inside the box" model.
  2. 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.
  3. 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

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 there.

This is lower priority than agent conversation history. The upstream devcontainer conflates two things:

  • Shell command history (~/.zsh_history) — what the operator or agent typed at a zsh prompt. Low value: the agent's prompts are driven by the conversation, not typed manually; the operator rarely drops into the container shell.
  • Agent conversation history — every prompt the operator sent to Claude/Codex/Amp/Kimi/OpenCode and every answer the agent gave back. High value: this is the actual work record.

jackin already preserves agent conversation history through the per-agent home bind mounts (~/.jackin/data/{name}/home/.claude, .codex, etc.) — the conversation files are on the host and survive eject. The deeper gap — cross-agent browsing, surviving prune, seeing history across instances — is tracked under the Déjà Vu roadmap item, not here.

Shell history persistence (the literal zsh HISTFILE) is a low-effort quality-of-life improvement independent of the hardening contract:

  1. Bind mount ~/.jackin/data/{name}/.zsh_history to /home/agent/.zsh_history via a single line in crates/jackin-runtime/src/runtime/launch.rs.
  2. Or redirect HISTFILE in zshrc to a path already bind-mounted (e.g. under /jackin/state/).

Recommendation: Defer until after Déjà Vu V1. A bound HISTFILE adds minimal value if the operator never manually shells into the container. Track it as a small quality-of-life item. The Déjà Vu item is the correct home for everything worth keeping from an agent session.

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 default git diff experience 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 baseline more useful than a bare devcontainer.

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.

Connection to the Docker Runtime Hardening Contract

The findings here are inputs to the broader Docker Runtime Hardening Contract. Specifically:

  • Finding 1 (iptables egress allowlist) maps to the api_only network tier in the capability taxonomy. The upstream init-firewall.sh is the V1 reference implementation. In jackin, this tier is configurable: the operator assembles the allowlist from agent endpoints + explicit additions rather than using a hard-coded Anthropic-specific list. The NET_ADMIN/NET_RAW capabilities required for iptables are implicitly granted when network = "api_only" is selected.
  • Finding 3 (scoped sudo) maps to the agent_scoped_sudo user privilege tier in the capability taxonomy — a tier between "no sudo" and "full sudo" where sudo is permitted only for named commands. network = "api_only" implicitly selects this tier so the firewall init script can run.
  • Finding 4 (shell history vs. conversation history) — the upstream conflates two concerns. Shell history (~/.zsh_history) is a trivial bind-mount addition, deferred. Agent conversation history — the actual work record — is already partially preserved by the per-agent home bind mounts (~/.claude, .codex, etc.) and is tracked in depth under Déjà Vu, which covers cross-agent browsing, surviving prune, and the full extraction/normalization story. Both are independent of the hardening contract.

The key distinction: the upstream devcontainer makes these choices as a fixed configuration for a single use case. jackin makes them configurable: every upstream choice becomes a toggle, and the session contract shows the operator exactly what was granted.

Non-Goals

  • Replacing jackin construct/derived-layer model with a monolithic Node- based image. The split is one of jackin 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.

On this page