A single custom Docker container running in WSL2 with NVIDIA GPU passthrough, accessed via SSH from external devices. The container runs Claude CLI and OpenAI Codex CLI as a non-root user with controlled network egress, volume-mounted workspace, and a GitHub machine user for code access.
┌─────────────────────────────────────────────────────┐
│ Windows 11 Host │
│ ┌───────────────────────────────────────────────┐ │
│ │ WSL2 (Ubuntu) │ │
│ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ Docker Container (agentsafe) │ │ │
│ │ │ │ │ │
│ │ │ claude (non-root user) │ │ │
│ │ │ ├── Claude CLI (OAuth) │ │ │
│ │ │ ├── Codex CLI (API key) │ │ │
│ │ │ ├── Python 3 + venv │ │ │
│ │ │ ├── git (machine user, HTTPS PAT) │ │ │
│ │ │ └── sshd (port 2222) │ │ │
│ │ │ │ │ │
│ │ │ Volumes: │ │ │
│ │ │ ├── /workspace ← host bind mount │ │ │
│ │ │ ├── /home/claude/.claude ← config │ │ │
│ │ │ └── /home/claude/.ssh ← authorized_keys│ │ │
│ │ │ │ │ │
│ │ │ Network: │ │ │
│ │ │ ├── ✅ HTTPS outbound (443) │ │ │
│ │ │ ├── ✅ Ollama LAN host (11434) │ │ │
│ │ │ ├── ✅ SSH inbound (2222) │ │ │
│ │ │ ├── ✅ Web app ports (8000-8099) │ │ │
│ │ │ └── ❌ Everything else denied │ │ │
│ │ │ │ │ │
│ │ │ GPU: NVIDIA runtime passthrough │ │ │
│ │ └─────────────────────────────────────────┘ │ │
│ │ │ │
│ │ /mnt/c ← NOT mounted into container │ │
│ └───────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
Multi-stage build borrowing patterns from cabinlab/claude-code-sdk-docker.
- Base:
node:22-slim(Debian). NVIDIA Container Toolkit handles GPU driver mounting at runtime, so no CUDA base image needed. Pre-built PyTorch wheels bundle their own CUDA runtime. - Non-root
claudeuser baked into image - Tools: Python 3, pip, git, curl, openssh-server, common CLI utils
- Claude CLI and Codex CLI installed globally via npm
.claude/scaffolding: hooks dir,.aiexclude, settings- Build deps (build-essential, etc.) stripped via multi-stage
- NVIDIA runtime + GPU device reservation
- Volume mounts: workspace (read/write), Claude config, SSH authorized_keys, GitHub credentials
- Port mappings:
2222:22(SSH),8000-8099(web apps) cap_drop: ALLwith selectivecap_add(see Security Hardening)env_filefor agent API keys (config/.env)- Restart policy:
unless-stopped
Custom Docker network with iptables rules applied in entrypoint:
- Allow: HTTPS outbound (ports 443, 80) for Claude API, web research, pip, GitHub
- Allow: Ollama LAN host IP on configurable port (default 11434, set via
OLLAMA_HOSTenv var, supportsip:portformat) - Allow: SSH inbound on port 22 (container-internal, mapped to 2222 externally)
- Block: All other RFC 1918 LAN ranges (prevents lateral movement)
/mnt/cis never in scope (not mounted)
A dedicated GitHub account with a fine-grained Personal Access Token (PAT):
- Why HTTPS over SSH: Fine-grained PATs can be scoped to specific repos AND specific permissions (e.g.,
contents:writeonly). An SSH key grants full access to whatever repos the machine user collaborates on — no per-permission scoping. - Machine user is added as a collaborator on each repo the agent needs (minimum required role)
- PAT stored on host in
config/git-credentialswith600permissions - Bind-mounted read-only into the container
- Git credential helper configured to read from the mounted file
.aiexcludeprevents Claude from reading the credentials file- Distinct git identity (e.g.,
agentsafe-bot <agentsafe-bot@users.noreply.github.com>) for audit trail - Blast radius if compromised: Only the repos and permissions granted to the PAT. Revoke the token without affecting any personal account.
- Note: GitHub fine-grained PATs can be set to expire (recommended: 1 year for a local sandbox with calendar reminder to rotate). "All repositories" scope is acceptable since the machine user account itself is the containment boundary.
- Branch protection on
mainis recommended to prevent direct merges without approval.
- Run
claude /loginon host to authenticate, which writes~/.claude/.credentials.json - Copy that file to
config/.credentials.jsonwith600permissions - Bind-mounted read-only into the container at a staging path (
.credentials-seed.json) - Entrypoint copies the seed to a writable
.credentials.jsonso Claude CLI can refresh short-lived access tokens using the refresh token - Tokens are re-seeded from the host file on each container restart
- Tradeoff: Any tokens refreshed inside the container are lost on restart. If the host seed's access token has expired, the CLI will use the refresh token to obtain a new one on next launch.
- Not passed as an env var (avoids
docker inspectexposure)
- Run
codex login --device-authinside the container (viascripts/codex.shor SSH) - Prints a URL and code — open the URL in any browser and enter the code to authenticate
- Credentials are stored in
~/.codex/auth.jsoninside the container - Credentials persist until the container is recreated (
docker compose down+uppreserves them;docker compose down -vordocker rmdoes not) - Alternative: Set
OPENAI_API_KEYinconfig/.envto skip OAuth entirely (but misses ChatGPT Plus/Pro API credits)
- Agent API keys and secrets stored in
config/.env(e.g.,OPENAI_API_KEYfor Codex CLI) - Loaded via
env_filein docker-compose.yml - Added to
.aiexcludeto prevent Claude from reading the file - Visible via
docker inspect(acceptable for a local sandbox)
cap_drop: ALLwith only required capabilities added back:CHOWN,FOWNER— fix ownership on mounted volumesSETUID,SETGID— gosu privilege dropSYS_CHROOT— sshd privilege separationDAC_OVERRIDE— sshd reading authorized_keysAUDIT_WRITE— sshd audit logging for PTY sessionsNET_ADMIN,NET_RAW— iptables egress rules
no-new-privilegesis not used (incompatible with sshd setuid on child processes)- No Docker socket mounted (agent cannot control Docker)
.aiexcludepatterns prevent Claude from reading credentials, SSH keys,.env, etc.- Python packages installed via
venv(never--break-system-packages) - sshd: key-only auth, no root login, no password auth,
StrictModes no(required for bind-mounted authorized_keys) - PAM
pam_loginuid.soset tooptional(required for container PTY allocation)
agentsafe/
├── SPEC.md # This file
├── SANDBOX.md # Original requirements
├── Dockerfile # Multi-stage custom image
├── docker-compose.yml # Orchestration + GPU + networking
├── entrypoint.sh # Process setup, sshd, permissions
├── config/
│ ├── .aiexclude # Prevent Claude from reading secrets
│ ├── .credentials.json # Claude OAuth credentials (seed)
│ ├── .env # Agent API keys and secrets
│ ├── authorized_keys # SSH public keys for remote access
│ ├── git-credentials # GitHub machine user PAT
│ ├── settings.json # Claude CLI settings
│ └── sshd_config # Hardened SSH server config
└── scripts/
├── claude.sh # Shortcut: launch Claude CLI in container
├── codex.sh # Shortcut: launch Codex CLI in container
├── setup-host.sh # One-time: install NVIDIA toolkit, create dirs
├── shell.sh # Shortcut: bash shell in container
└── start.sh # Daily: launch the sandbox
| Risk | Severity | Mitigation |
|---|---|---|
| OAuth token in bind-mounted file | Medium | File is 600-permed and .aiexcluded, but root in container could read it. Non-root user + cap-drop reduce this. |
| Agent can exfiltrate data via HTTPS | Medium | Claude needs outbound HTTPS to function. Could add DNS-level filtering (e.g., Pi-hole) but adds complexity. |
| GitHub PAT in bind-mounted file | Medium | Same mitigations as OAuth token. Fine-grained scoping limits blast radius. Set token expiry to 90 days. |
| GPU driver exploits | Low | NVIDIA container runtime is a kernel-level interface. Keep drivers updated. |
| Supply chain (npm/pip packages) | Medium | Agent can pip install anything. Consider a curated allowlist or private PyPI mirror for high-security use. |
| Container escape via kernel exploit | Low | WSL2 kernel + Docker. Keep both updated. Seccomp + cap-drop reduce surface. |
| Web research content injection | Low | Agent processes untrusted web content. Claude's prompt injection resistance is the primary defense. |
- Install NVIDIA Container Toolkit in WSL2 (
scripts/setup-host.sh) - Create GitHub machine user account, generate fine-grained PAT scoped to needed repos
- Build the Docker image:
docker build -t agentsafe . - Run
claude /loginon host, then copy~/.claude/.credentials.jsontoconfig/.credentials.json - Codex auth: Run
scripts/codex.sh login --device-auth(or setOPENAI_API_KEYinconfig/.env) - Save GitHub PAT to
config/git-credentials - Generate SSH keypair for remote access, place public key in
config/authorized_keys - Create workspace directory on host
docker compose up -dto launch- SSH in from phone/laptop:
ssh -p 2222 claude@<wsl-ip>
- cabinlab/claude-code-sdk-docker — Multi-stage Dockerfile patterns, OAuth token handling
- nezhar/claude-container — API traffic logging, UID/GID mapping patterns
- Patrick McCanna: Limiting agent access to secrets
- Claude Code Docker Compose guide
- Claude Code settings
- Composio secure agent setup