Running the Claude Code agent inside the dev-site container
Workflow update steps (see @saflib/new-workflows) can be driven directly by an agent instead of printing prompts for a human to relay — kickoff --run=claude on the CLI, or the equivalent agentConfig on a run started through dev-site. For that to work from dev-site itself (a browser hitting the dev-site container in base/dev), the container needs a working claude CLI, not just the host machine.
Mechanism
claudeis installed in the dev-site-docker image (dev-site/dev-site-docker/Dockerfile.template,npm install -g @anthropic-ai/claude-code).The container runs as the image's non-root
nodeuser, not root.--dangerously-skip-permissions(which theclaude-agentadapter always passes, so a headless run never blocks on a permission prompt) refuses to run as root outright. Running as non-root generally is also just better hygiene for a container that executes an agent with permissions skipped — it's what contains the blast radius, not the OAuth reuse below.The host's OAuth session is reused, not a separate API key. A Claude Pro/Max login lives in the macOS Keychain (or a plain
~/.claude/.credentials.jsonfile on Linux, since there's no keychain in a container).scripts/resolve-claude-credentials.sh(in this folder) extracts whichever one the host has into a gitignored.claude-credentials.jsonnext to the compose files, which is bind-mounted read-write into the container at/home/node/.claude/.credentials.json. It runs on everynpm run dev(wired intodev-compose.sh/dev-site-compose.sh), never prints the credential value, and soft-fails to an empty placeholder if the host isn't logged in —docker compose upstill comes up either way, just without a working agent.The repo mount is read-write.
/repoused to be mounted:ro(the dev-site scan/commit features only ever read it); it's now read-write so an agent's file edits land on the same checkout the host sees. The mount still covers exactly one thing —DEV_SITE_REPO_MOUNT, the product + saflib monorepo checkout — nothing else on the host is exposed.
Why this is reasonably safe
- Scope is unchanged, just no longer read-only. The agent's reach inside the container is
/repo(product + saflib, same as the read-only mount already exposed),/data(dev-site's own sqlite), and the container's own disposable image filesystem. No SSH keys, no other repos or projects, no Docker socket, no other host paths are mounted in. - Non-root contains the container-local blast radius. Even with permissions skipped, the agent can't touch root-owned files inside the container itself.
- The container holds a copy of the session, not the Keychain. If you ever suspect it's been misused,
claude logout(or just re-login) on the host rotates the underlying token; the container's stale copy stops working, and the nextnpm run devpushes a fresh one. The copy itself is gitignored and never leaves the machine. - This is the same risk profile as running
--dangerously-skip-permissionslocally — a supported, common pattern for sandboxed dev environments — not a new category of exposure. If anything, containerizing it narrows what the agent can reach compared to running the same flag directly on the host.
Known tradeoff, not a security issue: OAuth refresh tokens typically rotate on use. If the host CLI and the container's CLI both refresh around the same time, one can invalidate the other's session — a usability hiccup (re-run npm run dev, or re-login on host), not a vulnerability.
See base/security/threat-model.md's Operator hygiene section for the broader guidance this extends: coding agents running on a machine raise the stakes of everything else on that machine (SSH keys, other credentials) being reachable — the containerization here is what keeps this specific agent's reach bounded to the repo it's meant to edit.