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

  • Linux with unprivileged user namespaces (bubblewrap must work as a normal user)
  • docker, bwrap (bubblewrap), sg (shadow-utils), curl, jq, ncat (nmap-ncat), git
  • sudo once, to install netlock
  • OpenCode 2: npm i -g @opencode/cli, or a local install referenced by OCSB_OPENCODE_BIN
  • optional: xdg-dbus-proxy for desktop notifications from the sandbox

Install

git clone --recursive <repository-url> ~/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:

  1. package defaults
  2. ~/.config/opencode-sandbox/sandbox.conf — yours, for all projects
  3. .opencode-sandbox/sandbox.conf — the project's
  4. .opencode-sandbox/local/sandbox.conf — yours, for this project
  5. OCSB_<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_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_SKILLS lists skill directories (with a SKILL.md) mounted read-only as the agent's global skills, from skills/ or your OCSB_SKILL_DIRS. The opencode-sandbox skill 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 own opencode.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_HOME, /tmp. Everything else is read-only.
  • Hidden: OCSB_HIDE_HOME, OCSB_HIDE, private keys in ~/.ssh (config, known_hosts and *.pub stay visible; the host's ssh-agent signs).
  • Network: HTTP(S)_PROXY point to the filter. GIT_SSH_COMMAND sends ssh through it with -F ~/.ssh/config (inside the user namespace, root-owned system ssh configs look owned by nobody, and ssh refuses them). DOCKER_CONFIG passes the proxy to builds and containers.
  • OpenCode: own XDG_* directories in the state directory (your ~/.config/opencode is not used); permissions via OPENCODE_CONFIG_CONTENT: everything allowed, sudo and systemd-run denied.
  • Session bus: replaced by xdg-dbus-proxy that talks only to org.freedesktop.Notifications; the real bus would let the agent start processes outside the sandbox with systemd-run --user.
  • Browser: xdg-open is disabled for the agent; the UI is opened by run from your normal session.
  • No silent fallback: with OCSB_SANDBOX=1 and no working bubblewrap, run refuses 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.
  • /tmp is shared with the host.

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
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

License

MIT

S
Description
No description provided
Readme MIT
244 KiB
Languages
Shell 97.6%
Makefile 1.8%
Dockerfile 0.6%