An all-in-one, Docker-based sandboxed environment for running OpenCode with additional development tools and MCP servers pre-installed.
The goal of this project is to create a secure, sandboxed environment - where one can run OpenCode within a workspace, without it touching any non-workspace files on the host.
The security is as strong as the security of the virtualization layer (Docker) itself.
- OpenCode — AI-powered coding agent
- Prettier, Biome, Ruff, Stylelint — Linters and formatters
- ShellCheck, yamllint — Shell and YAML linting
Furthermore some MCP servers are pre-installed, and can be run straight from the image:
- agent-browser — Browser automation via Playwright
- Engram — Persistent memory MCP server
- codebase-memory-mcp — Codebase knowledge graph
You can easily add your own (Python) packages by either placing them in the
extra/ directory, and/or creating a file extra/apt-packages.txt.
Two trust levels are separated:
- User trust — the LLM, and everything inside the workspace (project files,
.env,.memory/state, anything the agent writes and can read). - Host trust — the host operating system, Docker itself, and the files outside the workspace (SSH keys, browser profiles, other projects).
The image enforces the boundary in the user → host direction: normal runs get no access to the host Docker daemon, no mounts outside the workspace, a read-only configuration, and run as a non-root user.
.env(e.g.OPENCODE_SERVER_PASSWORD) is readable. If you keep secrets in it, assume the agent can read them.~/.local/share/opencode/auth.json(LLM API keys) is mounted read-only — it cannot be modified, but it is readable.
Furthermore the opencode configuration directory is mounted read-only, as well
as a ~/.gitconfig file (if present).
Everything inside the workspace is fully readable and writable by the sandbox, by design - including persistent state.
Workshop state under .memory/ persists across runs and influences (future)
sessions: Engram memories (engram.db), the codebase knowledge graph, the
OpenCode session database and prompt history, and DinD images/containers
(.memory/dind/).
Content an agent (or a malicious prompt it processed) writes into memory today
is trusted input for every future session. Deleting .memory/ resets the agent
environment entirely.
The OpenCode configuration directory itself, including skills, tools, and agents, is mounted read-only. This will be shared with the container.
# Enforce the configuration location
OPENCODE_CONFIG_DIR=/home/node/.config/opencode
# Map the original OpenCode config directory as read-only
~/.config/opencode:/home/node/.config/opencode:ro
# If it exists, the authentication file will also be mapped read-only
~/.local/share/opencode/auth.json:/home/node/.local/share/opencode/auth.json:ro
The project directory where opencode-sandbox is started, is mounted as read-write project root ("user trust")
Project-specific files like prompts, sessions, and Docker-in-Docker artifacts,
are stored within the project directory itself, under the .memory directory.
This allows you to store and resume sessions while still having a sandboxed
environment.
$(PROJECT_ROOT)/.memory/codebase-memory-mcp/:/home/node/codebase-memory-mcp/:rw
$(PROJECT_ROOT)/.memory/engram/:/home/node/.engram/:rw
$(PROJECT_ROOT)/.memory/opencode/prompt-history.jsonl:/home/node/.local/state/opencode/prompt-history.jsonl:rw
$(PROJECT_ROOT)/.memory/opencode/opencode.db:/home/node/.local/share/opencode/opencode.db:rw
$(PROJECT_ROOT)/.memory/opencode/opencode.db-shm:/home/node/.local/share/opencode/opencode.db-shm:rw
$(PROJECT_ROOT)/.memory/opencode/opencode.db-wal:/home/node/.local/share/opencode/opencode.db-wal:rw
MCP servers like engram and Playwright live within the container. Configuration files are mapped read-only, if they exist on the host.
~/.gitconfig:/home/node/.gitconfig:ro
~/.agent-browser/config.json:/home/node/.agent-browser/config.json:ro
- Docker
- make
- optional: a working OpenCode configuration
# Use the default .env file, adjust where needed
cp env.example .env
# Build the sandbox image
make imageNote that you can customize the build without editing the Dockerfile by adding
files inside the extra/ directory (not part of this repository):
- Every subdirectory with Python packaging metadata (pyproject.toml, setup.py or setup.cfg) is installed into the sandbox's Python environment.
extra/apt-packages.txt: one Debian system package per line (blank lines and lines starting with#are ignored) - those packages are installed during the build.
On a fresh machine where OpenCode has never been executed, the OpenCode
configuration directory might not exist yet or is still empty. Use the one-time
make run-init for the initial run: it creates the directory if needed and
mounts it read-write, so OpenCode can bootstrap its own configuration files.
Optionally, place a seed opencode.json there in advance — that is the only
file allowed to pre-exist. Anything else blocks the bootstrap run (override with
FORCE=1).
# First time only: bootstrap the OpenCode configuration directory
make run-init
# Run OpenCode in the sandbox
make runAfterwards, use make run as usual.
| Mode | Daemon | Escape reach |
|---|---|---|
make run |
none | sandbox only |
make run-dind |
private rootless daemon | sandbox only |
make run-insecure |
host daemon (socket mount) | game over — avoid |
make run-dind starts a user-namespaced dockerd inside the container
(rootlesskit + slirp4netns + fuse-overlayfs). The agent can build and run images
fully autonomously, but there is no path to the host Docker instance. Images and
containers persist in .memory/dind/.
This is the preferred way if you want to use Docker within the OpenCode environment. It requires unprivileged user namespaces on the host kernel (default on Linux, WSL2 and Docker Desktop).
If startup fails with "operation not permitted":
- Debian family / WSL:
sudo sysctl -w kernel.unprivileged_userns_clone=1 - Ubuntu 24.04+:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
DIND runs relax the outer seccomp filter (seccomp=unconfined) because the
engine default blocks nested namespace/mount primitives, and disable
no-new-privileges (default-on since Engine 25) so the setuid UID-map helpers
work. The sandbox boundaries are unaffected: no host Docker socket, namespaced
daemon, non-root user. Override with DIND_SECURITY_FLAGS=. Without
/dev/fuse, the daemon falls back to the slower vfs storage driver. Override
device flags with DIND_DEVICE_FLAGS= or pass extras via DIND_EXTRA_FLAGS=.
docker compose is not installed; port publishing uses rootlesskit's builtin
port driver.
If startup fails with chmod .../.memory/dind: operation not permitted, the
.memory/dind directory is owned by a different user than the one running make.
The preflight checks catch this before Docker starts: every .memory entry must
be writable by the invoking user (group permissions count), and .memory/dind
must additionally be owned by them, because rootless dockerd chmods its data
root. Fix it once with:
sudo chown -R "$(id -u):$(id -g)" .memory && sudo chmod -R u+rwX .memoryThere is also an insecure version, make run-insecure, which gives the
OpenCode container access to the host Docker daemon. Please note that this is
not secure, and would allow (any process within) OpenCode to break out of the
sandbox easily. Prefer make run-dind.
This will map the following additional files/sockets:
/usr/bin/docker:/usr/bin/docker:ro
/usr/libexec/docker:/usr/libexec/docker:ro
/var/run/docker.sock:/var/run/docker.sock:ro
Configuration is managed via a .env file. Copy the example and adjust:
cp env.example .env| Variable | Default | Description |
|---|---|---|
ALLOW_WEAK_SERVER_CREDENTIALS |
0 |
Set to 1 to allow weak server credentials |
ENGRAM_VERSION |
1.20.0 |
Version of the Engram binary to download |
ENGRAM_SHA256 |
pinned | SHA256 of the Engram tarball (build fails on mismatch) |
BUILDX_VERSION |
0.36.1 |
buildx plugin version (BuildKit inside rootless DinD) |
BUILDX_SHA256_AMD64 |
pinned | SHA256 of the buildx binary (amd64) |
BUILDX_SHA256_ARM64 |
pinned | SHA256 of the buildx binary (arm64) |
GROUP |
default group | Group for container files; baked in as GROUP_ID at build time. Images are tagged per group (latest-g<gid>), so runs always use the image matching the requested group. Rootless DinD requires the runtime gid to match the image's primary group |
HOST_LEMONADE |
(empty) | IP address to map LEMONADE_HOST to (for local dev) |
LEMONADE_HOST |
(empty) | Hostname for --add-host mapping (set in .env) |
SERVER_BIND |
127.0.0.1 |
Bind address for the published server port |
STRICT_TLS |
1 |
0 = INSECURE: disables Node.js TLS verification |
TARGET |
example.com |
Test target hostname for screenshot tests |
OPENCODE_SERVER_USERNAME |
(current user) | Username for make server |
OPENCODE_SERVER_PASSWORD |
(current user) | Password for make server (weak values are refused) |
make image # Build without preflight checks
make run-tests # Run testsmake image stages the exact build inputs into an isolated .build-context/
directory first, so the Docker build never walks the workspace — workspace
entries owned by other users (e.g. .memory/) cannot break the build. The
staging directory is removed after a successful build (kept for inspection when
the build fails; make clean also removes it).
make run-init mounts the directory ~/.config/opencode (skills, agents,
rules) read-write. Anything written there becomes trusted, read-only
configuration for all future sessions. Run it yourself first, review what was
created, then hand autonomy to the agent.
make run # Run OpenCode sandbox (no Docker access)
make run-dind # Run with a private rootless Docker daemon (recommended)
make run-insecure # Run with host Docker socket access (INSECURE, see above)
make latest # Run with "latest" tag
make bash # Start a bash shell in the sandbox
make server # Run OpenCode server (requires OPENCODE_SERVER_PASSWORD)make run-tests # Run the complete tiered chain (default)
make run-tests TYPE=dind # Run only the rootless Docker-in-Docker chain
make run-tests TYPE=updates # Only check for package updates (advisory)
./test.sh [IMAGE_NAME] [TYPE] # Run tests locallyThe full chain runs from cheap to expensive — static checks, local infra, LLM
server reachability, then the agent-browser/playwright end-to-end tests — and
stops when it no longer makes sense to continue: end-to-end steps are gated on
their dependencies passing in the same run. Skipped steps appear in the summary
with a reason, and the exit code reflects real failures only. The dind chain
never touches the host daemon; via make it starts its own private rootless
daemon for you.
| Platform | Notes |
|---|---|
| Linux | Full experience; unprivileged user namespaces required for DinD |
| WSL2 | Same as Linux; may need kernel.unprivileged_userns_clone=1 (Debian) or kernel.apparmor_restrict_unprivileged_userns=0 (Ubuntu 24.04+) |
| macOS / Docker Desktop | No /dev/fuse (DinD falls back to the slower vfs storage driver) and no /dev/net/tun (DinD containers share the sandbox's network namespace instead of getting their own) |
- Build-time downloads (the Engram binary and the buildx plugin) are version- and checksum-pinned: the build fails closed on a mismatched or missing checksum.
- The base image (
node:24-trixie-slim) is pinned by digest — refresh it deliberately with the registry query documented in the Dockerfile.
This project is licensed under the GNU General Public License v3.0 or later. See LICENSE for details.
Copyright (C) 2026 Peter Mosmans