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
+1
View File
@@ -0,0 +1 @@
# nothing generated lives in the repo; state is in ~/.local/share/opencode-sandbox
+6
View File
@@ -0,0 +1,6 @@
[submodule "plugins/context-panel"]
path = plugins/context-panel
url = ssh://git@git.neotokai.ru:2222/public/opencode2-context-panel.git
[submodule "plugins/skill-compact-resurrect"]
path = plugins/skill-compact-resurrect
url = ssh://git@git.neotokai.ru:2222/public/skill-compact-resurrect.git
+3
View File
@@ -0,0 +1,3 @@
# Settings are shared between sourced files, and bash config files set them
disable=SC2034
source-path=SCRIPTDIR
+30
View File
@@ -0,0 +1,30 @@
PREFIX ?= $(HOME)/.local
BIN := $(PREFIX)/bin/opencode-sandbox
.PHONY: help install uninstall plugins lint
help:
@echo "make install link bin/opencode-sandbox into $(PREFIX)/bin and fetch plugins"
@echo "make uninstall remove the link"
@echo "make plugins fetch or update plugins (git submodules)"
@echo "make lint bash -n and shellcheck"
install: plugins
@mkdir -p $(dir $(BIN))
ln -sfn $(CURDIR)/bin/opencode-sandbox $(BIN)
@echo "✓ $(BIN) → $(CURDIR)/bin/opencode-sandbox"
@case ":$$PATH:" in *":$(PREFIX)/bin:"*) ;; *) echo "⚠ $(PREFIX)/bin is not in PATH";; esac
uninstall:
rm -f $(BIN)
plugins:
git submodule update --init --recursive
lint:
@for f in bin/opencode-sandbox lib/*.sh egress/entrypoint.sh shim/xdg-open templates/project/hooks/*.example; do \
bash -n $$f || exit 1; done
@if command -v shellcheck >/dev/null; then \
shellcheck -x bin/opencode-sandbox lib/*.sh egress/entrypoint.sh shim/xdg-open; \
else echo "shellcheck not installed — syntax only"; fi
@echo "✓ lint"
+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
```
+301
View File
@@ -0,0 +1,301 @@
# opencode-sandbox
[English](README.md) · **Русский**
Дайте [OpenCode 2](https://opencode.ai) работать над проектом с полными правами — без
вопросов «разрешить этот путь?», — а его ошибки пусть остаются внутри песочницы.
```bash
cd my-project
opencode-sandbox init # один раз на проект
opencode-sandbox run # фильтр → netlock → самопроверки → веб-интерфейс OpenCode
```
## Что это даёт
| | |
|---|---|
| **Изоляция файлов** | bubblewrap: агент пишет только в проект, его worktree и разрешённые вами пути. Остальная система — только чтение; SSH-ключи, облачные учётки, `~/.kube/config`, профили браузеров и конфиги других агентов скрыты |
| **Сетевой фильтр** | весь HTTP(S) идёт через squid с правилами проекта; прод и внутренние сети недоступны, пока вы их не разрешите |
| **Netlock** | nftables на уровне ядра отклоняет всё, что идёт в обход фильтра: прямой TCP, IP-адреса, ping, обычный SSH |
| **Без подтверждений** | внутри песочницы агенту разрешено всё, кроме `sudo` и `systemd-run` |
| **Worktree** | одна сессия — одно дерево; создаются и удаляются командой `opencode-sandbox wt`, с хуками проекта (порты, `.env`, запуск стека) |
| **Дополнительно** | панель «Токены» в веб-интерфейсе (состав контекста, расход, скорость модели), скилы после compaction возвращаются, [cc-safety-net](https://github.com/kenryu42/cc-safety-net) против разрушительных git/rm, уведомления на рабочий стол |
Только Linux: bubblewrap, nftables, systemd, Docker.
## Содержание
- [Требования](#требования) · [Установка](#установка) · [Быстрый старт](#быстрый-старт)
- [Файлы проекта](#файлы-проекта) · [Настройки](#настройки) · [Сетевые правила](#сетевые-правила)
- [Worktree](#worktree) · [Скилы и плагины](#скилы-и-плагины)
- [Как это устроено](#как-это-устроено) · [Модель безопасности и ограничения](#модель-безопасности-и-ограничения)
- [Решение проблем](#решение-проблем) · [Удаление](#удаление) · [Команды](#команды)
## Требования
- Linux с непривилегированными user namespaces (bubblewrap должен работать от обычного пользователя)
- `docker`, `bwrap` (bubblewrap), `sg` (shadow-utils), `curl`, `jq`, `ncat` (nmap-ncat), `git`
- `sudo` один раз — для установки netlock
- OpenCode 2: `npm i -g @opencode/cli` или локальная установка, путь — в `OCSB_OPENCODE_BIN`
- по желанию: `xdg-dbus-proxy` — уведомления из песочницы
## Установка
```bash
git clone --recursive <адрес-репозитория> ~/opencode-sandbox
cd ~/opencode-sandbox
make install # ссылка opencode-sandbox в ~/.local/bin, плагины
opencode-sandbox doctor # проверка зависимостей и настроек хоста
```
Первый `run` ставит netlock через `sudo` (или вручную: `opencode-sandbox netlock setup`).
Вас добавят в группу `opencode-sandbox`; перелогиниваться не нужно.
Обновление: `git pull && make plugins`.
## Быстрый старт
```bash
cd my-project
opencode-sandbox init # создаст .opencode-sandbox/ и .cc-safety-net/policy.json
$EDITOR .opencode-sandbox/egress.conf # разрешите хосты, которые нужны проекту
ssh-add # только если агенту нужен git по SSH
opencode-sandbox run
```
`run` поднимает фильтр, проверяет netlock, прогоняет самопроверки и запускает OpenCode.
Браузер откроется на `http://127.0.0.1:4196/`: логин `opencode`, пароль — в
`~/.local/share/opencode-sandbox/<проект>/password` (выводится при первом запуске).
Закоммитьте `.opencode-sandbox/` (кроме `local/`) и `.cc-safety-net/` — каждый, кто
склонирует проект, получит ту же песочницу.
## Файлы проекта
```
my-project/
├── .opencode-sandbox/
│ ├── sandbox.conf настройки (bash) в git
│ ├── egress.conf сетевые правила в git
│ ├── hooks/
│ │ ├── wt-post-add после создания worktree в git
│ │ └── wt-pre-rm перед удалением worktree в git
│ └── local/ личные переопределения, не в git
│ ├── sandbox.conf
│ └── egress.conf
└── .cc-safety-net/policy.json исключения cc-safety-net в git
```
Состояние проекта лежит вне репозитория, в `~/.local/share/opencode-sandbox/<имя>/`:
база сессий OpenCode, кэши, пароль веб-интерфейса, конфиг Docker-клиента, сгенерированный
squid.conf.
## Настройки
`sandbox.conf` — файл bash. Слои, побеждает последний:
1. умолчания пакета
2. `~/.config/opencode-sandbox/sandbox.conf` — ваши, для всех проектов
3. `.opencode-sandbox/sandbox.conf` — проекта
4. `.opencode-sandbox/local/sandbox.conf` — ваши, для этого проекта
5. `OCSB_<ИМЯ>=…` в окружении — на один запуск (только скаляры)
`opencode-sandbox config` показывает итоговые значения. Когда файлы читаются,
`OCSB_PROJECT_DIR` уже задан — его можно использовать в значениях. Относительные пути —
от проекта; `~/` работает.
| Настройка | По умолчанию | Что задаёт |
|---|---|---|
| `OCSB_NAME` | имя каталога проекта | имя каталога состояния и контейнера фильтра |
| `OCSB_WEB_PORT` | `4196` | порт веб-интерфейса; сам OpenCode — на следующем |
| `OCSB_EGRESS_PORT` | `3128` | порт фильтра на 127.0.0.1 и шлюзе docker0 |
| `OCSB_OPENCODE_BIN` | `opencode` из PATH | бинарник OpenCode 2 |
| `OCSB_ENV_FILE` | — | файл `KEY=VALUE` с токенами MCP-серверов и провайдера модели |
| `OCSB_KUBECONFIG` | — | kubeconfig агента (`~/.kube/config` остаётся скрытым) |
| `OCSB_ENV` | `()` | дополнительные `KEY=VALUE` для OpenCode |
| `OCSB_RW` | `()` | дополнительные пути на запись |
| `OCSB_HIDE` | `()` | дополнительные скрытые пути |
| `OCSB_RW_HOME` | `.cache .npm .asdf .bundle .gem …` | каталоги инструментов в `~` на запись (если есть) |
| `OCSB_HIDE_HOME` | `.gnupg .aws .kube/config .claude …` | скрытые пути в `~` |
| `OCSB_SKILLS` | `(opencode-sandbox)` | скилы, которые монтируются агенту |
| `OCSB_SKILL_DIRS` | `()` | дополнительные каталоги со скилами |
| `OCSB_PLUGINS` | skill-compact-resurrect | файлы плагинов из `plugins/` (или абсолютные пути) |
| `OCSB_PANEL` | `1` | панель «Токены» в веб-интерфейсе |
| `OCSB_NOTIFICATIONS` | `1` | уведомления через фильтрующий прокси D-Bus |
| `OCSB_SANDBOX` | `1` | `0` — только фильтр и netlock, без bubblewrap |
| `OCSB_WORKTREES_DIR` | `../<проект>-worktrees` | куда класть worktree |
| `OCSB_WT_PREFIX` | — | каталог дерева = `<префикс><slug>` |
| `OCSB_WT_BASE` | текущий `HEAD` | от какой ветки создаются деревья |
| `OCSB_WT_SLUG_REGEX` | — | регулярка с одной группой: slug из имени ветки |
| `OCSB_OPENCODE_CONFIG` | `~/.config/opencode-sandbox/opencode.jsonc` или пакета | глобальный конфиг OpenCode |
| `OCSB_STATE_DIR` | `~/.local/share/opencode-sandbox/<имя>` | каталог состояния |
| `OCSB_NETLOCK_GROUP` | `opencode-sandbox` | группа netlock |
| `OCSB_DOCKER_GW` | адрес docker0 | где контейнеры видят фильтр |
Пример — проект с внутренними сервисами, файлом токенов и worktree:
```bash
OCSB_NAME=shop
OCSB_WEB_PORT=4296 # разные порты — несколько проектов одновременно
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]+)'
```
## Сетевые правила
Политика — чёрный список: что не запрещено, то разрешено. Всегда запрещены, если не
разрешены явно: внутренние сети (10/8, 172.16/12, 192.168/16, 100.64/10, link-local,
loopback), имена без точки, порты кроме 80 и 443.
```
allow jira.example.com gitlab.example.com # побеждает block
allow-regex ^[a-z0-9-]+\.staging\.example\.com$
block .example.com # домен и все поддомены
block-regex ^prod-
connect git.example.com:2222 10.0.0.5:6443 # туннели: git по SSH, API Kubernetes
expect-block https://prod.example.com/ # самопроверки для `run` и `egress verify`
expect-pass https://gitlab.example.com/
expect-pass https://10.0.0.5:6443/version -k # опции curl — после URL
```
Порядок проверки: `connect` → порты кроме 80/443 запрещены → `allow` → `block` → имена без
точки и внутренние сети запрещены → остальное разрешено.
Слои (правила складываются): [egress/defaults.conf](egress/defaults.conf) →
`~/.config/opencode-sandbox/egress.conf` → `.opencode-sandbox/egress.conf` →
`.opencode-sandbox/local/egress.conf`.
**Куда класть хосты компании.** В `egress.conf` проекта: он лежит в репозитории компании,
и новый коллега получает рабочие правила вместе с `git clone`. То, что нельзя коммитить, —
в `local/egress.conf`. В самом пакете хостов компаний нет.
Изменения применяются при следующем `run` или сразу командой `opencode-sandbox egress up`
(перечитывает правила без перезапуска squid). `egress render` показывает сгенерированный
squid.conf, `egress logs` — каждый запрос и отказ в реальном времени.
## Worktree
```bash
opencode-sandbox wt add feature-SHOP-42-cart --seed # аргументы после ветки — хуку
opencode-sandbox wt rm 42 # откажет, если есть незакоммиченное
opencode-sandbox wt rm 42 --force # выбросит незакоммиченное
opencode-sandbox wt list
```
`wt add` создаёт `<OCSB_WORKTREES_DIR>/<OCSB_WT_PREFIX><slug>` в новой ветке от
`OCSB_WT_BASE` и запускает в нём `hooks/wt-post-add`. `wt rm` запускает `hooks/wt-pre-rm`,
проверяет незакоммиченные изменения и удаляет дерево (ветка остаётся). Хуки получают
`WT_DIR`, `WT_BRANCH`, `WT_SLUG`, `MAIN_ROOT` и дополнительные аргументы; ошибка хука
прерывает команду. Шаблоны — `hooks/*.example` после `init`.
Агенту сказано создавать worktree только для изменений кода в новой ветке — не для
вопросов, поиска и ревью.
## Скилы и плагины
- **Скилы.** `OCSB_SKILLS` — каталоги скилов (с `SKILL.md`), которые монтируются агенту
только на чтение как глобальные: из [skills/](skills/) или ваших `OCSB_SKILL_DIRS`. Скилл
`opencode-sandbox` объясняет агенту среду: что доступно на запись, что значит 403 от
прокси, как попросить хост, как работать с worktree.
- **Плагины** пакета — git submodules в [plugins/](plugins/):
[context-panel](plugins/context-panel) (панель «Токены») и
[skill-compact-resurrect](plugins/skill-compact-resurrect) (скилы переживают compaction).
- **Глобальный конфиг OpenCode** [opencode/opencode.jsonc](opencode/opencode.jsonc)
добавляет opencode-notifier и cc-safety-net. Замените его своим
`~/.config/opencode-sandbox/opencode.jsonc`. Собственный `opencode.jsonc` проекта (модель,
MCP-серверы, плагины проекта) работает как обычно.
## Как это устроено
```
opencode-sandbox run
├─ docker: opencode-sandbox-egress-<имя> squid, сеть хоста, 127.0.0.1 + docker0
├─ xdg-dbus-proxy сессионная шина — только уведомления
└─ sg opencode-sandbox netlock: группе доступны только loopback и DNS
└─ bwrap / только чтение; проект, worktree, состояние — запись
└─ opencode serve :4197 прокси в окружении, свои XDG, права = allow
браузер → :4196 (панель внутри OpenCode) → :4197
```
Внутри песочницы:
- **На запись:** проект, каталог worktree, каталог состояния, `OCSB_RW`, `OCSB_RW_HOME`,
`/tmp`. Всё остальное — только чтение.
- **Скрыто:** `OCSB_HIDE_HOME`, `OCSB_HIDE`, приватные ключи в `~/.ssh` (`config`,
`known_hosts` и `*.pub` видны; подписывает ssh-agent хоста).
- **Сеть:** `HTTP(S)_PROXY` указывают на фильтр. `GIT_SSH_COMMAND` пускает ssh через него
с `-F ~/.ssh/config` (внутри user namespace системные конфиги ssh, принадлежащие root,
выглядят как принадлежащие `nobody`, и ssh их отвергает). `DOCKER_CONFIG` передаёт прокси
сборкам и контейнерам.
- **OpenCode:** свои каталоги `XDG_*` в каталоге состояния (ваш `~/.config/opencode` не
используется); права — через `OPENCODE_CONFIG_CONTENT`: разрешено всё, `sudo` и
`systemd-run` запрещены.
- **Сессионная шина:** заменена на `xdg-dbus-proxy`, который пропускает только
`org.freedesktop.Notifications`; настоящая шина позволила бы агенту запускать процессы вне
песочницы через `systemd-run --user`.
- **Браузер:** `xdg-open` агенту отключён; интерфейс открывает `run` из вашей обычной сессии.
- **Без тихого отката:** при `OCSB_SANDBOX=1` и неработающем bubblewrap `run` не запустится.
**Netlock** (`netlock setup`, sudo) создаёт группу `opencode-sandbox`, добавляет в неё вас и
ставит `opencode-sandbox-netlock.service`: таблица nftables разрешает сокетам этой группы
только `127.0.0.0/8`, `::1` и DNS-серверы из `resolv.conf`, остальное отклоняет. OpenCode
запускается с этой группой — под правило попадают агент и всё, что он запускает.
## Модель безопасности и ограничения
Цель — удержать агента, который ошибается (не тот путь, `rm -rf` не там, запрос в прод), а
не остановить целенаправленного злоумышленника.
- **Docker — выход наружу.** Агенту доступен сокет Docker, а Docker может смонтировать любой
путь хоста (`docker run -v /:/host`). Если это важно — не давайте агенту Docker; закрыть
это можно фильтрующим прокси для сокета или rootless Podman.
- **Общее ядро.** bubblewrap — это namespaces, а не виртуальная машина.
- **Разрешённые хосты — канал утечки.** Данные могут уйти на любой хост, который пропускают
правила.
- **Проект доступен на запись.** Ошибки внутри него настоящие; cc-safety-net блокирует
типичные разрушительные команды, git хранит историю — коммитьте чаще.
- `/tmp` общий с хостом.
## Решение проблем
| Симптом | Причина и что делать |
|---|---|
| `Read-only file system` | путь вне разрешённых на запись. Если агенту он правда нужен — добавьте в `OCSB_RW` |
| `403` от прокси, `CONNECT tunnel failed` | хост не разрешён: добавьте `allow` (или `connect host:port` для порта не 443) в `egress.conf`, выполните `opencode-sandbox egress up` |
| git по SSH: `Permission denied (publickey)` | в ssh-agent нет ключей: `ssh-add` перед `run` |
| git по SSH: `403` | добавьте `connect <хост>:<порт>` в `egress.conf` |
| `port … is busy` | другой проект или старый экземпляр: задайте другие `OCSB_WEB_PORT` / `OCSB_EGRESS_PORT` |
| `netlock is NOT working` | `opencode-sandbox netlock setup`, затем `netlock verify` |
| `bubblewrap cannot create a sandbox` | отключены непривилегированные user namespaces (`kernel.unprivileged_userns_clone`, ограничения AppArmor) |
| нет панели «Токены» | открывайте порт 4196, а не 4197; журнал — `<состояние>/data/opencode/log/context-panel.log` |
| нет уведомлений | установите `xdg-dbus-proxy` |
| `docker build` не скачивает образы | реестры доступны через squid: смотрите `egress logs` |
## Удаление
```bash
opencode-sandbox egress down # в каждом проекте
opencode-sandbox netlock teardown # sudo; группа остаётся: sudo groupdel opencode-sandbox
cd ~/opencode-sandbox && make uninstall
rm -rf ~/.local/share/opencode-sandbox # состояние: сессии, кэши, пароли
docker rmi opencode-sandbox-egress
```
## Команды
```
opencode-sandbox init добавить .opencode-sandbox/ в текущий репозиторий
opencode-sandbox config итоговые настройки
opencode-sandbox doctor зависимости и настройки хоста
opencode-sandbox run [аргументы opencode] всё сразу, затем OpenCode
opencode-sandbox prepare фильтр + netlock + самопроверки, без OpenCode
opencode-sandbox egress up|down|status|logs|verify|render
opencode-sandbox netlock setup|teardown|status|verify
opencode-sandbox wt add <ветка> [аргументы] | rm <имя|путь> [--force] | list
```
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# opencode-sandbox — run OpenCode 2 for a project inside a sandbox:
# egress filter (squid in a container) + netlock (nftables by group) + bubblewrap.
# See README.md. Start with: opencode-sandbox help
set -euo pipefail
OCSB_HOME="$(cd "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/.." && pwd)"
export OCSB_HOME
# shellcheck source-path=SCRIPTDIR/..
# shellcheck source=lib/common.sh
source "${OCSB_HOME}/lib/common.sh"
usage() {
cat <<'EOF'
Usage: opencode-sandbox <command> [args]
Project setup
init Create .opencode-sandbox/ in the current git repository
config Print the effective configuration
doctor Check dependencies and host setup
Running
run [opencode args] Prepare everything and start OpenCode (web UI) in the sandbox
prepare Egress filter up + netlock check + self-tests, without OpenCode
Egress filter (squid)
egress up|down|status|logs|verify|render
Netlock (blocks direct egress for the agent group; setup/teardown need sudo)
netlock setup|teardown|status|verify
Worktrees
wt add <branch> [hook args] Create a worktree (runs .opencode-sandbox/hooks/wt-post-add)
wt rm <name|path> [--force] Remove a worktree (runs .opencode-sandbox/hooks/wt-pre-rm)
wt list
Environment: OCSB_<SETTING>=... overrides any setting for one run (see `config`).
EOF
}
command_name="${1:-help}"
[[ $# -gt 0 ]] && shift
case "${command_name}" in
init)
source "${OCSB_HOME}/lib/init.sh"
ocsb_init "$@"
;;
config)
ocsb_load_project
ocsb_print_config
;;
doctor)
source "${OCSB_HOME}/lib/doctor.sh"
ocsb_doctor "$@"
;;
run)
ocsb_load_project
source "${OCSB_HOME}/lib/egress.sh"
source "${OCSB_HOME}/lib/netlock.sh"
source "${OCSB_HOME}/lib/run.sh"
ocsb_prepare
ocsb_run "$@"
;;
prepare)
ocsb_load_project
source "${OCSB_HOME}/lib/egress.sh"
source "${OCSB_HOME}/lib/netlock.sh"
source "${OCSB_HOME}/lib/run.sh"
ocsb_prepare
;;
egress)
ocsb_load_project
source "${OCSB_HOME}/lib/egress.sh"
ocsb_egress "$@"
;;
netlock)
ocsb_load_project --optional
source "${OCSB_HOME}/lib/netlock.sh"
ocsb_netlock "$@"
;;
wt)
ocsb_load_project
source "${OCSB_HOME}/lib/worktree.sh"
ocsb_wt "$@"
;;
help | -h | --help)
usage
;;
*)
usage >&2
ocsb_die "unknown command: ${command_name}"
;;
esac
+10
View File
@@ -0,0 +1,10 @@
# Egress filter for opencode-sandbox: squid on the host network. The rendered squid.conf is
# mounted into /etc/opencode-sandbox (a directory, so editors' rename-on-save is picked up).
FROM fedora:44
RUN dnf install -y squid && dnf clean all
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
+11
View File
@@ -0,0 +1,11 @@
# Package defaults for every project. Projects add their own rules in
# .opencode-sandbox/egress.conf (format: see lib/egress.sh).
# `opencode pair --remote` would expose the agent's server to the internet
block .opentunnel.xyz
# Reserved test domain for the self-test
block .example-blocked.invalid
expect-block http://example-blocked.invalid/
expect-block https://opentunnel.xyz/
expect-pass https://example.com/
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -eu
mkdir -p /var/log/squid
touch /var/log/squid/access.log
chown squid:squid /var/log/squid/access.log
# /tmp survives a container restart: a stale PID file makes squid refuse to start
rm -f /tmp/squid.pid
# Audit log to `docker logs` (squid writes access_log only as cache_effective_user)
tail -F /var/log/squid/access.log &
exec squid -N -f /etc/opencode-sandbox/squid.conf
+158
View File
@@ -0,0 +1,158 @@
# shellcheck shell=bash
# Shared helpers: messages, project discovery, configuration, state paths.
ocsb_die() {
echo "opencode-sandbox: $*" >&2
exit 1
}
ocsb_warn() { echo "⚠ $*" >&2; }
ocsb_info() { echo "→ $*"; }
# --- configuration ------------------------------------------------------------------------
# Layers, later wins:
# 1. defaults below
# 2. ~/.config/opencode-sandbox/sandbox.conf (user, all projects)
# 3. <project>/.opencode-sandbox/sandbox.conf (committed)
# 4. <project>/.opencode-sandbox/local/sandbox.conf (git-ignored, personal)
# 5. OCSB_<SETTING> from the environment (scalars only, one run)
# Config files are bash: scalars and arrays. OCSB_PROJECT_DIR is already set when they load.
OCSB_SCALARS=(
OCSB_NAME OCSB_WEB_PORT OCSB_EGRESS_PORT OCSB_NETLOCK_GROUP OCSB_SANDBOX OCSB_PANEL
OCSB_NOTIFICATIONS OCSB_OPENCODE_BIN OCSB_ENV_FILE OCSB_KUBECONFIG OCSB_PASSWORD_FILE
OCSB_WORKTREES_DIR OCSB_WT_PREFIX OCSB_WT_BASE OCSB_WT_SLUG_REGEX OCSB_DOCKER_GW
OCSB_STATE_DIR OCSB_OPENCODE_CONFIG OCSB_EGRESS_BASE
)
OCSB_ARRAYS=(OCSB_RW OCSB_RW_HOME OCSB_HIDE OCSB_HIDE_HOME OCSB_SKILLS OCSB_SKILL_DIRS OCSB_PLUGINS OCSB_ENV)
ocsb_defaults() {
OCSB_NAME="" # default: basename of the project directory
OCSB_WEB_PORT=4196 # browser port (context panel); OpenCode itself listens on +1
OCSB_EGRESS_PORT=3128 # squid port on 127.0.0.1 and the docker0 gateway
OCSB_NETLOCK_GROUP=opencode-sandbox
OCSB_SANDBOX=1 # bubblewrap; 0 = egress filter and netlock only
OCSB_PANEL=1 # "Tokens" panel in the web UI (plugins/context-panel)
OCSB_NOTIFICATIONS=1 # desktop notifications through a filtered D-Bus proxy
OCSB_OPENCODE_BIN="" # default: `opencode` from PATH
OCSB_ENV_FILE="" # KEY=VALUE file with tokens, exported to OpenCode
OCSB_KUBECONFIG="" # kubeconfig for the agent (~/.kube/config stays hidden)
OCSB_PASSWORD_FILE="" # web UI password; default: generated in the state dir
OCSB_WORKTREES_DIR="" # default: <parent of project>/<project>-worktrees
OCSB_WT_PREFIX="" # worktree directory = <prefix><slug>
OCSB_WT_BASE="" # branch new worktrees start from; default: current HEAD
OCSB_WT_SLUG_REGEX="" # ERE with one group: slug from the branch name
OCSB_DOCKER_GW="" # default: address of docker0
OCSB_STATE_DIR="" # default: ~/.local/share/opencode-sandbox/<name>
OCSB_OPENCODE_CONFIG="" # global OpenCode config; default: user's or the package's
OCSB_RW=() # extra paths the agent may write (relative to the project)
# Home directories that stay writable for toolchains and caches (only if they exist)
OCSB_RW_HOME=(.cache .npm .bun .bundle .gem .asdf .local/share/mise .cargo .rustup go
.m2 .gradle .docker/buildx .kube/cache)
OCSB_HIDE=() # extra paths hidden from the agent (relative to the project)
# Hidden home paths: credentials of other tools, browsers, other agents
OCSB_HIDE_HOME=(.gnupg .aws .azure .config/gcloud .kube/config .docker/config.json .netrc
.git-credentials .config/gh/hosts.yml .password-store .local/share/keyrings .mozilla
.config/google-chrome .config/chromium .config/BraveSoftware .claude .codex
.config/opencode .local/share/opencode)
OCSB_SKILLS=(opencode-sandbox) # skills mounted for the agent (names of skill directories)
OCSB_SKILL_DIRS=() # extra directories to look for skills in
OCSB_PLUGINS=(skill-compact-resurrect/skill-compact-resurrect.v2.ts) # files in plugins/
OCSB_ENV=() # extra KEY=VALUE for OpenCode
}
ocsb_find_project() {
local dir=${1:-$PWD}
while [[ ${dir} != / ]]; do
[[ -f ${dir}/.opencode-sandbox/sandbox.conf ]] && { echo "${dir}"; return 0; }
dir=$(dirname "${dir}")
done
return 1
}
# ocsb_load_project [--optional]: find the project, load config layers, derive paths
ocsb_load_project() {
local optional=0 saved=() v f
[[ ${1:-} == --optional ]] && optional=1
for v in "${OCSB_SCALARS[@]}"; do
[[ -n ${!v+x} ]] && saved+=("${v}=${!v}")
done
ocsb_defaults
OCSB_PROJECT_DIR=""
if [[ -n ${OCSB_PROJECT:-} ]]; then
OCSB_PROJECT_DIR=$(cd "${OCSB_PROJECT}" && pwd)
else
OCSB_PROJECT_DIR=$(ocsb_find_project) || true
fi
if [[ -z ${OCSB_PROJECT_DIR} || ! -f ${OCSB_PROJECT_DIR}/.opencode-sandbox/sandbox.conf ]]; then
[[ ${optional} == 1 ]] || ocsb_die "no .opencode-sandbox/sandbox.conf here or above (run: opencode-sandbox init)"
fi
export OCSB_PROJECT_DIR
for f in "${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/sandbox.conf" \
${OCSB_PROJECT_DIR:+"${OCSB_PROJECT_DIR}/.opencode-sandbox/sandbox.conf"} \
${OCSB_PROJECT_DIR:+"${OCSB_PROJECT_DIR}/.opencode-sandbox/local/sandbox.conf"}; do
# shellcheck disable=SC1090
[[ -f ${f} ]] && source "${f}"
done
for v in "${saved[@]}"; do
printf -v "${v%%=*}" '%s' "${v#*=}"
done
ocsb_derive
}
ocsb_derive() {
local base
base=$(basename "${OCSB_PROJECT_DIR:-default}")
OCSB_NAME=${OCSB_NAME:-${base}}
OCSB_NAME=$(printf '%s' "${OCSB_NAME}" | tr -c 'a-zA-Z0-9_.-' '-' | tr '[:upper:]' '[:lower:]')
OCSB_STATE_DIR=${OCSB_STATE_DIR:-${XDG_DATA_HOME:-${HOME}/.local/share}/opencode-sandbox/${OCSB_NAME}}
if [[ -n ${OCSB_PROJECT_DIR} ]]; then
OCSB_WORKTREES_DIR=${OCSB_WORKTREES_DIR:-$(dirname "${OCSB_PROJECT_DIR}")/${base}-worktrees}
OCSB_WORKTREES_DIR=$(ocsb_abs "${OCSB_WORKTREES_DIR}")
fi
if [[ -z ${OCSB_DOCKER_GW} ]]; then
OCSB_DOCKER_GW=$(ip -4 -o addr show docker0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
OCSB_DOCKER_GW=${OCSB_DOCKER_GW:-172.17.0.1}
fi
OCSB_CONTAINER="opencode-sandbox-egress-${OCSB_NAME}"
OCSB_IMAGE="opencode-sandbox-egress"
}
# Path relative to the project (or ~/..., or absolute) → absolute path
ocsb_abs() {
local p=$1
# shellcheck disable=SC2088 # a literal "~" comes from config files
case "${p}" in
/*) ;;
"~") p=${HOME} ;;
"~/"*) p=${HOME}/${p#"~/"} ;;
*) p=${OCSB_PROJECT_DIR}/${p} ;;
esac
realpath -m "${p}"
}
ocsb_print_config() {
local v
echo "# project: ${OCSB_PROJECT_DIR:-none}"
for v in "${OCSB_SCALARS[@]}"; do
printf '%s=%q\n' "${v}" "${!v}"
done
for v in "${OCSB_ARRAYS[@]}"; do
local -n arr=${v}
printf '%s=(' "${v}"
[[ ${#arr[@]} -gt 0 ]] && printf ' %q' "${arr[@]}"
printf ' )\n'
unset -n arr
done
}
# OpenCode binary: OCSB_OPENCODE_BIN (a path, relative to the project, or a command) or PATH
ocsb_opencode_bin() {
local b=${OCSB_OPENCODE_BIN:-opencode}
if [[ ${b} == */* || ${b} == "~"* ]]; then
ocsb_abs "${b}"
else
command -v "${b}" || true
fi
}
+39
View File
@@ -0,0 +1,39 @@
# shellcheck shell=bash
# opencode-sandbox doctor: dependencies and host setup
ocsb_doctor() {
local fail=0 c
ocsb_load_project --optional
echo "Required:"
for c in docker bwrap sg curl jq ncat git; do
if command -v "${c}" >/dev/null; then echo " ✓ ${c}"; else echo " ✗ ${c}"; fail=1; fi
done
local bin
bin=$(ocsb_opencode_bin)
if [[ -x ${bin} ]]; then
echo " ✓ opencode: ${bin} ($("${bin}" --version 2>/dev/null | head -1))"
elif [[ -z ${OCSB_PROJECT_DIR} ]]; then
echo " - opencode: not in PATH (fine if projects set OCSB_OPENCODE_BIN; run doctor inside a project to check)"
else
echo " ✗ opencode: ${bin:-not in PATH} (set OCSB_OPENCODE_BIN in sandbox.conf or put opencode in PATH)"
fail=1
fi
echo "Optional:"
command -v xdg-dbus-proxy >/dev/null && echo " ✓ xdg-dbus-proxy (notifications)" || echo " - xdg-dbus-proxy: no desktop notifications in the sandbox"
if ssh-add -l >/dev/null 2>&1; then echo " ✓ ssh-agent has keys"; else echo " - ssh-agent has no keys: git over SSH fails in the sandbox (ssh-add)"; fi
[[ -d ${OCSB_HOME}/plugins/context-panel/.git || -f ${OCSB_HOME}/plugins/context-panel/.git ]] &&
echo " ✓ plugins checked out" || echo " - plugins missing: git -C ${OCSB_HOME} submodule update --init"
echo "Host:"
if bwrap --ro-bind / / --dev /dev true 2>/dev/null; then echo " ✓ unprivileged bubblewrap works"
else echo " ✗ bubblewrap cannot create a sandbox (user namespaces disabled?)"; fail=1; fi
source "${OCSB_HOME}/lib/netlock.sh"
ocsb_netlock status
if [[ -n ${OCSB_PROJECT_DIR} ]]; then
echo "Project: ${OCSB_PROJECT_DIR} (name ${OCSB_NAME}, state ${OCSB_STATE_DIR})"
source "${OCSB_HOME}/lib/egress.sh"
ocsb_egress status
else
echo "Project: none here (opencode-sandbox init)"
fi
return ${fail}
}
+223
View File
@@ -0,0 +1,223 @@
# shellcheck shell=bash
# Egress filter: squid in a container on the host network, listening on loopback and the
# docker0 gateway. squid.conf is rendered from egress.conf layers (later layers add rules):
# <package>/egress/defaults.conf
# ~/.config/opencode-sandbox/egress.conf
# <project>/.opencode-sandbox/egress.conf (committed with the project)
# <project>/.opencode-sandbox/local/egress.conf (git-ignored)
#
# egress.conf lines (# starts a comment):
# allow <domain>... allowed even if a block rule matches (.domain = with subdomains)
# allow-regex <regex>... same, by regular expression
# block <domain>... denied
# block-regex <regex>...
# connect <host:port>... raw CONNECT tunnel to a non-443 port or an internal IP
# (git over SSH, Kubernetes API); host may be an IP or CIDR
# expect-block <url> [curl args] self-test: must be refused (egress verify)
# expect-pass <url> [curl args] self-test: must go through
# Built in, after all of the above: non-80/443 ports, names without a dot, private networks
# are denied; everything else is allowed (blacklist policy).
ocsb_egress_layers() {
local f
for f in "${OCSB_HOME}/egress/defaults.conf" \
"${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/egress.conf" \
"${OCSB_PROJECT_DIR}/.opencode-sandbox/egress.conf" \
"${OCSB_PROJECT_DIR}/.opencode-sandbox/local/egress.conf"; do
[[ -f ${f} ]] && echo "${f}"
done
}
# Prints "directive<TAB>arg" for every argument of every rule line
ocsb_egress_rules() {
local f line directive rest a args
while IFS= read -r f; do
while IFS= read -r line || [[ -n ${line} ]]; do
line=${line%%#*}
read -r directive rest <<<"${line}"
[[ -z ${directive} ]] && continue
case "${directive}" in
allow | allow-regex | block | block-regex | connect)
read -ra args <<<"${rest}"
for a in "${args[@]}"; do printf '%s\t%s\n' "${directive}" "${a}"; done
;;
expect-block | expect-pass)
printf '%s\t%s\n' "${directive}" "${rest}"
;;
*) ocsb_die "${f}: unknown directive '${directive}'" ;;
esac
done <"${f}"
done < <(ocsb_egress_layers)
}
ocsb_is_ip() { [[ $1 =~ ^[0-9.]+(/[0-9]+)?$ || $1 =~ ^[0-9a-fA-F:]+:[0-9a-fA-F:]*(/[0-9]+)?$ ]]; }
ocsb_egress_render() {
local out=${OCSB_STATE_DIR}/egress/squid.conf rules allow=() allow_re=() block=() block_re=()
local connect=() d a i=0 host port
mkdir -p "${OCSB_STATE_DIR}/egress"
rules=$(ocsb_egress_rules)
while IFS=$'\t' read -r d a; do
case "${d}" in
allow) allow+=("${a}") ;;
allow-regex) allow_re+=("${a}") ;;
block) block+=("${a}") ;;
block-regex) block_re+=("${a}") ;;
connect) connect+=("${a}") ;;
esac
done <<<"${rules}"
{
echo "# Rendered by opencode-sandbox from egress.conf layers — do not edit, it is overwritten."
echo "http_port 127.0.0.1:${OCSB_EGRESS_PORT}"
echo "http_port ${OCSB_DOCKER_GW}:${OCSB_EGRESS_PORT}"
echo "visible_hostname opencode-sandbox-egress"
echo "pid_filename /tmp/squid.pid"
echo "cache deny all"
echo "forwarded_for off"
echo "access_log /var/log/squid/access.log"
echo "logfile_rotate 10"
echo
echo "acl CONNECT method CONNECT"
echo "acl SSL_ports port 443"
echo "acl Safe_ports port 80 443"
for a in "${connect[@]}"; do
host=${a%:*}
port=${a##*:}
[[ ${host} != "${a}" && ${port} =~ ^[0-9]+$ ]] || ocsb_die "connect ${a}: expected host:port"
host=${host#[}
host=${host%]}
i=$((i + 1))
if ocsb_is_ip "${host}"; then
echo "acl connect_${i}_host dst ${host}"
else
echo "acl connect_${i}_host dstdomain ${host}"
fi
echo "acl connect_${i}_port port ${port}"
done
[[ ${#allow[@]} -gt 0 ]] && echo "acl allowed dstdomain ${allow[*]}"
[[ ${#allow_re[@]} -gt 0 ]] && echo "acl allowed_re dstdom_regex -i ${allow_re[*]}"
[[ ${#block[@]} -gt 0 ]] && echo "acl blocked dstdomain ${block[*]}"
[[ ${#block_re[@]} -gt 0 ]] && echo "acl blocked_re dstdom_regex -i ${block_re[*]}"
echo "acl unqualified dstdom_regex ^[^.]+\$"
echo "acl private_dst dst 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16 127.0.0.0/8 fc00::/7 fe80::/10 ::1"
echo
for ((i = 1; i <= ${#connect[@]}; i++)); do
echo "http_access allow CONNECT connect_${i}_host connect_${i}_port"
done
echo "http_access deny !Safe_ports"
echo "http_access deny CONNECT !SSL_ports"
[[ ${#allow[@]} -gt 0 ]] && echo "http_access allow allowed"
[[ ${#allow_re[@]} -gt 0 ]] && echo "http_access allow allowed_re"
[[ ${#block[@]} -gt 0 ]] && echo "http_access deny blocked"
[[ ${#block_re[@]} -gt 0 ]] && echo "http_access deny blocked_re"
echo "http_access deny unqualified"
echo "http_access deny private_dst"
echo "http_access allow all"
} >"${out}.tmp"
# Replace via rename into the mounted directory: squid -k reconfigure then sees the new file
mv "${out}.tmp" "${out}"
}
ocsb_egress_src_hash() {
cat "${OCSB_HOME}/egress/Dockerfile" "${OCSB_HOME}/egress/entrypoint.sh" | sha256sum | cut -c1-16
}
ocsb_egress_running() {
[[ $(docker inspect -f '{{.State.Running}}' "${OCSB_CONTAINER}" 2>/dev/null) == true ]]
}
ocsb_egress_up() {
local hash
hash=$(ocsb_egress_src_hash)
ocsb_egress_render
if [[ $(docker image inspect -f '{{index .Config.Labels "opencode-sandbox.src-hash"}}' "${OCSB_IMAGE}" 2>/dev/null) != "${hash}" ]]; then
ocsb_info "egress: building image ${OCSB_IMAGE}"
docker build -q --label "opencode-sandbox.src-hash=${hash}" -t "${OCSB_IMAGE}" "${OCSB_HOME}/egress" >/dev/null
fi
if ocsb_egress_running &&
[[ $(docker inspect -f '{{index .Config.Labels "opencode-sandbox.src-hash"}}' "${OCSB_CONTAINER}") == "${hash}" ]]; then
ocsb_info "egress: ${OCSB_CONTAINER} running, reloading rules"
docker exec "${OCSB_CONTAINER}" squid -k reconfigure -f /etc/opencode-sandbox/squid.conf
sleep 1
return
fi
docker rm -f "${OCSB_CONTAINER}" >/dev/null 2>&1 || true
if (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null; then
ocsb_die "port ${OCSB_EGRESS_PORT} is busy (another project's filter?) — set OCSB_EGRESS_PORT in sandbox.conf"
fi
ocsb_info "egress: starting ${OCSB_CONTAINER} on 127.0.0.1:${OCSB_EGRESS_PORT}"
docker run -d --name "${OCSB_CONTAINER}" --restart unless-stopped \
--label "opencode-sandbox.src-hash=${hash}" \
-v "${OCSB_STATE_DIR}/egress:/etc/opencode-sandbox:ro,z" \
--network host "${OCSB_IMAGE}" >/dev/null
local i
for i in $(seq 30); do
(exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null && return 0
sleep 0.2
done
docker logs --tail 20 "${OCSB_CONTAINER}" >&2
ocsb_die "egress: squid did not start"
}
# One self-test: want = block|pass
ocsb_egress_check() {
local want=$1 url=$2 connect http got
shift 2
read -r connect http < <(curl -s -o /dev/null -w '%{http_connect} %{http_code}' \
--max-time 15 -x "http://127.0.0.1:${OCSB_EGRESS_PORT}" "$@" "${url}" </dev/null)
if [[ ${connect} == 403 || ${http} == 403 ]]; then got=block
elif [[ ${connect} == 200 || ${http} != 000 ]]; then got=pass
else got=error; fi
if [[ ${got} == "${want}" ]]; then
printf ' ✓ %-5s %s\n' "${want}" "${url}"
else
printf ' ✗ %-5s %s → %s (connect=%s http=%s)\n' "${want}" "${url}" "${got}" "${connect}" "${http}"
return 1
fi
}
ocsb_egress_verify() {
local fail=0 d a url args
echo "Egress self-test (127.0.0.1:${OCSB_EGRESS_PORT}):"
ocsb_egress_check block https://localhost-name-without-dot/ || fail=1
ocsb_egress_check block https://10.255.255.1/ || fail=1
ocsb_egress_check block telnet://example.com:22 -p || fail=1
while IFS=$'\t' read -r d a; do
[[ ${d} == expect-* ]] || continue
read -r url args <<<"${a}"
# shellcheck disable=SC2086 # args are curl options from egress.conf
ocsb_egress_check "${d#expect-}" "${url}" ${args} || fail=1
done < <(ocsb_egress_rules)
return ${fail}
}
ocsb_egress() {
local sub=${1:-status}
case "${sub}" in
up) ocsb_egress_up ;;
down)
docker rm -f "${OCSB_CONTAINER}" >/dev/null 2>&1 || true
echo "✓ ${OCSB_CONTAINER} removed"
;;
status)
if ocsb_egress_running; then
echo " container ${OCSB_CONTAINER}: running"
else
echo " container ${OCSB_CONTAINER}: not running (opencode-sandbox egress up)"
fi
if (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null; then
echo " port 127.0.0.1:${OCSB_EGRESS_PORT}: open"
else
echo " port 127.0.0.1:${OCSB_EGRESS_PORT}: closed"
fi
;;
logs) docker logs -f --tail 50 "${OCSB_CONTAINER}" ;;
verify) ocsb_egress_verify ;;
render)
ocsb_egress_render
cat "${OCSB_STATE_DIR}/egress/squid.conf"
;;
*) ocsb_die "egress: unknown subcommand ${sub} (up|down|status|logs|verify|render)" ;;
esac
}
+31
View File
@@ -0,0 +1,31 @@
# shellcheck shell=bash
# opencode-sandbox init: add .opencode-sandbox/ to the current git repository
ocsb_init() {
local root t=${OCSB_HOME}/templates/project f
root=$(git rev-parse --show-toplevel 2>/dev/null) || ocsb_die "run init inside a git repository"
if [[ -f ${root}/.opencode-sandbox/sandbox.conf ]]; then
ocsb_die "${root}/.opencode-sandbox already exists"
fi
mkdir -p "${root}/.opencode-sandbox/hooks" "${root}/.opencode-sandbox/local"
cp "${t}/sandbox.conf" "${t}/egress.conf" "${root}/.opencode-sandbox/"
printf '/local/\n' >"${root}/.opencode-sandbox/.gitignore"
for f in "${t}"/hooks/*.example; do
cp "${f}" "${root}/.opencode-sandbox/hooks/"
done
if [[ ! -e ${root}/.cc-safety-net/policy.json ]]; then
mkdir -p "${root}/.cc-safety-net"
cp "${t}/cc-safety-net-policy.json" "${root}/.cc-safety-net/policy.json"
fi
cat <<EOF
✓ created ${root}/.opencode-sandbox/
sandbox.conf settings (ports, writable paths, skills, worktrees) — commit it
egress.conf domains for this project — commit it
hooks/ worktree hooks: drop .example and chmod +x to enable
local/ personal overrides (sandbox.conf, egress.conf), git-ignored
Next:
opencode-sandbox doctor check the host
opencode-sandbox run start OpenCode in the sandbox
EOF
}
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Netlock, run as root (opencode-sandbox netlock setup|teardown calls it through sudo).
# Processes whose primary group is GROUP may only talk to loopback (where the egress filter
# listens) and to DNS servers from resolv.conf; everything else is rejected. The rules are
# loaded by a systemd unit and survive reboots.
# netlock-root.sh setup <group> <user>
# netlock-root.sh teardown
set -euo pipefail
TABLE=opencode_sandbox
CONF_DIR=/etc/opencode-sandbox
RULES="${CONF_DIR}/netlock.nft"
UNIT=opencode-sandbox-netlock.service
UNIT_PATH="/etc/systemd/system/${UNIT}"
[[ ${EUID} -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
setup() {
local group=$1 user=${2:-} gid ns dns_rules=""
getent group "${group}" >/dev/null || groupadd "${group}"
gid=$(getent group "${group}" | cut -d: -f3)
if [[ -n ${user} ]] && ! id -nG "${user}" | tr ' ' '\n' | grep -qx "${group}"; then
usermod -aG "${group}" "${user}"
echo " ${user} added to group ${group} (log out and in, or use the new group via sg)"
fi
# The loopback resolver (systemd-resolved) is covered by the loopback rule; non-local
# nameservers are allowed one by one, not port 53 anywhere
while read -r ns; do
case "${ns}" in
127.* | ::1) ;;
*:*) dns_rules+=" meta skgid ${gid} ip6 daddr ${ns} meta l4proto { tcp, udp } th dport 53 accept"$'\n' ;;
*) dns_rules+=" meta skgid ${gid} ip daddr ${ns} meta l4proto { tcp, udp } th dport 53 accept"$'\n' ;;
esac
done < <(awk '$1 == "nameserver" { print $2 }' /etc/resolv.conf)
mkdir -p "${CONF_DIR}"
cat >"${RULES}" <<EOF
# Generated by opencode-sandbox (lib/netlock-root.sh) — edit the script, not this file.
table inet ${TABLE} {
chain output {
type filter hook output priority 0; policy accept;
meta skgid ${gid} ip daddr 127.0.0.0/8 accept
meta skgid ${gid} ip6 daddr ::1 accept
${dns_rules} meta skgid ${gid} reject with icmpx admin-prohibited
}
}
EOF
chmod 644 "${RULES}"
cat >"${UNIT_PATH}" <<EOF
[Unit]
Description=opencode-sandbox netlock (nftables, group ${group})
Wants=network-pre.target
Before=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=-/usr/sbin/nft delete table inet ${TABLE}
ExecStart=/usr/sbin/nft -f ${RULES}
ExecStop=/usr/sbin/nft delete table inet ${TABLE}
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable "${UNIT}" >/dev/null 2>&1
systemctl restart "${UNIT}"
echo "✓ netlock: group ${group} (gid ${gid}), unit ${UNIT} enabled"
echo " allowed: 127.0.0.0/8, ::1${dns_rules:+, DNS servers from resolv.conf}"
}
teardown() {
if systemctl list-unit-files "${UNIT}" >/dev/null 2>&1; then
systemctl disable --now "${UNIT}" >/dev/null 2>&1 || true
rm -f "${UNIT_PATH}"
systemctl daemon-reload
fi
nft list table inet "${TABLE}" >/dev/null 2>&1 && nft delete table inet "${TABLE}"
rm -rf "${CONF_DIR}"
echo "✓ netlock removed (unit ${UNIT}, table ${TABLE}); the group is kept"
}
case "${1:-}" in
setup) shift; setup "$@" ;;
teardown) teardown ;;
*) echo "usage: $0 setup <group> [user] | teardown" >&2; exit 1 ;;
esac
+75
View File
@@ -0,0 +1,75 @@
# shellcheck shell=bash
# Netlock: direct egress (ping, curl by IP, ssh, anything not via the egress filter) is
# rejected for processes running with the netlock group as their primary group. OpenCode
# is started through `sg <group>`, so the agent and everything it spawns are covered.
OCSB_NETLOCK_UNIT=opencode-sandbox-netlock.service
OCSB_NETLOCK_PROBE=${OCSB_NETLOCK_PROBE:-1.1.1.1/443}
ocsb_netlock_active() { systemctl is-active --quiet "${OCSB_NETLOCK_UNIT}"; }
ocsb_netlock_member() {
local members
getent group "${OCSB_NETLOCK_GROUP}" >/dev/null || return 1
members=$(getent group "${OCSB_NETLOCK_GROUP}" | cut -d: -f4)
[[ ",${members}," == *",${USER},"* ]]
}
ocsb_netlock_setup() {
sudo "${OCSB_HOME}/lib/netlock-root.sh" setup "${OCSB_NETLOCK_GROUP}" "${USER}"
}
# Fails unless direct TCP from the group is rejected (not merely timing out)
ocsb_netlock_check() {
local rc=0
ocsb_netlock_member || ocsb_die "${USER} is not in group ${OCSB_NETLOCK_GROUP} (opencode-sandbox netlock setup)"
sg "${OCSB_NETLOCK_GROUP}" -c "timeout 10 bash -c 'exec 3<>/dev/tcp/${OCSB_NETLOCK_PROBE}'" 2>/dev/null || rc=$?
case ${rc} in
0) ocsb_die "netlock is NOT working: direct TCP to ${OCSB_NETLOCK_PROBE/\//:} from ${OCSB_NETLOCK_GROUP} succeeded" ;;
124) ocsb_die "netlock check inconclusive: direct TCP to ${OCSB_NETLOCK_PROBE/\//:} hung instead of being rejected" ;;
esac
}
ocsb_netlock_verify() {
local code
echo "Netlock self-test (group ${OCSB_NETLOCK_GROUP}):"
if sg "${OCSB_NETLOCK_GROUP}" -c 'curl --noproxy "*" -s -o /dev/null --max-time 5 https://example.com/' 2>/dev/null; then
echo " ✗ direct HTTPS bypassing the filter went through"; return 1
fi
echo " ✓ direct HTTPS bypassing the filter is blocked"
if sg "${OCSB_NETLOCK_GROUP}" -c 'ping -c1 -W1 1.1.1.1' >/dev/null 2>&1; then
echo " ✗ ping went through"; return 1
fi
echo " ✓ ping is blocked"
if [[ -n ${OCSB_PROJECT_DIR} ]]; then
# What is checked is that the group reaches the proxy and the proxy opens the tunnel
# (CONNECT 200), not that a particular remote site answers in time
code=$(sg "${OCSB_NETLOCK_GROUP}" -c "curl -sx http://127.0.0.1:${OCSB_EGRESS_PORT} -o /dev/null -w '%{http_connect} %{http_code}' --max-time 20 https://example.com/" || true)
case "${code}" in
200\ * | *\ [23]??) echo " ✓ HTTPS through the filter → tunnel open (connect/http: ${code})" ;;
*) echo " ✗ HTTPS through the filter → connect/http: ${code:-none} (is the egress filter up?)"; return 1 ;;
esac
fi
}
ocsb_netlock() {
local sub=${1:-status}
case "${sub}" in
setup) ocsb_netlock_setup ;;
teardown) sudo "${OCSB_HOME}/lib/netlock-root.sh" teardown ;;
status)
if getent group "${OCSB_NETLOCK_GROUP}" >/dev/null; then
echo " group ${OCSB_NETLOCK_GROUP}: exists$(ocsb_netlock_member && echo ", ${USER} is a member")"
else
echo " group ${OCSB_NETLOCK_GROUP}: missing (opencode-sandbox netlock setup)"
fi
if systemctl is-enabled --quiet "${OCSB_NETLOCK_UNIT}" 2>/dev/null; then
echo " unit ${OCSB_NETLOCK_UNIT}: $(systemctl is-active "${OCSB_NETLOCK_UNIT}")"
else
echo " unit ${OCSB_NETLOCK_UNIT}: not installed (opencode-sandbox netlock setup)"
fi
;;
verify) ocsb_netlock_verify ;;
*) ocsb_die "netlock: unknown subcommand ${sub} (setup|teardown|status|verify)" ;;
esac
}
+288
View File
@@ -0,0 +1,288 @@
# shellcheck shell=bash
# Preparing and running OpenCode: per-project state, plugins, skills, bubblewrap, launch.
# Egress filter up, netlock in place, self-tests
ocsb_prepare() {
command -v docker >/dev/null || ocsb_die "docker is required for the egress filter"
ocsb_egress_up
if ! ocsb_netlock_member || ! (ocsb_netlock_check) 2>/dev/null; then
ocsb_info "netlock: not in place, installing (sudo)"
ocsb_netlock_setup
ocsb_netlock_member || ocsb_die "group ${OCSB_NETLOCK_GROUP} was just created: log out and back in, then retry"
fi
ocsb_netlock_check
ocsb_egress_verify || ocsb_die "egress self-test failed (rules: opencode-sandbox egress render; log: egress logs)"
ocsb_netlock_verify || ocsb_die "netlock self-test failed"
}
# XDG directory of the agent: entries of the user's directory as symlinks (git, gh, uv and
# friends keep working), except opencode — the agent gets its own.
ocsb_mirror_xdg() {
local src=$1 dst=$2 entry name
mkdir -p "${dst}"
chmod 700 "${dst}"
for entry in "${src}"/* "${src}"/.[!.]*; do
[[ -e ${entry} ]] || continue
name=$(basename "${entry}")
[[ ${name} == opencode ]] && continue
ln -sfn "${entry}" "${dst}/${name}"
done
}
ocsb_find_skill() {
local name=$1 dir
for dir in "${OCSB_HOME}/skills" "${OCSB_SKILL_DIRS[@]}"; do
dir=$(ocsb_abs "${dir}")
[[ -f ${dir}/${name}/SKILL.md ]] && { echo "${dir}/${name}"; return 0; }
done
return 1
}
ocsb_prepare_state() {
local s=${OCSB_STATE_DIR} cfg plugin skill src
mkdir -p "${s}"
chmod 700 "${s}"
ocsb_mirror_xdg "${XDG_CONFIG_HOME:-${HOME}/.config}" "${s}/config"
ocsb_mirror_xdg "${XDG_DATA_HOME:-${HOME}/.local/share}" "${s}/data"
ocsb_mirror_xdg "${XDG_STATE_HOME:-${HOME}/.local/state}" "${s}/state"
ocsb_mirror_xdg "${XDG_CACHE_HOME:-${HOME}/.cache}" "${s}/cache"
rm -f "${s}/data/opencode-sandbox" # the mirror would point back at the state root
# Global OpenCode config and instructions: explicit > user's > package's
cfg=${OCSB_OPENCODE_CONFIG:-${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/opencode.jsonc}
[[ -f ${cfg} ]] || cfg=${OCSB_HOME}/opencode/opencode.jsonc
mkdir -p "${s}/config/opencode"
ln -sfn "${cfg}" "${s}/config/opencode/opencode.jsonc"
ln -sfn "${OCSB_HOME}/opencode/AGENTS.md" "${s}/config/opencode/AGENTS.md"
# Plugins and skills are re-linked on every start: the list in sandbox.conf is the truth
rm -rf "${s}/config/opencode/plugins" "${s}/config/opencode/skills"
mkdir -p "${s}/config/opencode/plugins" "${s}/config/opencode/skills"
local plugins=("${OCSB_PLUGINS[@]}")
[[ ${OCSB_PANEL} == 1 ]] && plugins+=(context-panel/context-panel.v2.ts)
for plugin in "${plugins[@]}"; do
src=${OCSB_HOME}/plugins/${plugin}
[[ ${plugin} == /* ]] && src=${plugin}
if [[ -f ${src} ]]; then
ln -sfn "${src}" "${s}/config/opencode/plugins/$(basename "${src}")"
else
ocsb_warn "plugin not found: ${src} (git submodule update --init in ${OCSB_HOME}?)"
[[ ${plugin} == context-panel/* ]] && OCSB_PANEL=0
fi
done
for skill in "${OCSB_SKILLS[@]}"; do
if src=$(ocsb_find_skill "${skill}"); then
ln -sfn "${src}" "${s}/config/opencode/skills/${skill}"
else
ocsb_warn "skill not found: ${skill} (looked in ${OCSB_HOME}/skills ${OCSB_SKILL_DIRS[*]})"
fi
done
# Docker client config: the user's registry logins plus the proxy. Docker passes the proxy
# to RUN in builds and to containers' env; from a container 127.0.0.1 is the container,
# so the address is the docker0 gateway, where squid listens too.
mkdir -p "${s}/docker"
local proxy="http://${OCSB_DOCKER_GW}:${OCSB_EGRESS_PORT}"
jq --arg p "${proxy}" '.proxies.default = {httpProxy: $p, httpsProxy: $p, noProxy: "localhost,127.0.0.1,::1"}' \
"${HOME}/.docker/config.json" 2>/dev/null >"${s}/docker/config.json" ||
jq -n --arg p "${proxy}" '.proxies.default = {httpProxy: $p, httpsProxy: $p, noProxy: "localhost,127.0.0.1,::1"}' \
>"${s}/docker/config.json"
chmod 600 "${s}/docker/config.json"
# Web UI password: OpenCode 2 refuses to serve without one
OCSB_PASSWORD_FILE=${OCSB_PASSWORD_FILE:-${s}/password}
if [[ ! -s ${OCSB_PASSWORD_FILE} ]]; then
(umask 077 && head -c 18 /dev/urandom | base64 | tr -d '/+=' >"${OCSB_PASSWORD_FILE}")
echo " generated web UI password: ${OCSB_PASSWORD_FILE}"
fi
}
# Filtered session bus: only desktop notifications, not systemd (systemd-run --user would
# start processes outside the sandbox)
ocsb_dbus_sock() { echo "${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/opencode-sandbox-${OCSB_NAME}.bus"; }
ocsb_start_dbus_proxy() {
# In XDG_RUNTIME_DIR: unix socket paths are limited to 108 bytes
local sock
sock=$(ocsb_dbus_sock)
[[ ${OCSB_NOTIFICATIONS} == 1 && -n ${DBUS_SESSION_BUS_ADDRESS:-} ]] || return 1
command -v xdg-dbus-proxy >/dev/null || { ocsb_warn "no xdg-dbus-proxy — notifications are off in the sandbox"; return 1; }
rm -f "${sock}"
xdg-dbus-proxy "${DBUS_SESSION_BUS_ADDRESS}" "${sock}" --filter --talk=org.freedesktop.Notifications &
OCSB_DBUS_PROXY_PID=$!
local i
for i in $(seq 20); do [[ -S ${sock} ]] && return 0; sleep 0.1; done
kill "${OCSB_DBUS_PROXY_PID}" 2>/dev/null || true
ocsb_warn "xdg-dbus-proxy did not start — notifications are off"
return 1
}
# bubblewrap arguments for the sandbox (variant: read-only host, writable project)
ocsb_bwrap_args() {
local p f uid
uid=$(id -u)
OCSB_BWRAP=(
--die-with-parent --new-session --unshare-pid --unshare-ipc --unshare-uts
--ro-bind / / --dev /dev --proc /proc
--bind /tmp /tmp
--bind "${OCSB_PROJECT_DIR}" "${OCSB_PROJECT_DIR}"
--bind "${OCSB_WORKTREES_DIR}" "${OCSB_WORKTREES_DIR}"
--bind "${OCSB_STATE_DIR}" "${OCSB_STATE_DIR}"
--tmpfs "/run/user/${uid}"
)
for p in "${OCSB_RW[@]}"; do
p=$(ocsb_abs "${p}")
if [[ -e ${p} ]]; then
OCSB_BWRAP+=(--bind "${p}" "${p}")
else
ocsb_warn "OCSB_RW: no such path ${p}"
fi
done
for p in "${OCSB_RW_HOME[@]}"; do
[[ -e ${HOME}/${p} ]] && OCSB_BWRAP+=(--bind "${HOME}/${p}" "${HOME}/${p}")
done
# ssh: private keys hidden, the host's ssh-agent signs (load keys with ssh-add first)
if [[ -d ${HOME}/.ssh ]]; then
OCSB_BWRAP+=(--tmpfs "${HOME}/.ssh")
for f in "${HOME}"/.ssh/config "${HOME}"/.ssh/known_hosts "${HOME}"/.ssh/*.pub; do
[[ -e ${f} ]] && OCSB_BWRAP+=(--ro-bind "${f}" "${f}")
done
fi
if [[ -S ${SSH_AUTH_SOCK:-} ]]; then
OCSB_BWRAP+=(--bind "${SSH_AUTH_SOCK}" "${SSH_AUTH_SOCK}")
fi
# Hidden: directories become empty, files become empty
local hide=()
for p in "${OCSB_HIDE_HOME[@]}"; do hide+=("${HOME}/${p}"); done
for p in "${OCSB_HIDE[@]}"; do hide+=("$(ocsb_abs "${p}")"); done
for p in "${hide[@]}"; do
if [[ -d ${p} ]]; then
OCSB_BWRAP+=(--tmpfs "${p}")
elif [[ -e ${p} ]]; then
OCSB_BWRAP+=(--ro-bind /dev/null "${p}")
fi
done
if [[ -n ${OCSB_DBUS_PROXY_PID:-} && -S $(ocsb_dbus_sock) ]]; then
OCSB_BWRAP+=(--ro-bind "$(ocsb_dbus_sock)" "/run/user/${uid}/bus")
fi
OCSB_BWRAP+=(--chdir "${OCSB_PROJECT_DIR}")
}
ocsb_run() {
local bin
bin=$(ocsb_opencode_bin)
[[ -x ${bin} ]] || ocsb_die "OpenCode 2 not found (set OCSB_OPENCODE_BIN or put opencode in PATH)"
[[ -z ${OCSB_KUBECONFIG} || -r $(ocsb_abs "${OCSB_KUBECONFIG}") ]] || ocsb_die "OCSB_KUBECONFIG not readable: ${OCSB_KUBECONFIG}"
mkdir -p "${OCSB_WORKTREES_DIR}"
ocsb_prepare_state
# Tokens go to the environment before sg (sg keeps it), not into the command text (ps)
if [[ -n ${OCSB_ENV_FILE} ]]; then
local env_file
env_file=$(ocsb_abs "${OCSB_ENV_FILE}")
[[ -r ${env_file} ]] || ocsb_die "OCSB_ENV_FILE not readable: ${env_file}"
set -a
# shellcheck disable=SC1090
source "${env_file}"
set +a
fi
OPENCODE_SERVER_PASSWORD=$(head -1 "${OCSB_PASSWORD_FILE}")
export OPENCODE_SERVER_PASSWORD
unset OPENCODE_SERVER_USERNAME
local opencode_port=${OCSB_WEB_PORT}
if [[ ${OCSB_PANEL} == 1 ]]; then
# The panel's proxy lives inside OpenCode (plugins/context-panel) on WEB_PORT
opencode_port=$((OCSB_WEB_PORT + 1))
fi
local port
for port in "${OCSB_WEB_PORT}" "${opencode_port}"; do
if (exec 3<>"/dev/tcp/127.0.0.1/${port}") 2>/dev/null; then
ocsb_die "port ${port} is busy (OpenCode for this project already running? else set OCSB_WEB_PORT)"
fi
done
local s=${OCSB_STATE_DIR} proxy="http://127.0.0.1:${OCSB_EGRESS_PORT}" kv
# git over SSH goes through the filter too (needs `connect host:port` in egress.conf).
# -F: only the user's config. Inside the sandbox root-owned files look owned by nobody,
# and ssh refuses the system ssh_config.d with "Bad owner"
local ssh_cmd="ssh"
[[ -f ${HOME}/.ssh/config ]] && ssh_cmd+=" -F ${HOME}/.ssh/config"
[[ -f ${HOME}/.ssh/config ]] || ssh_cmd+=" -F /dev/null"
ssh_cmd+=" -o ProxyCommand=\"ncat --proxy 127.0.0.1:${OCSB_EGRESS_PORT} --proxy-type http %h %p\""
local env=(
"HTTP_PROXY=${proxy}" "http_proxy=${proxy}" "HTTPS_PROXY=${proxy}" "https_proxy=${proxy}"
"NO_PROXY=localhost,127.0.0.1,::1" "no_proxy=localhost,127.0.0.1,::1"
"GIT_SSH_COMMAND=${ssh_cmd}"
"XDG_CONFIG_HOME=${s}/config" "XDG_DATA_HOME=${s}/data" "XDG_STATE_HOME=${s}/state" "XDG_CACHE_HOME=${s}/cache"
"DOCKER_CONFIG=${s}/docker"
"CC_SAFETY_NET_HOME=${s}/cc-safety-net"
# The agent may not open a browser: it would run outside the filter (shim/xdg-open)
"PATH=${OCSB_HOME}/shim:${OCSB_HOME}/bin:${PATH}"
"GDK_DEBUG=no-portals"
"OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1" "OPENCODE_DISABLE_AUTOUPDATE=1" "OPENCODE_DISABLE_SHARE=1"
"CTX_PANEL_LISTEN=${OCSB_WEB_PORT}" "CTX_PANEL_TARGET=${opencode_port}"
"OCSB_PROJECT=${OCSB_PROJECT_DIR}" "OCSB_IN_SANDBOX=${OCSB_SANDBOX}"
)
[[ -n ${OCSB_KUBECONFIG} ]] && env+=("KUBECONFIG=$(ocsb_abs "${OCSB_KUBECONFIG}")")
env+=("${OCSB_ENV[@]}")
local cmd=("${bin}" serve --hostname 127.0.0.1 --port "${opencode_port}" "$@")
if [[ ${OCSB_SANDBOX} == 1 ]]; then
command -v bwrap >/dev/null || ocsb_die "OCSB_SANDBOX=1 but bwrap is missing (install bubblewrap) — refusing to run unsandboxed"
ssh-add -l >/dev/null 2>&1 || ocsb_warn "ssh-agent has no keys — git over SSH will fail in the sandbox (ssh-add)"
if ocsb_start_dbus_proxy; then
env+=("DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus")
trap 'kill "${OCSB_DBUS_PROXY_PID}" 2>/dev/null || true' EXIT
else
unset OCSB_DBUS_PROXY_PID
env+=("DBUS_SESSION_BUS_ADDRESS=")
fi
ocsb_bwrap_args
cmd=(bwrap "${OCSB_BWRAP[@]}" -- "${cmd[@]}")
# No permission prompts in the sandbox: permission rules from all configs form one
# list, the last match wins, and OPENCODE_CONFIG_CONTENT is the last document.
# Denies are repeated after "*" so they still win.
env+=('OPENCODE_CONFIG_CONTENT={"permission":{"bash":{"*":"allow","systemd-run *":"deny","sudo *":"deny"},"external_directory":{"*":"allow"}}}')
echo " sandbox: writable ${OCSB_PROJECT_DIR}, ${OCSB_WORKTREES_DIR}${OCSB_RW[*]:+, ${OCSB_RW[*]}}; the rest of the system is read-only"
fi
echo "→ OpenCode: ${OCSB_PROJECT_DIR}"
echo " web UI: http://127.0.0.1:${OCSB_WEB_PORT}/ — user opencode, password in ${OCSB_PASSWORD_FILE}"
# Inner command: env + exec, quoted for sg -c
local inner="exec env"
for kv in "${env[@]}"; do inner+=" $(printf '%q' "${kv}")"; done
for kv in "${cmd[@]}"; do inner+=" $(printf '%q' "${kv}")"; done
local opened=0 line
cd "${OCSB_PROJECT_DIR}" || return
sg "${OCSB_NETLOCK_GROUP}" -c "${inner}" 2>&1 | while IFS= read -r line; do
printf '%s\n' "${line}"
# The browser is opened by us, from the normal group, and only at the UI address
if [[ ${opened} -eq 0 && ${line} =~ http://127\.0\.0\.1:${opencode_port}(/|[[:space:]]|$) ]]; then
(ocsb_open_ui "${opencode_port}") &
opened=1
fi
done
}
# OpenCode loads plugins lazily, on the first API request for a project: until then the
# panel's proxy does not listen. Make that request, wait for the port, open the browser.
ocsb_open_ui() {
local opencode_port=$1 url="http://127.0.0.1:${OCSB_WEB_PORT}/" i
if [[ ${OCSB_PANEL} == 1 ]]; then
printf 'user = "opencode:%s"\n' "${OPENCODE_SERVER_PASSWORD}" |
curl -s -o /dev/null -m 10 -K - "http://127.0.0.1:${opencode_port}/api/plugin" || true
url="http://127.0.0.1:${opencode_port}/"
for i in $(seq 50); do
if (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_WEB_PORT}") 2>/dev/null; then
url="http://127.0.0.1:${OCSB_WEB_PORT}/"
break
fi
sleep 0.1
done
[[ ${url} == *":${OCSB_WEB_PORT}/" ]] || ocsb_warn "context panel did not come up — see ${OCSB_STATE_DIR}/data/opencode/log/context-panel.log"
fi
xdg-open "${url}" >/dev/null 2>&1 || true
}
+91
View File
@@ -0,0 +1,91 @@
# shellcheck shell=bash
# Worktrees for parallel agent sessions. Generic part here; project specifics (ports, .env,
# starting a stack, seeding) go into hooks:
# .opencode-sandbox/hooks/wt-post-add after `git worktree add`, cwd = the new worktree
# .opencode-sandbox/hooks/wt-pre-rm before removal (stop the stack), cwd = the worktree
# Hooks get WT_DIR, WT_BRANCH, WT_SLUG, MAIN_ROOT in the environment and the extra
# arguments of `wt add` / `wt rm` as their arguments. A failing hook aborts the command.
ocsb_wt_main_root() {
git -C "${OCSB_PROJECT_DIR}" rev-parse --path-format=absolute --git-common-dir | sed 's|/\.git$||'
}
ocsb_wt_slug() {
local branch=$1 slug=""
if [[ -n ${OCSB_WT_SLUG_REGEX} && ${branch} =~ ${OCSB_WT_SLUG_REGEX} ]]; then
slug=${BASH_REMATCH[1]}
fi
[[ -n ${slug} ]] || slug=$(printf '%s' "${branch}" | tr '/' '-' | tr -c 'a-zA-Z0-9._-' '-')
echo "${slug}"
}
ocsb_wt_hook() {
local name=$1 dir=$2
shift 2
local hook=${OCSB_PROJECT_DIR}/.opencode-sandbox/hooks/${name}
[[ -x ${hook} ]] || return 0
ocsb_info "hook ${name}"
(cd "${dir}" && "${hook}" "$@")
}
ocsb_wt_add() {
[[ $# -ge 1 ]] || ocsb_die "usage: opencode-sandbox wt add <branch> [hook args]"
local branch=$1 main slug dir base
shift
git check-ref-format --branch "${branch}" >/dev/null 2>&1 || ocsb_die "invalid branch name: ${branch}"
main=$(ocsb_wt_main_root)
slug=$(ocsb_wt_slug "${branch}")
dir=${OCSB_WORKTREES_DIR}/${OCSB_WT_PREFIX}${slug}
[[ -e ${dir} ]] && ocsb_die "already exists: ${dir}"
base=${OCSB_WT_BASE:-HEAD}
if [[ ${base} != HEAD ]] && git -C "${main}" rev-parse -q --verify "origin/${base}" >/dev/null; then
echo " ${base} is $(git -C "${main}" rev-list --count "${base}..origin/${base}") commit(s) behind origin/${base} (git fetch if needed)"
fi
mkdir -p "${OCSB_WORKTREES_DIR}"
git -C "${main}" worktree add -b "${branch}" "${dir}" "${base}"
export WT_DIR=${dir} WT_BRANCH=${branch} WT_SLUG=${slug} MAIN_ROOT=${main}
ocsb_wt_hook wt-post-add "${dir}" "$@"
echo "✓ worktree ${dir} (branch ${branch}); remove: opencode-sandbox wt rm ${slug}"
}
ocsb_wt_rm() {
[[ $# -ge 1 ]] || ocsb_die "usage: opencode-sandbox wt rm <name|path> [--force]"
local name=$1 force=0 main dir branch status a extra=()
shift
for a in "$@"; do
if [[ ${a} == --force ]]; then force=1; else extra+=("${a}"); fi
done
main=$(ocsb_wt_main_root)
case "${name}" in
/*) dir=${name} ;;
*) dir=${OCSB_WORKTREES_DIR}/${OCSB_WT_PREFIX}${name} ;;
esac
[[ -d ${dir} ]] || ocsb_die "no such worktree: ${dir}"
[[ $(realpath "${dir}") != "$(realpath "${main}")" ]] || ocsb_die "refusing to remove the main worktree"
branch=$(git -C "${dir}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
export WT_DIR=${dir} WT_BRANCH=${branch} WT_SLUG=${name##*/} MAIN_ROOT=${main}
ocsb_wt_hook wt-pre-rm "${dir}" "${extra[@]}"
status=$(git -C "${dir}" status --porcelain)
if [[ -n ${status} && ${force} -ne 1 ]]; then
echo "${status}" >&2
ocsb_die "worktree has uncommitted changes — commit them or pass --force (they will be lost)"
fi
if [[ ${force} -eq 1 ]]; then
git -C "${main}" worktree remove --force "${dir}"
else
git -C "${main}" worktree remove "${dir}"
fi
git -C "${main}" worktree prune
echo "✓ removed ${dir} (branch ${branch:-?} kept)"
}
ocsb_wt() {
local sub=${1:-list}
[[ $# -gt 0 ]] && shift
case "${sub}" in
add) ocsb_wt_add "$@" ;;
rm) ocsb_wt_rm "$@" ;;
list) git -C "${OCSB_PROJECT_DIR}" worktree list ;;
*) ocsb_die "wt: unknown subcommand ${sub} (add|rm|list)" ;;
esac
}
+10
View File
@@ -0,0 +1,10 @@
# Environment: opencode-sandbox
You run inside opencode-sandbox. Load the `opencode-sandbox` skill before working with the
network, files outside the project, git remotes, Docker, or worktrees. In short:
- You may write only to the project, its worktrees and paths listed in `OCSB_RW`; the rest of
the system is read-only. "Read-only file system" is the sandbox, not a bug — do not retry.
- Network goes through a filtering proxy. A 403 from the proxy is policy: tell the user which
domain you need and why; do not try to bypass it.
- You cannot open a browser; give the user the link instead.
+20
View File
@@ -0,0 +1,20 @@
// Global OpenCode 2 config used by opencode-sandbox for every project. Override it with
// ~/.config/opencode-sandbox/opencode.jsonc or OCSB_OPENCODE_CONFIG. Project settings
// (model, MCP servers, project plugins) stay in the project's opencode.jsonc.
{
"$schema": "https://opencode.ai/config.json",
"share": "disabled",
"update": "disable",
// Plugin lists from the global and project configs are merged. Versions are pinned: a
// plugin is code inside the OpenCode process with all of its rights.
"plugins": [
// desktop notifications (in the sandbox: through a D-Bus proxy limited to notifications)
"@mohak34/opencode-notifier@1.1.0",
// parses commands before tools run and blocks destructive ones (git reset --hard,
// clean -f, checkout --, branch -D, rm -rf on dangerous paths, curl | bash) and secret
// access (~/.ssh, .env). Project exceptions: <project>/.cc-safety-net/policy.json.
// Does not inspect docker or kubectl
"cc-safety-net@2.7.0"
]
}
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# xdg-open for processes inside opencode-sandbox (OpenCode and the agent's shell). A browser
# started from here would inherit the netlock group and the proxy, and an already running
# one would open the agent's link without any filter. opencode-sandbox opens the web UI
# itself, from the user's normal group.
echo "xdg-open is disabled in the sandbox: $*" >&2
echo "Ask the user to open the link." >&2
exit 1
+70
View File
@@ -0,0 +1,70 @@
---
name: opencode-sandbox
description: "How the opencode-sandbox environment works: what is writable, how network access is filtered (proxy, 403, connect rules, git over SSH), hidden credentials, Docker, worktrees (opencode-sandbox wt add/rm). Use when a command fails with 'Read-only file system', 'Permission denied', a proxy 403 or a connection error, before touching git remotes, Docker or files outside the project, and before creating or removing a worktree (worktrees are only for changing code on a new branch — not for questions, search, analysis or review)."
---
# Working inside opencode-sandbox
The project directory is `$OCSB_PROJECT`. `$OCSB_IN_SANDBOX` is `1` when the filesystem
sandbox is on.
## Files
| Path | Access |
|---|---|
| The project, its worktrees directory, paths from `OCSB_RW` in `.opencode-sandbox/sandbox.conf` | read-write, real disk |
| Toolchain caches in `~` (`.cache`, `.npm`, `.asdf`, `.bundle`, `.gem`, …) | read-write |
| `/tmp` | read-write, shared with the host |
| Everything else | read-only |
| Private SSH keys, `~/.gnupg`, cloud CLI credentials, `~/.kube/config`, browser profiles, other agents' configs, `OCSB_HIDE` paths | hidden (empty) |
- `Read-only file system` / `EROFS` means the path is outside the writable set. Do not retry,
do not look for another way to write there. If the task really needs it, tell the user
which path and why — they can add it to `OCSB_RW`.
- A hidden credential is not a bug either; ask the user instead of searching for it.
## Network
- All HTTP(S) goes through the egress proxy (`HTTPS_PROXY`). Direct connections (ping, raw
TCP, IP literals, SSH without the proxy) are rejected by the kernel.
- A `403` from the proxy, or `CONNECT tunnel failed, response 403`, is policy. Tell the user
the exact host (and port) you need and why. Rules live in `.opencode-sandbox/egress.conf`
(`allow`, `block`, `connect host:port`); do not edit them yourself unless asked.
- Private networks, names without a dot and ports other than 80/443 are blocked unless the
project allows them with `connect`.
- git over SSH works through the proxy (`GIT_SSH_COMMAND` is set) when the host and port are
allowed with `connect`; the key is in the host's ssh-agent, not on disk.
- Containers get the proxy through `DOCKER_CONFIG` (builds and `docker run`).
## Things you cannot do
- Open a browser (`xdg-open` is disabled): print the link for the user.
- `sudo`, `systemd-run`: denied.
- Start processes outside the sandbox through the session bus: only desktop notifications
are reachable.
## Docker
The Docker socket is available, so Docker can see the whole host. Mount only paths inside
the project or its worktrees; never mount `/`, `~` or system directories.
## Worktrees
A worktree is for **changing code on a new branch** — and only that. Do not create one for
questions, search, reading code, analysis, review or planning: work in the current tree
read-only. Do not create one "just in case" before you know changes are needed, and do not
create a second one if your session already has a worktree for the task.
When you do change code: one session — one worktree. Create and remove them with the
package's command; the project's hooks start and stop its stack:
```shell
opencode-sandbox wt add feature-PROJ-42-login --seed # extra args go to the project hook
opencode-sandbox wt rm 42 # refuses if there are uncommitted changes
opencode-sandbox wt rm 42 --force # discards them
opencode-sandbox wt list
```
Worktrees live in the worktrees directory next to the project (writable). Remove yours
after the merge, and right away if it turned out no changes were needed; `wt list` shows
leftovers.
@@ -0,0 +1,5 @@
{
"destructive_command_protection": {
"overrides": {}
}
}
+19
View File
@@ -0,0 +1,19 @@
# Network rules for this project, on top of the package defaults. Commit this file;
# personal or secret rules go to local/egress.conf.
#
# Policy: everything is allowed except what is blocked; private networks, names without a
# dot and ports other than 80/443 are always blocked unless allowed here.
#
# allow <domain>... .domain = the domain and all subdomains
# allow-regex <regex>...
# block <domain>...
# block-regex <regex>...
# connect <host:port>... tunnel to a non-443 port or an internal address
# expect-block <url> self-test (opencode-sandbox egress verify)
# expect-pass <url>
#
# Example: allow internal services but keep production out of reach
# allow git.example.internal ci.example.internal
# block .example.internal
# connect git.example.internal:2222 # git over SSH
# expect-block https://prod.example.internal/
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
# Runs after `opencode-sandbox wt add <branch> [args]` in the new worktree.
# Environment: WT_DIR, WT_BRANCH, WT_SLUG, MAIN_ROOT. Arguments: extra args of `wt add`.
# Rename to wt-post-add and chmod +x to enable. A non-zero exit fails the command.
set -eu
# Example: give each worktree its own port and start its stack
# port=$((3000 + $(git -C "$MAIN_ROOT" worktree list | wc -l) * 10))
# echo "APP_PORT=$port" > .env
# make up
+8
View File
@@ -0,0 +1,8 @@
#!/bin/sh
# Runs before `opencode-sandbox wt rm <name> [args]` removes the worktree, in the worktree.
# Environment: WT_DIR, WT_BRANCH, WT_SLUG, MAIN_ROOT. Arguments: extra args of `wt rm`.
# Rename to wt-pre-rm and chmod +x to enable. A non-zero exit aborts the removal.
set -eu
# Example: stop the worktree's stack
# make down || true
+28
View File
@@ -0,0 +1,28 @@
# opencode-sandbox settings for this project (bash). Commit this file; personal overrides
# go to local/sandbox.conf. All settings and defaults: `opencode-sandbox config`.
# OCSB_PROJECT_DIR is set when this file is read.
# OCSB_NAME=myproject # container/state name; default: directory name
# OCSB_WEB_PORT=4196 # web UI; OpenCode listens on +1. Different per project
# OCSB_EGRESS_PORT=3128 # egress filter. Different per project
# Writable for the agent besides the project and its worktrees (relative to the project)
# OCSB_RW=(../shared-fixtures)
# Hidden from the agent (relative to the project)
# OCSB_HIDE=(../secrets)
# Skills mounted for the agent: names of skill directories from the package's skills/
# or OCSB_SKILL_DIRS
# OCSB_SKILLS=(opencode-sandbox)
# OCSB_SKILL_DIRS=(~/skills)
# Tokens for MCP servers and the model provider: KEY=VALUE file, keep it out of git
# OCSB_ENV_FILE=~/.config/myproject/agent.env
# OCSB_KUBECONFIG=~/.kube/myproject-agent.yaml
# OCSB_ENV=(SOME_FLAG=1)
# Worktrees: <OCSB_WORKTREES_DIR>/<OCSB_WT_PREFIX><slug>
# OCSB_WORKTREES_DIR=../myproject-worktrees
# OCSB_WT_PREFIX=myproject-wt-
# OCSB_WT_BASE=main
# OCSB_WT_SLUG_REGEX='PROJ-([0-9]+)' # branch feature-PROJ-42-x → slug 42