- run (on exit) and doctor scan the OpenCode log for 'Read-only file system' / EROFS, map the agent's XDG mirror back to the home directory and print a ready OCSB_RW_HOME line; writable, hidden and protected project paths are skipped - docs/known-issues.md: open gaps and planned work (Docker, blocklist egress, DNS, shared network namespace, resource limits, undo, planted repos, credentials, agents working around guards); linked from both READMEs
340 lines
19 KiB
Markdown
340 lines
19 KiB
Markdown
# 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 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
|
|
|
|
```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. `sandbox.conf` and the hooks are bash that runs on your host, so
|
|
only trusted projects are loaded: `init` trusts the project it creates; in a clone, read
|
|
`.opencode-sandbox/` and run `opencode-sandbox trust` (the list is
|
|
`~/.config/opencode-sandbox/projects`). A `.opencode-sandbox/` in a worktree or a
|
|
subdirectory is ignored.
|
|
|
|
## 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_DISPLAY` | `0` | `1`: the agent's windows (e.g. a Playwright browser) on your desktop through the Wayland socket |
|
|
| `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:
|
|
|
|
```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` and a private `/tmp`. Everything else is read-only.
|
|
- **Private `/tmp`:** the host's `/tmp` holds 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 `/tmp` paths would point at the host's `/tmp` — mount project paths.
|
|
- **Read-only inside the project:** what the host runs outside the sandbox — `.opencode-sandbox/`,
|
|
`.cc-safety-net/`, `.git/hooks`, `.git/config` (`core.fsmonitor` there runs on any
|
|
`git status`, a shell prompt is enough), `core.hooksPath` if it is in the project — and the
|
|
egress rules and safety-net home in the state directory. `.git` itself is a mount point, so
|
|
it cannot be renamed and replaced. Git still works; only `git config` writes fail (e.g.
|
|
`push -u` cannot record the upstream). After each session `run` reports any other `.git` in
|
|
the project and worktrees, worktrees whose `.git` points elsewhere and per-worktree
|
|
overrides; `doctor` does the same.
|
|
- **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`.
|
|
- **Display:** by default none — `DISPLAY`, `WAYLAND_DISPLAY` and `XAUTHORITY` are empty and the
|
|
X cookie is hidden; run browsers headless. With `OCSB_DISPLAY=1` the agent gets only the
|
|
Wayland socket (and `/dev/dri` for the GPU): its windows appear on your desktop, but unlike
|
|
X11 a Wayland client cannot read other windows or inject input into them. X11 is never
|
|
passed in. `xdg-open` is disabled; 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
|
|
|
|
Open gaps and planned work: [docs/known-issues.md](docs/known-issues.md).
|
|
|
|
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.
|
|
- **Files you run yourself.** Hooks, configs and git metadata are protected (see above), but
|
|
the agent edits code: a changed `Makefile`, `package.json` script or `bin/` file runs with
|
|
your rights when you run it. Look at `git diff` before running project commands on the host.
|
|
- **Planted repositories are reported, not prevented.** A `.git` the agent creates in a new
|
|
directory or a new worktree is found by the check after the session; don't run git there
|
|
until you have looked.
|
|
- With `OCSB_TMP=shared` the host's `/tmp` and its sockets are reachable.
|
|
- With `OCSB_DISPLAY=1` the agent can open windows on your desktop and use the clipboard while
|
|
its window is focused.
|
|
|
|
## Troubleshooting
|
|
|
|
| Symptom | Cause and fix |
|
|
|---|---|
|
|
| `Read-only file system`, an MCP server or tool fails to start | path outside the writable set. After each session (and in `doctor`) `run` lists the read-only paths found in the OpenCode log with a ready line, e.g. `OCSB_RW_HOME+=(.local/share/pnpm)`; add it to `sandbox.conf` (or `local/sandbox.conf`) and restart. Elsewhere: `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: set `OCSB_DISPLAY=1` (Wayland session) or 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
|
|
|
|
```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 trust [dir] trust an existing .opencode-sandbox/ (a clone)
|
|
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](LICENSE)
|