- /tmp: the agent gets its own (<state>/tmp, files older than 7 days are removed on start) — the host's /tmp holds sockets that lead out of the sandbox (tmux, X11, browsers' singleton sockets, other tools). OCSB_TMP=shared keeps the old behaviour. /tmp is mounted before the project and state binds so they stay visible if they live under /tmp - DISPLAY, WAYLAND_DISPLAY, XAUTHORITY are empty in the sandbox and ~/.Xauthority is hidden; browsers must run headless - GIT_SSH_COMMAND uses -F ~/.ssh/config: in the user namespace root-owned system ssh configs look owned by nobody and ssh refuses them - netlock self-test checks that the proxy opens the tunnel, not that a remote site answers; failed self-tests now say so instead of exiting silently - doctor: no false error for opencode outside a project - skill and READMEs: worktrees only for code changes, private /tmp, headless browsers, troubleshooting entries
16 KiB
opencode-sandbox
English · Русский
Let OpenCode 2 work on your project with full permissions — no "allow this path?" prompts — while its mistakes stay inside a sandbox.
cd my-project
opencode-sandbox init # once per project
opencode-sandbox run # filter → netlock → self-tests → OpenCode web UI
What you get
| Filesystem isolation | bubblewrap: the agent writes only to the project, its worktrees and paths you allow. The rest of the system is read-only; SSH keys, cloud credentials, ~/.kube/config, browser profiles and other agents' configs are hidden |
| Network filter | all HTTP(S) goes through squid with per-project rules; production and private networks are unreachable unless you allow them |
| Netlock | nftables rejects anything that bypasses the filter (raw TCP, IP literals, ping, plain SSH) at the kernel level |
| No permission prompts | inside the sandbox the agent is allowed everything except sudo and systemd-run |
| Worktrees | one session — one worktree, created and removed by opencode-sandbox wt, with project hooks (ports, .env, starting a stack) |
| Extras | "Tokens" panel in the web UI (context composition, spend, model speed), skills restored after compaction, cc-safety-net against destructive git/rm commands, desktop notifications |
Linux only: bubblewrap, nftables, systemd, Docker.
Contents
- Requirements · Install · Quick start
- Project files · Settings · Network rules
- Worktrees · Skills and plugins
- How it works · Security model and limits
- Troubleshooting · Uninstall · Commands
Requirements
- Linux with unprivileged user namespaces (bubblewrap must work as a normal user)
docker,bwrap(bubblewrap),sg(shadow-utils),curl,jq,ncat(nmap-ncat),gitsudoonce, to install netlock- OpenCode 2:
npm i -g @opencode/cli, or a local install referenced byOCSB_OPENCODE_BIN - optional:
xdg-dbus-proxyfor desktop notifications from the sandbox
Install
git clone --recursive https://git.neotokai.ru/public/opencode-sandbox.git ~/opencode-sandbox
cd ~/opencode-sandbox
make install # links opencode-sandbox into ~/.local/bin, fetches plugins
opencode-sandbox doctor # checks dependencies and host setup
The first run installs netlock with sudo (or do it yourself:
opencode-sandbox netlock setup). It adds you to the group opencode-sandbox; no re-login
is needed.
Update: git pull && make plugins.
Quick start
cd my-project
opencode-sandbox init # creates .opencode-sandbox/ and .cc-safety-net/policy.json
$EDITOR .opencode-sandbox/egress.conf # allow the hosts your project needs
ssh-add # only if the agent needs git over SSH
opencode-sandbox run
run brings up the egress filter, checks netlock, runs self-tests and starts OpenCode. The
browser opens at http://127.0.0.1:4196/; log in as opencode with the password from
~/.local/share/opencode-sandbox/<project>/password (printed on the first start).
Commit .opencode-sandbox/ (except local/) and .cc-safety-net/ — everyone who clones the
project gets the same sandbox.
Project files
my-project/
├── .opencode-sandbox/
│ ├── sandbox.conf settings (bash) commit
│ ├── egress.conf network rules commit
│ ├── hooks/
│ │ ├── wt-post-add after a worktree is created commit
│ │ └── wt-pre-rm before a worktree is removed commit
│ └── local/ personal overrides, git-ignored
│ ├── sandbox.conf
│ └── egress.conf
└── .cc-safety-net/policy.json exceptions for cc-safety-net commit
Per-project state lives outside the repository, in
~/.local/share/opencode-sandbox/<name>/: OpenCode's sessions database, caches, the web UI
password, the Docker client config, the rendered squid.conf.
Settings
sandbox.conf is a bash file. Layers, later wins:
- package defaults
~/.config/opencode-sandbox/sandbox.conf— yours, for all projects.opencode-sandbox/sandbox.conf— the project's.opencode-sandbox/local/sandbox.conf— yours, for this projectOCSB_<NAME>=…in the environment — one run (scalars only)
opencode-sandbox config prints the effective values. OCSB_PROJECT_DIR is set when the
files are read, so you can use it in values. Relative paths are relative to the project;
~/ works.
| Setting | Default | Meaning |
|---|---|---|
OCSB_NAME |
project directory name | name of the state directory and the filter container |
OCSB_WEB_PORT |
4196 |
web UI port; OpenCode itself listens on the next one |
OCSB_EGRESS_PORT |
3128 |
filter port on 127.0.0.1 and the docker0 gateway |
OCSB_OPENCODE_BIN |
opencode from PATH |
OpenCode 2 binary |
OCSB_ENV_FILE |
— | KEY=VALUE file with tokens for MCP servers and the model provider |
OCSB_KUBECONFIG |
— | kubeconfig for the agent (~/.kube/config stays hidden) |
OCSB_ENV |
() |
extra KEY=VALUE for OpenCode |
OCSB_RW |
() |
extra writable paths |
OCSB_HIDE |
() |
extra hidden paths |
OCSB_RW_HOME |
.cache .npm .asdf .bundle .gem … |
writable toolchain directories in ~ (if they exist) |
OCSB_HIDE_HOME |
.gnupg .aws .kube/config .claude … |
hidden paths in ~ |
OCSB_SKILLS |
(opencode-sandbox) |
skills mounted for the agent |
OCSB_SKILL_DIRS |
() |
extra directories with skills |
OCSB_PLUGINS |
skill-compact-resurrect | plugin files from plugins/ (or absolute paths) |
OCSB_PANEL |
1 |
"Tokens" panel in the web UI |
OCSB_NOTIFICATIONS |
1 |
desktop notifications through a filtered D-Bus proxy |
OCSB_TMP |
private |
private: the agent's own /tmp in the state directory; shared: the host's /tmp |
OCSB_SANDBOX |
1 |
0 = filter and netlock only, no bubblewrap |
OCSB_WORKTREES_DIR |
../<project>-worktrees |
where worktrees go |
OCSB_WT_PREFIX |
— | worktree directory = <prefix><slug> |
OCSB_WT_BASE |
current HEAD |
branch new worktrees start from |
OCSB_WT_SLUG_REGEX |
— | regex with one group: slug from the branch name |
OCSB_OPENCODE_CONFIG |
~/.config/opencode-sandbox/opencode.jsonc or the package's |
global OpenCode config |
OCSB_STATE_DIR |
~/.local/share/opencode-sandbox/<name> |
state directory |
OCSB_NETLOCK_GROUP |
opencode-sandbox |
netlock group |
OCSB_DOCKER_GW |
address of docker0 | where containers reach the filter |
Example — a project with internal services, a tokens file and worktrees:
OCSB_NAME=shop
OCSB_WEB_PORT=4296 # different ports let several projects run at once
OCSB_EGRESS_PORT=3138
OCSB_ENV_FILE=~/.config/shop/agent.env
OCSB_HIDE=(../shop-secrets)
OCSB_ENV=("SHOP_ENV=dev")
OCSB_WT_PREFIX=shop-wt-
OCSB_WT_BASE=main
OCSB_WT_SLUG_REGEX='SHOP-([0-9]+)'
Network rules
Blacklist policy: what is not blocked is allowed. Always blocked unless allowed explicitly: private networks (10/8, 172.16/12, 192.168/16, 100.64/10, link-local, loopback), names without a dot, ports other than 80 and 443.
allow jira.example.com gitlab.example.com # wins over block
allow-regex ^[a-z0-9-]+\.staging\.example\.com$
block .example.com # the domain and all subdomains
block-regex ^prod-
connect git.example.com:2222 10.0.0.5:6443 # tunnels: git over SSH, Kubernetes API
expect-block https://prod.example.com/ # self-tests run by `run` and `egress verify`
expect-pass https://gitlab.example.com/
expect-pass https://10.0.0.5:6443/version -k # extra curl options after the URL
Order of evaluation: connect → non-80/443 ports denied → allow → block → names without a
dot and private networks denied → everything else allowed.
Layers (rules add up): egress/defaults.conf →
~/.config/opencode-sandbox/egress.conf → .opencode-sandbox/egress.conf →
.opencode-sandbox/local/egress.conf.
Where to put company hosts. In the project's egress.conf: it lives in your company's
repository, so a new colleague gets working rules with git clone. Anything that must not be
committed goes to local/egress.conf. The package itself contains no company hosts.
Changes apply on the next run, or right away with opencode-sandbox egress up (reloads
squid without a restart). egress render shows the generated squid.conf, egress logs
follows every request and refusal.
Worktrees
opencode-sandbox wt add feature-SHOP-42-cart --seed # args after the branch go to the hook
opencode-sandbox wt rm 42 # refuses if there are uncommitted changes
opencode-sandbox wt rm 42 --force # discards them
opencode-sandbox wt list
wt add creates <OCSB_WORKTREES_DIR>/<OCSB_WT_PREFIX><slug> on a new branch from
OCSB_WT_BASE, then runs hooks/wt-post-add inside it. wt rm runs hooks/wt-pre-rm, checks
for uncommitted changes and removes the worktree (the branch is kept). Hooks receive
WT_DIR, WT_BRANCH, WT_SLUG, MAIN_ROOT and the extra arguments; a failing hook aborts the
command. Templates: hooks/*.example after init.
The agent is told to use worktrees only for changing code on a new branch — not for questions, search or review.
Skills and plugins
- Skills.
OCSB_SKILLSlists skill directories (with aSKILL.md) mounted read-only as the agent's global skills, from skills/ or yourOCSB_SKILL_DIRS. Theopencode-sandboxskill explains the environment to the agent: what is writable, what a proxy 403 means, how to ask for a host, how to use worktrees. - Plugins of this package are git submodules in plugins/: context-panel (the "Tokens" panel) and skill-compact-resurrect (skills survive compaction).
- Global OpenCode config opencode/opencode.jsonc adds
opencode-notifier and cc-safety-net. Replace it with
~/.config/opencode-sandbox/opencode.jsonc. The project's ownopencode.jsonc(model, MCP servers, project plugins) is used as usual.
How it works
opencode-sandbox run
├─ docker: opencode-sandbox-egress-<name> squid, host network, 127.0.0.1 + docker0
├─ xdg-dbus-proxy session bus limited to notifications
└─ sg opencode-sandbox netlock: this group reaches only loopback and DNS
└─ bwrap read-only /, writable project + worktrees + state
└─ opencode serve :4197 proxy env, own XDG dirs, permissions = allow
browser → :4196 (context panel inside OpenCode) → :4197
Inside the sandbox:
- Writable: the project, the worktrees directory, the state directory,
OCSB_RW,OCSB_RW_HOMEand a private/tmp. Everything else is read-only. - Private
/tmp: the host's/tmpholds sockets that lead out of a sandbox (tmux, X11, browsers' singleton sockets, other tools), so the agent gets its own, stored in<state>/tmp(files older than 7 days are removed on start). Look there for its screenshots and logs. Docker mounts of/tmppaths would point at the host's/tmp— mount project paths. - Hidden:
OCSB_HIDE_HOME,OCSB_HIDE, private keys in~/.ssh(config,known_hostsand*.pubstay visible; the host's ssh-agent signs). - Network:
HTTP(S)_PROXYpoint to the filter.GIT_SSH_COMMANDsends ssh through it with-F ~/.ssh/config(inside the user namespace, root-owned system ssh configs look owned bynobody, and ssh refuses them).DOCKER_CONFIGpasses the proxy to builds and containers. - OpenCode: own
XDG_*directories in the state directory (your~/.config/opencodeis not used); permissions viaOPENCODE_CONFIG_CONTENT: everything allowed,sudoandsystemd-rundenied. - Session bus: replaced by
xdg-dbus-proxythat talks only toorg.freedesktop.Notifications; the real bus would let the agent start processes outside the sandbox withsystemd-run --user. - No display:
DISPLAY,WAYLAND_DISPLAYandXAUTHORITYare empty and the X cookie is hidden, so GUI programs cannot reach your desktop; run browsers (Playwright MCP, Chrome) headless.xdg-openis disabled; the UI is opened byrunfrom your normal session. - No silent fallback: with
OCSB_SANDBOX=1and no working bubblewrap,runrefuses to start.
Netlock (netlock setup, sudo) creates the group opencode-sandbox, adds you to it and
installs opencode-sandbox-netlock.service: an nftables table that lets sockets of this group
reach 127.0.0.0/8, ::1 and the DNS servers from resolv.conf, and rejects everything else.
OpenCode runs with this group, so the agent and everything it starts are covered.
Security model and limits
The goal is to contain an agent that makes mistakes — wrong paths, rm -rf in the wrong
place, requests to production — not to hold a determined attacker.
- Docker is a way out. The agent can use the Docker socket, and Docker can mount any host
path (
docker run -v /:/host). Don't give the agent Docker if that matters to you; a filtering socket proxy or rootless Podman would close this. - Shared kernel. bubblewrap is namespaces, not a VM.
- Allowed hosts are a channel. Data can leave through any host the rules allow.
- The project is writable. Mistakes inside it are real; cc-safety-net blocks the common destructive commands, git keeps history — commit often.
- With
OCSB_TMP=sharedthe host's/tmpand its sockets are reachable.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
Read-only file system |
path outside the writable set. Add it to OCSB_RW if the agent really needs it |
proxy 403, CONNECT tunnel failed |
host not allowed: add allow (or connect host:port for non-443) to egress.conf, run opencode-sandbox egress up |
git over SSH: Permission denied (publickey) |
no keys in ssh-agent: ssh-add before run |
git over SSH: 403 |
add connect <host>:<port> to egress.conf |
port … is busy |
another project or an old instance: set other OCSB_WEB_PORT / OCSB_EGRESS_PORT |
netlock is NOT working |
opencode-sandbox netlock setup, then netlock verify |
bubblewrap cannot create a sandbox |
unprivileged user namespaces are disabled (kernel.unprivileged_userns_clone, AppArmor userns restrictions) |
| no "Tokens" panel | open port 4196, not 4197; see <state>/data/opencode/log/context-panel.log |
| no notifications | install xdg-dbus-proxy |
browser tools fail (Missing X server, Failed to connect to Wayland display) |
no display in the sandbox: run the browser headless (Playwright MCP: --headless) |
can't find a file the agent put in /tmp |
it is in ~/.local/share/opencode-sandbox/<name>/tmp |
docker build fails pulling images |
the proxy reaches registries through squid: check egress logs |
Uninstall
opencode-sandbox egress down # in each project
opencode-sandbox netlock teardown # sudo; the group is kept: sudo groupdel opencode-sandbox
cd ~/opencode-sandbox && make uninstall
rm -rf ~/.local/share/opencode-sandbox # state: sessions, caches, passwords
docker rmi opencode-sandbox-egress
Commands
opencode-sandbox init add .opencode-sandbox/ to the current repository
opencode-sandbox config effective settings
opencode-sandbox doctor dependencies and host setup
opencode-sandbox run [opencode args] everything, then OpenCode
opencode-sandbox prepare filter + netlock + self-tests, without OpenCode
opencode-sandbox egress up|down|status|logs|verify|render
opencode-sandbox netlock setup|teardown|status|verify
opencode-sandbox wt add <branch> [args] | rm <name|path> [--force] | list