Development container for agents and people, that not allow agents to break your system.
Quick Start • Enviroment variables • Language Version Managment • Using as a devcontainer Tools included •
Development sandbox image based on official Microsoft's devcontainers:base image. Focused on security and zero-configuration setup. Supports majority of languages and agents out of the box.
- Supported languages: Python, Node.js, Bun, C++, Java, C#, F#, .NET Core, PHP, Go
- Supported agents: Claude Code, OpenCode, Gemini CLI, Codex
- Multi-arch:
linux/amd64+linux/arm64(Apple Silicon native). - Language servers (LSP) preinstalled: TypeScript, Python, Java, Go
- MCP servers preinstalled: Context7, codemap, docker-mcp
- Shells preinstalled: bash, fish, zsh (and Oh My Zsh!)
- Version/Package managers preinstalled: nvm, pyenv, rbenv, sdkman, conda, brew
- Enviroment managers: mise, nix, devbox
- Majority of defult tools and packages that need for regular development: git, gh, jq, dvc, make, just, etc.
- All images validated using Trivy Vulnarability Scanner and Hadolint
The base OS is Debian 13 (trixie). The non-root user inside the container is vscode (UID/GID 1000).
Four images are published to the GitHub Container Registry under neolabhq/sandbox:
| Tag | Contents | Use when |
|---|---|---|
neolabhq/sandbox:base |
devcontainers/base:trixie + mise (Node/Python/Go) + nix + devbox + Homebrew + gh CLI + apt top-up list + dvc/yq |
You need a clean multi-language base without agents |
neolabhq/sandbox:agents |
:base + Claude Code + OpenCode + Gemini CLI + Codex + codemap + gopls + pyright + typescript-language-server + docker-mcp |
You want agents and code-intelligence tools without Claude and MCP preconfiguration |
neolabhq/sandbox:latest |
:agents + pre-configured Claude Code settings + entrypoint autodetection |
The default — everything wired up |
neolabhq/sandbox:universal |
:latest + Java + Rust + Zig + .NET SDK (via mise) + PHP + Composer (via apt/installer) + jdtls (Java LSP) |
Drop-in replacement for devcontainers/universal with the broader language stack |
All four variants are published for linux/amd64 and linux/arm64.
No ~/.claude* mounts. Claude Code reads the OAuth token from CLAUDE_CODE_OAUTH_TOKEN, skips the interactive onboarding flow, and all state is discarded when the container exits.
docker run -it --rm \
-v "$PWD:/workspaces/$(basename "$PWD")" \
-e CLAUDE_CODE_OAUTH_TOKEN \
-e ANTHROPIC_API_KEY \
-e CONTEXT7_API_KEY \
-w "/workspaces/$(basename "$PWD")" \
neolabhq/sandbox:latest \
bashThen launch your prefered agent
claudeWithout ~/.claude* mounts, every container starts cold: no command history, no plugin state, no previously registered MCPs. Claude Code authenticates via the token and runs without prompting for onboarding, but there is no continuity between runs. In exchange, you get a hermetic environment that cannot accidentally read or write your host's Claude configuration. This is the recommended pattern for CI and non-interactive contexts.
Minimal configuration. Requires to use devcontainer cli or vscode built-in devcontinaer integration. Devcontainer is pre-installed in VS Code/Claude/Antigravity/etc.
The docker-outside-of-docker feature connects container agents to Docker on your host machine, allowing to start service or use testcontainers.
.devcontainer/devcontainer.json:
Launch devcontainer
devcontainer up --workspace-folder .Check started container name and attach console to it
# List containers
docker ps
# Attach console to it
docker exec -it -u "vscode" -w "/workspace/$workspace_folder_name" "$container_id" bashThen launch your prefered agent
claudeMounts your host ~/.claude/ directory and ~/.claude.json into the container so Claude Code's credentials, settings, command history, and MCP registrations survive across container restarts.
docker run -it --rm \
-v "$PWD:/workspaces/$(basename "$PWD")" \
-v "$HOME/.claude:/home/vscode/.claude" \
-v "$HOME/.claude.json:/home/vscode/.claude.json" \
-e CLAUDE_CODE_OAUTH_TOKEN \
-e ANTHROPIC_API_KEY \
-e CONTEXT7_API_KEY \
-w "/workspaces/$(basename "$PWD")" \
neolabhq/sandbox:latest \
bashThen launch your prefered agent
claudeWhat each flag does:
| Flag | Purpose |
|---|---|
-v "$HOME/.claude:/home/vscode/.claude" |
Persists Claude credentials, settings, plugins, and session state |
-v "$HOME/.claude.json:/home/vscode/.claude.json" |
Persists onboarding state, MCP registrations, and project history — prevents re-onboarding on every start |
-e CLAUDE_CODE_OAUTH_TOKEN |
Passes your OAuth token from the host environment; Claude Code skips the interactive login flow when this is set |
-e ANTHROPIC_API_KEY |
Alternative auth path to the OAuth token |
-e CONTEXT7_API_KEY |
When set, the entrypoint automatically registers the Context7 MCP server at container start |
MCP registration. The container's entrypoint reads CONTEXT7_API_KEY at startup. When the variable is non-empty, it registers the Context7 MCP server automatically. No manual postCreateCommand is required when consuming the published image directly. Set DOCKER_MCP_SERVER to a server identifier (e.g. catalog://mcp/docker-mcp-catalog/paper-search) to additionally register the baked docker-mcp profile pointing to that server.
Prerequisite for ~/.claude.json. If the file does not exist on the host yet, create it before running the container — Docker will create a directory at that path otherwise, which Claude Code will reject:
touch ~/.claude.jsonTrade-off. Mounting ~/.claude* binds the container to your host machine's Claude profile. That is ideal for interactive daily development but undesirable for CI runners or shared environments. For those use cases, see the ephemeral pattern below.
Mount your project directory under /workspaces/ (the container's working directory):
-v "$PWD:/workspaces/$(basename "$PWD")"
-w "/workspaces/$(basename "$PWD")"-v "$HOME/.claude:/home/vscode/.claude"
-v "$HOME/.claude.json:/home/vscode/.claude.json"~/.claude/ contains credentials (.credentials.json), settings, statusline config, and session state.
~/.claude.json stores the onboarding completion flag (hasCompletedOnboarding), MCP server registrations, and project history. Without this file, Claude Code treats every container start as a first run and re-prompts for onboarding. Mount an empty file to suppress re-onboarding even if you do not need the history:
touch ~/.claude.json # run once on the host if the file does not exist yet-v "$HOME/.ssh:/home/vscode/.ssh:ro"
-v "$HOME/.gitconfig:/home/vscode/.gitconfig:ro"The :ro flag prevents the container from modifying your host keys or config.
Mount each project under a sibling path inside /workspaces/:
docker run -it --rm \
-v "$HOME/code/project-a:/workspaces/project-a" \
-v "$HOME/code/project-b:/workspaces/project-b" \
-v "$HOME/code/shared-lib:/workspaces/shared-lib" \
-v "$HOME/.claude:/home/vscode/.claude" \
-v "$HOME/.claude.json:/home/vscode/.claude.json" \
-e CLAUDE_CODE_OAUTH_TOKEN \
-e ANTHROPIC_API_KEY \
-e CONTEXT7_API_KEY \
-w "/workspaces" \
neolabhq/sandbox:latest \
bashHow it works:
- Each project is isolated in its own sub-directory. Agents and LSPs locate project roots by walking up to the nearest
.git,pyproject.toml,go.mod,package.json, etc., so siblings do not bleed into each other. - Cross-project work is enabled because all projects share one container
PATH, oneghauth session, and one Claude session — useful for cross-repository refactors or whenshared-libis a dependency of multiple projects. - For read-only dependencies, add
:roto that specific volume:-v "$HOME/code/shared-lib:/workspaces/shared-lib:ro".
Entrypoint autodetection across multiple projects. The entrypoint walks up from the container's working directory ($PWD). When you cd into a project that contains a devbox.json, mise.toml, .mise.toml, or .tool-versions, the entrypoint will activate the matching project shell on the next container start (or on a docker exec invocation). See the Language version manager stack section for details.
Trade-off. A single shared container is convenient but reduces isolation: a runaway process in one project can affect the others. For full isolation, run a separate container per project, each with its own ~/.claude* mounts.
The primary authentication mechanism. When set, Claude Code skips the OAuth browser flow and uses the token directly.
Obtain a token on any machine where you are already logged in to Claude Code:
claude setup-tokenCopy the printed token and store it in your shell environment (for example in ~/.bashrc or ~/.zshrc, or in a secrets manager):
export CLAUDE_CODE_OAUTH_TOKEN="sk-ant-..."Pass it to the container with -e CLAUDE_CODE_OAUTH_TOKEN (no value needed on the right-hand side — Docker reads it from your current environment). Never hard-code the token value in a docker run command that may appear in shell history or logs.
If you prefer API-key-based auth over OAuth, set ANTHROPIC_API_KEY instead. Claude Code accepts either. Pass it the same way:
-e ANTHROPIC_API_KEYWhen set, the container's entrypoint (/opt/devcontainer/entrypoint.sh) automatically registers the Context7 MCP server at container start:
claude mcp add --scope user --transport http context7 \
https://mcp.context7.com/mcp \
--header "CONTEXT7_API_KEY: <your-key>"
Obtain a key at context7.com. Without this key the entrypoint logs "No MCP env vars detected; skipping MCP registration." and continues — the rest of the image works normally.
When set to a Docker MCP catalog server identifier, the container's entrypoint (/opt/devcontainer/entrypoint.sh) automatically activates the baked docker-mcp CLI plugin at container start by running, in order:
docker mcp feature enable profiles
docker mcp catalog pull mcp/docker-mcp-catalog
docker mcp profile create --name dev-tools \
--server "$DOCKER_MCP_SERVER" \
--connect claude-code
Pass it the same way as the other secrets:
-e DOCKER_MCP_SERVER=catalog://mcp/docker-mcp-catalog/paper-searchThe plugin binary is already installed in the image (~/.docker/cli-plugins/docker-mcp); this variable both gates whether the setup runs at startup and provides the server identifier that the profile create step binds the dev-tools profile to. Each command is best-effort: failures are logged with the [entrypoint] prefix and do not abort container startup. When this variable is unset the entrypoint logs "No MCP env vars detected; skipping MCP registration." (when CONTEXT7_API_KEY is also unset) and continues — the rest of the image works normally.
The image ships three version management tools with distinct, non-overlapping roles.
| Tool | Role | PATH position |
|---|---|---|
mise |
Language runtimes (Node, Python, Go, Java, Ruby, Rust, Zig, .NET) | /usr/local/share/mise/shims — first in PATH |
nix |
Reproducible system CLIs and libraries (pinned via nixpkgs commit) | /home/vscode/.nix-profile/bin |
devbox |
Per-project nix wrapper — consumes the same /nix/store; each repo provides its own devbox.json |
exposes the project's nix profile on PATH when activated |
Why three tools? mise is purpose-built to replace nvm/pyenv/goenv/sdkman with a single CLI and a single mise.toml. Shim resolution works in non-interactive Docker shells without mise activate. nix owns reproducible system CLIs where a devbox.lock (nixpkgs commit hash) provides bit-for-bit reproducibility. devbox is exposed for per-project use; no image-wide devbox.json is shipped.
What is NOT installed: nvm, pyenv, sdkman, rvm, rbenv — these would re-introduce the manager fragmentation that mise is meant to eliminate. Use mise for all language version pinning.
The image-level defaults (set via mise use --global) resolve at build time. Exact resolved versions are recorded in the CI build summary; they are not pinned in the Dockerfile.
The image runs the same setup logic (/opt/devcontainer/setup.sh) from three triggers so autodetection works regardless of how you start the container: docker run, devcontainer flow, docker exec.
setup.sh automatically detects the following and apply only once in container:
- Project runtimes based on the presence of
devbox.json/mise.toml/.mise.toml/.tool-versions, found by walking up from the working directory.devbox.jsonis checked first (runsdevbox install), then the mise files (runsmise install). If no file is present, the image-global runtime versions apply. - MCP servers based on the presence of the
CONTEXT7_API_KEYandDOCKER_MCP_SERVERenvironment variables (delegated toclaude/install-mcp.sh).
Limitation. Only the ENTRYPOINT path wraps your command in mise exec -- / devbox shell --, and that only applies to PID 1. An interactive docker exec shell does not inherit that wrapper — it relies on the mise shims being first on PATH (so node, python3, go, java resolve through the project-pinned versions that setup.sh already installed). For devbox-pinned system CLIs, run devbox shell in the project directory to enter the project's nix profile.
Override language versions for a specific project by dropping a mise.toml at the project root:
# mise.toml at the project root
[tools]
node = "20.11.0"
python = "3.12"
go = "1.22"
[env]
NODE_OPTIONS = "--max-old-space-size=4096"Override system-level CLIs for a specific project by dropping a devbox.json at the project root:
{
"packages": [
"pre-commit@latest",
"lefthook@latest",
"tree-sitter@latest"
],
"shell": {
"init_hook": ["pre-commit install --install-hooks"]
}
}(e) Role boundary. mise owns language runtimes in the project file just as it does at the image level: Node, Python, Go, Java, Ruby, Deno, Bun, etc. devbox owns system CLIs and libraries a project pins via nixpkgs. They compose cleanly because devbox's nix profile entries land on PATH before the mise shims when devbox shell activates, but the mise shims still resolve language binaries because devbox does not install Node, Python, Go, or Java by default.
This section describes how to consume the published image as a devcontainer base in your own project. It is not a description of changes to this repo's .devcontainer/ directory (which is preserved for local development of the sandbox itself).
Minimal configuration. The docker-outside-of-docker feature connects container agents to Docker on your host machine.
.devcontainer/devcontainer.json:
{
"name": "Agent Sandbox",
"image": "neolabhq/sandbox:latest",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"moby": false
}
},
"remoteUser": "vscode",
"containerEnv": {
"CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}",
"ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}",
"CONTEXT7_API_KEY": "${localEnv:CONTEXT7_API_KEY}"
}
}For projects that want MCP servers proxied from the host's Docker MCP Catalog, extend the quick-setup example with an explicit MCP catalog mount and the DOCKER_MCP_SERVER variable:
.devcontainer/devcontainer.json:
{
"name": "Agent Sandbox",
"image": "neolabhq/sandbox:latest",
"features": {
"ghcr.io/devcontainers/features/docker-outside-of-docker:1": {
"moby": false
}
},
"mounts": [
"source=${localEnv:HOME}/.docker/mcp,target=/home/vscode/.docker/mcp,type=bind,consistency=cached"
],
"remoteUser": "vscode",
"containerEnv": {
"CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}",
"CONTEXT7_API_KEY": "${localEnv:CONTEXT7_API_KEY}",
"DOCKER_MCP_SERVER": "catalog://mcp/docker-mcp-catalog/paper-search"
}
}Every CI run also publishes SHA-suffixed immutable tags alongside the moving ones:
neolabhq/sandbox:base-<sha>
neolabhq/sandbox:agents-<sha>
neolabhq/sandbox:latest-<sha>
neolabhq/sandbox:universal-<sha>
These tags are never overwritten. If a moving tag regresses — whether from a change in this repo or an upstream Microsoft rebuild flowing through the floating base:trixie pin — restore service by re-tagging the last known-good SHA variant back to the moving tag:
docker buildx imagetools create \
-t neolabhq/sandbox:latest \
neolabhq/sandbox:latest-<previous-good-sha>This operation is atomic at the registry level and requires no rebuild. The same pattern applies to :base, :agents, and :universal.
For an upstream Microsoft regression (the floating base:trixie tag rebuilt with a bad layer), the build-base CI job records the resolved upstream digest as an OCI annotation on :base. Emergency-pin Dockerfile.base to mcr.microsoft.com/devcontainers/base:trixie@sha256:<last-good> recorded there, then revert to the floating tag once upstream stabilizes.
Language runtimes are managed by mise — a single Rust-based meta version manager that replaces nvm, pyenv, goenv, and sdkman with one CLI and one mise.toml. The mise shims directory is prepended to PATH so node, python3, go, java and dotnet resolve through mise first in any shell (interactive, non-interactive, docker exec, CI) without requiring mise activate.
| Image | Language | Manager | Default selector |
|---|---|---|---|
:base and above |
Node.js | mise |
node@lts — current Node LTS at build time |
:base and above |
Python | mise |
python@latest — current stable Python 3 at build time |
:base and above |
Go | mise |
go@latest — current stable Go at build time |
:universal only |
Java | mise |
java@temurin-25 — current Eclipse Temurin 25 at build time |
:universal only |
Ruby | mise |
ruby@latest — current stable Ruby at build time |
:universal only |
Rust | mise |
rust@latest — current stable Rust at build time |
:universal only |
Zig | mise |
zig@latest — current stable Zig at build time |
:universal only |
PHP | apt | Current stable PHP from Debian trixie's archive |
:universal only |
.NET SDK | Microsoft apt repo | Current LTS .NET SDK; verify at build time |
The exact resolved versions depend on when the image was built.
| Tool | Purpose | Verify |
|---|---|---|
mise |
Language runtime version manager | mise --version && mise current |
nix |
Reproducible system CLI package manager | nix --version && nix-env --version |
devbox |
Per-project nix wrapper | devbox version |
| Homebrew (Linuxbrew) | Cross-cutting CLI tooling | brew --version |
| Agent | Command | Installed via |
|---|---|---|
| Claude Code | claude |
Official claude.ai/install.sh installer |
| OpenCode | opencode |
Official opencode.ai/install installer |
| Gemini CLI | gemini |
npm install -g @google/gemini-cli (requires Node LTS) |
| Codex (OpenAI) | codex |
npm install -g @openai/codex |
pi and oh-my-pi are planned additions. Install commands are deferred until upstream project sources are verified; the spec's CI build summary records their status.
| Server | Transport | Registration |
|---|---|---|
| Context7 | HTTP (https://mcp.context7.com/mcp) |
Registered at container start by the entrypoint when CONTEXT7_API_KEY is set |
| codemap | Stdio (binary at /usr/local/bin/codemap) |
Baked into :agents; feeds project structure context to agents |
| docker-mcp | CLI plugin (~/.docker/cli-plugins/docker-mcp) |
Baked into :agents; activated at container start by the entrypoint when DOCKER_MCP_SERVER is set to a server identifier (e.g. catalog://mcp/docker-mcp-catalog/paper-search) |
| LSP | Language | Command | Verify | Image |
|---|---|---|---|---|
| gopls | Go | gopls |
gopls version |
:agents and above |
| pyright | Python | pyright |
pyright --version |
:agents and above |
| jdtls (Eclipse JDT) | Java | jdtls |
jdtls --help |
:universal only — co-located with the Temurin JVM it needs to launch |
| typescript-language-server | TypeScript / JavaScript | typescript-language-server |
typescript-language-server --version |
:agents and above |
| Tool | Purpose |
|---|---|
gh |
GitHub CLI |
jq |
JSON processor |
dvc |
Data version control |
yq |
YAML / JSON processor |
bun |
Alternative JS runtime and package manager |
just (rust-just) |
Task runner |
git |
Version control |
build-essential |
C/C++ compiler toolchain (gcc, g++, make) |
{ "name": "Agent Sandbox", "image": "neolabhq/sandbox:latest", "features": { "ghcr.io/devcontainers/features/docker-outside-of-docker:1": { "moby": false } }, "remoteUser": "vscode", "containerEnv": { "CLAUDE_CODE_OAUTH_TOKEN": "${localEnv:CLAUDE_CODE_OAUTH_TOKEN}", "ANTHROPIC_API_KEY": "${localEnv:ANTHROPIC_API_KEY}", "CONTEXT7_API_KEY": "${localEnv:CONTEXT7_API_KEY}" } }