Skip to content

Isolated dev container environments for supply chain attack mitigation #48

@radiosilence

Description

@radiosilence

Problem

Any npm install, mix deps.get, or cargo build can execute arbitrary code on the host machine. A single malicious package in the dependency tree gets full access to:

  • SSH keys (1Password agent socket)
  • AWS credentials (aws-vault session or IAM creds)
  • GitHub tokens
  • Browser cookies/sessions
  • Claude API keys
  • Everything else in $HOME

Real-world examples: event-stream (CVE-2018-16492), ua-parser-js (CVE-2021-27292), colors/faker sabotage, the eslint-scope npm token theft. This isn't theoretical — it's routine.

Goal: A single task dev <project-name> command that boots an isolated, credential-scoped container environment in seconds. Base image has zero baked-in secrets. Runtime credential injection is minimal, scoped, and gated behind hardware confirmation (passkey/Touch ID) where possible.

Design Principles

  1. Zero-trust base image — no credentials, tokens, or secrets in the image. Ever.
  2. Runtime-only credential injection — secrets passed as env vars or agent socket mounts at docker run time
  3. Minimal credential scope — read-only npm tokens for install, ssh-agent forwarding (not key copying), short-lived AWS sessions
  4. Hardware-gated access — 1Password SSH agent w/ passkey confirmation, aws-vault w/ Keychain, Touch ID sudo
  5. Fast startup — must be <5s from cold, <1s warm. Docker + OrbStack is the path
  6. Claude Code native — AI assistant must work seamlessly inside the container
  7. Taskfile orchestrationtask dev <project> is the only interface

Architecture

Runtime: Docker on OrbStack

Why Docker over alternatives:

Option Boot time macOS FS perf Tooling maturity Isolation
Docker + OrbStack ~1s Near-native (VirtioFS) Excellent Good (namespaces + seccomp)
Apple Containers ~1s Unknown Very new, limited Better (full VM)
Vagrant/VMs 30-60s Poor (shared folders) Mature but heavyweight Best
K8s pods ~2-5s Volume-dependent Overkill for local Good

Apple Containers (macOS container CLI, Virtualization.framework) are interesting but too immature for daily driving. Worth revisiting in 12 months. OrbStack gives us Docker with near-native perf and optional k8s if we want it later.

K8s option — don't dismiss entirely. OrbStack has built-in k8s. A future phase could define dev environments as k8s Deployments with PVCs, which would give us:

  • Resource limits (CPU/memory per project)
  • Network policies (restrict container egress)
  • PVC-backed persistent volumes (better than host mounts for isolation)
  • Same abstraction whether running locally or on a remote dev server

Base Image Strategy

Multi-stage Dockerfile, language-specific layers on a common base:

┌─────────────────────────────────┐
│  devbox-base                    │
│  Ubuntu 24.04 + mise + zsh +   │
│  task + common tools            │
├─────────────────────────────────┤
│  devbox-ts     (node, bun, pnpm)│
│  devbox-elixir (erlang, elixir) │
│  devbox-rust   (rustup, cargo)  │
│  devbox-go     (go toolchain)   │
│  devbox-full   (all of above)   │
└─────────────────────────────────┘

All images are public-registry-safe — nothing proprietary, no auth tokens, no private package access.

Container Filesystem Layout

/workspace/              ← project source (bind mount or named volume)
/home/dev/               ← user home
/home/dev/.config/       ← tool configs (mounted read-only from host where safe)
/home/dev/.ssh/          ← EMPTY, ssh-agent socket mounted at runtime
/home/dev/.claude/       ← claude config (mounted at runtime)
/run/secrets/            ← runtime-injected secrets (tmpfs)

Persistence Strategy

Two modes, project-dependent:

  1. Bind mount (-v $(pwd):/workspace) — for active development, host editor access. Tradeoff: host FS is exposed.
  2. Named volume (-v project-data:/workspace) — for higher isolation. Code stays in the volume, accessed only via container. Use docker cp or git to sync.

Recommendation: bind mount for dev, named volume for untrusted dependency work (e.g., auditing a new package).

Credential Injection

SSH Agent Forwarding (1Password)

# macOS 1Password SSH agent socket
docker run \
  -v "$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock:/run/ssh-agent.sock:ro" \
  -e SSH_AUTH_SOCK=/run/ssh-agent.sock \
  devbox-ts

Every git push, ssh connection etc. triggers a passkey/Touch ID prompt on the host. The container never sees a private key.

GitHub CLI

# Generate a scoped token and inject
GITHUB_TOKEN=$(gh auth token) docker run -e GITHUB_TOKEN devbox-ts

# Or mount gh config read-only
docker run -v "$HOME/.config/gh:/home/dev/.config/gh:ro" devbox-ts

AWS (via aws-vault)

# Short-lived session credentials, never long-lived keys
aws-vault exec my-profile -- docker run \
  -e AWS_ACCESS_KEY_ID \
  -e AWS_SECRET_ACCESS_KEY \
  -e AWS_SESSION_TOKEN \
  -e AWS_REGION \
  devbox-ts

Session tokens expire (typically 1hr). Even if exfiltrated, damage window is limited.

npm / Private Registry Auth

# Read-only automation token, injected at runtime
docker run -e NPM_TOKEN="$(op read 'op://Dev/npm-token/credential')" devbox-ts

Inside the container, .npmrc references the env var:

//registry.npmjs.org/:_authToken=${NPM_TOKEN}

The token is never written to the image or filesystem.

Hex (Elixir)

docker run -e HEX_API_KEY="$(op read 'op://Dev/hex-key/credential')" devbox-elixir

Claude Code

docker run \
  -e ANTHROPIC_API_KEY="$(op read 'op://Dev/anthropic-key/credential')" \
  -v "$HOME/.claude:/home/dev/.claude" \
  devbox-ts

Claude Code can also operate from the host talking to the container via docker exec:

# Claude on host, executing commands in container
claude --exec "docker exec -i devbox-myproject"

This is potentially the better model — Claude stays on the host with full context, but all code execution happens in the container.

Safe Package Manager Defaults

npm / pnpm (baked into base image .npmrc)

# CRITICAL: prevent arbitrary code execution during install
ignore-scripts=true
audit-level=moderate
package-lock=true
fund=false
update-notifier=false

When a project legitimately needs lifecycle scripts (native modules etc.), explicitly opt in per-project:

# In the project's package.json or .npmrc
# pnpm v10+ has allowedScripts for granular control

Mix / Hex (baked into base image config)

# ~/.config/mix/config.exs or project config
# mix hex.audit on every deps.get

Key risks: Mix compiles dependencies, which can execute arbitrary Elixir at compile time. Container isolation is the primary mitigation here — there's no ignore-scripts equivalent.

Cargo

# ~/.cargo/config.toml baked into image
[net]
git-fetch-with-cli = true  # uses ssh-agent, not stored keys

# Run cargo-audit as part of build
# Run cargo-vet for supply chain review

build.rs scripts run during compilation — same risk as npm postinstall. Container isolation is the mitigation.

Go

# Baked into image environment
GONOSUMCHECK=""  # enforce checksum verification for ALL modules
GOFLAGS="-mod=readonly"  # prevent unexpected go.mod changes
GOPROXY="https://proxy.golang.org,direct"
GONOSUMDB=""  # use sumdb for everything

Container Security Hardening

docker run \
  --security-opt=no-new-privileges \
  --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  --read-only \
  --tmpfs /tmp:rw,noexec,nosuid \
  --tmpfs /run:rw,noexec,nosuid \
  --tmpfs /home/dev/.cache:rw,noexec,nosuid \
  --network=bridge \
  --memory=8g \
  --cpus=4 \
  devbox-ts

Key hardening:

  • no-new-privileges — can't escalate via setuid/setgid
  • cap-drop=ALL — drop all Linux capabilities
  • read-only root filesystem with tmpfs for writable paths
  • Resource limits prevent crypto mining / DoS
  • Consider --network=none for pure offline builds, switch to bridge when needed

Claude Code Integration

Option A: Claude inside the container

  • Install Claude Code in the base image via curl -fsSL https://claude.ai/install.sh | bash
  • Mount ANTHROPIC_API_KEY at runtime
  • Full filesystem access within container only
  • MCP servers would need to be configured inside the container

Option B: Claude on host, exec into container (recommended)

  • Claude Code runs on host with full editor/context integration
  • Commands execute via docker exec into the running container
  • Host Claude config stays on host, container is just an execution sandbox
  • Configure via Claude Code hooks or custom shell wrapper

Option C: Hybrid

  • Claude on host for planning/editing
  • Claude inside container for execution-heavy tasks (tests, builds)
  • Use MCP or a simple HTTP bridge between host and container Claude instances

Recommendation: Start with Option B. It keeps the UX seamless (Zed/terminal Claude works normally) while sandboxing execution. The container just needs to be a running target for docker exec.

Taskfile Interface

# Taskfile.yml additions
tasks:
  dev:
    desc: Start isolated dev environment for a project
    vars:
      PROJECT: '{{.CLI_ARGS}}'
      LANG: '{{default "ts" .LANG}}'
      IMAGE: 'devbox-{{.LANG}}'
      CONTAINER: 'devbox-{{.PROJECT}}'
    cmds:
      - |
        # Check if container already running
        if docker ps --format '{{`{{.Names}}`}}' | grep -q "^{{.CONTAINER}}$"; then
          docker exec -it {{.CONTAINER}} zsh
          exit 0
        fi
        
        # Start new container
        docker run -dit \
          --name {{.CONTAINER}} \
          --hostname {{.CONTAINER}} \
          --security-opt=no-new-privileges \
          --cap-drop=ALL \
          --cap-add=NET_BIND_SERVICE \
          -v "$(pwd)/{{.PROJECT}}:/workspace" \
          -v "$HOME/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock:/run/ssh-agent.sock:ro" \
          -e SSH_AUTH_SOCK=/run/ssh-agent.sock \
          -e GITHUB_TOKEN="$(gh auth token)" \
          -e NPM_TOKEN="$(op read 'op://Dev/npm-token/credential' 2>/dev/null || true)" \
          -e ANTHROPIC_API_KEY="$(op read 'op://Dev/anthropic-key/credential' 2>/dev/null || true)" \
          -e HEX_API_KEY="$(op read 'op://Dev/hex-key/credential' 2>/dev/null || true)" \
          -w /workspace \
          {{.IMAGE}}
        
        docker exec -it {{.CONTAINER}} zsh

  dev:stop:
    desc: Stop a dev environment
    vars:
      PROJECT: '{{.CLI_ARGS}}'
      CONTAINER: 'devbox-{{.PROJECT}}'
    cmds:
      - docker stop {{.CONTAINER}} && docker rm {{.CONTAINER}}

  dev:build:
    desc: Build dev container images
    cmds:
      - docker build -t devbox-base -f containers/Dockerfile.base containers/
      - docker build -t devbox-ts -f containers/Dockerfile.ts containers/
      - docker build -t devbox-elixir -f containers/Dockerfile.elixir containers/
      - docker build -t devbox-rust -f containers/Dockerfile.rust containers/
      - docker build -t devbox-go -f containers/Dockerfile.go containers/
      - docker build -t devbox-full -f containers/Dockerfile.full containers/

  dev:list:
    desc: List running dev environments
    cmds:
      - docker ps --filter "name=devbox-" --format "table {{`{{.Names}}`}}\t{{`{{.Status}}`}}\t{{`{{.Image}}`}}"

  dev:exec:
    desc: Execute a command in a dev environment
    vars:
      PROJECT: '{{index (splitList " " .CLI_ARGS) 0}}'
      CMD: '{{join " " (rest (splitList " " .CLI_ARGS))}}'
    cmds:
      - docker exec -it devbox-{{.PROJECT}} {{.CMD}}

Implementation Plan

Phase 1: Foundation

  • Create containers/ directory in dotfiles
  • Write Dockerfile.base — Ubuntu 24.04, mise, zsh, task, safe .npmrc, common tools
  • Write Dockerfile.ts — node, bun, pnpm via mise
  • Write Dockerfile.elixir — erlang, elixir via mise
  • Add Taskfile tasks: dev, dev:stop, dev:build, dev:list
  • Test SSH agent forwarding with 1Password
  • Test npm install with ignore-scripts=true + token injection
  • Document in README

Phase 2: Polish

  • Write Dockerfile.rust and Dockerfile.go
  • Write Dockerfile.full (all languages)
  • Add container security hardening (cap-drop, read-only, etc.)
  • Add dev:exec for Claude Code host→container execution
  • Add per-project config (.devbox.yml or similar) for custom env vars, ports, volumes
  • Add port forwarding support for web dev (e.g., -p 3000:3000)
  • Test Claude Code inside container

Phase 3: Advanced

  • Evaluate Apple Containers when tooling matures
  • Evaluate k8s-based approach (OrbStack k8s) for multi-project isolation with network policies
  • Named volume mode for high-security dependency auditing
  • Network policy profiles (offline-build, registry-only, full-network)
  • Devcontainer spec compatibility (.devcontainer/devcontainer.json) for editor integration
  • Auto-detect project language from mise.toml/package.json and select image automatically
  • Warm image cache / pre-pull strategy for instant cold starts

Phase 4: Claude-Native Workflow

  • Claude Code MCP server for container management (start/stop/exec)
  • Claude Code hooks that auto-route execution to the project's container
  • Headless Claude Code inside container for background tasks (tests, linting)
  • .claude/settings.json container-aware configuration

Open Questions

  1. Bind mount vs named volume as default? — Bind mount is more convenient (host editor access) but exposes the host FS. Named volume is more secure but requires docker cp or git for file access. Could default to bind mount and offer task dev:secure <project> for named-volume mode.

  2. Network isolation granularity — Should the default allow outbound internet (for npm install, mix deps.get)? Probably yes for dev, but a --network=none mode for pure offline builds would be valuable.

  3. How to handle ports? — Web dev needs port forwarding. Could auto-detect from package.json scripts or require explicit config.

  4. Per-project config format? — A .devbox.yml in the project root? Or use the devcontainer spec for compatibility?

  5. Image registry — Build locally only? Or push to GHCR for faster cold starts on new machines?

  6. 1Password CLI vs env varsop read is elegant but requires 1Password CLI auth inside or on host. Env var injection from host op is simpler.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions