# 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//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//`: 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_=…` 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` | `../-worktrees` | where worktrees go | | `OCSB_WT_PREFIX` | — | worktree directory = `` | | `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/` | 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 `/` 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- 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 `/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 :` 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 `/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//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 [args] | rm [--force] | list ``` ## License [MIT](LICENSE)