opencode-sandbox: OpenCode 2 in a sandbox for any project

Runs OpenCode 2 with full permissions while containing its mistakes:

- egress filter: squid in a container per project; squid.conf is rendered
  from layered egress.conf files (package, user, project, local) with
  allow/block/connect rules and expect-pass/expect-block self-tests
- netlock: nftables rejects direct egress for the opencode-sandbox group
  (installed once with sudo as a systemd unit)
- bubblewrap: read-only host, writable project, worktrees and state;
  credentials hidden; ssh keys via the host's ssh-agent; session bus
  replaced by an xdg-dbus-proxy limited to notifications; no permission
  prompts (sudo and systemd-run denied); refuses to run unsandboxed
- per-project state in ~/.local/share/opencode-sandbox/<name>
- worktrees: `wt add|rm|list` with project hooks
- skills mounted per project; the opencode-sandbox skill explains the
  environment to the agent
- plugins as submodules: context-panel, skill-compact-resurrect;
  global config adds opencode-notifier and cc-safety-net
- README in English and Russian
This commit is contained in:
2026-10-30 01:00:02 -07:00
commit 64c222a084
29 changed files with 1945 additions and 0 deletions
+301
View File
@@ -0,0 +1,301 @@
# opencode-sandbox
**English** · [Русский](README.ru.md)
Let [OpenCode 2](https://opencode.ai) work on your project with full permissions — no
"allow this path?" prompts — while its mistakes stay inside a sandbox.
```bash
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](https://github.com/kenryu42/cc-safety-net) against destructive git/rm commands, desktop notifications |
Linux only: bubblewrap, nftables, systemd, Docker.
## Contents
- [Requirements](#requirements) · [Install](#install) · [Quick start](#quick-start)
- [Project files](#project-files) · [Settings](#settings) · [Network rules](#network-rules)
- [Worktrees](#worktrees) · [Skills and plugins](#skills-and-plugins)
- [How it works](#how-it-works) · [Security model and limits](#security-model-and-limits)
- [Troubleshooting](#troubleshooting) · [Uninstall](#uninstall) · [Commands](#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), `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
```bash
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
```bash
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:
```bash
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](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
```bash
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/](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/](plugins/):
[context-panel](plugins/context-panel) (the "Tokens" panel) and
[skill-compact-resurrect](plugins/skill-compact-resurrect) (skills survive compaction).
- **Global OpenCode config** [opencode/opencode.jsonc](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
```bash
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
```