commit 64c222a08498077e01bdd2d0b1238cd64838b8ec Author: raptorggi Date: Fri Oct 30 01:00:02 2026 -0700 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/ - 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee6a7f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +# nothing generated lives in the repo; state is in ~/.local/share/opencode-sandbox diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c39390f --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..d9a6806 --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,3 @@ +# Settings are shared between sourced files, and bash config files set them +disable=SC2034 +source-path=SCRIPTDIR diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3b9760d --- /dev/null +++ b/Makefile @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..bf33cfb --- /dev/null +++ b/README.md @@ -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 ~/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. + +## 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_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`, `/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 :` 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` | +| `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 [args] | rm [--force] | list +``` diff --git a/README.ru.md b/README.ru.md new file mode 100644 index 0000000..424b6ac --- /dev/null +++ b/README.ru.md @@ -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` | — | каталог дерева = `<префикс>` | +| `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_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 +``` diff --git a/bin/opencode-sandbox b/bin/opencode-sandbox new file mode 100755 index 0000000..be7098c --- /dev/null +++ b/bin/opencode-sandbox @@ -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 [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 [hook args] Create a worktree (runs .opencode-sandbox/hooks/wt-post-add) + wt rm [--force] Remove a worktree (runs .opencode-sandbox/hooks/wt-pre-rm) + wt list + +Environment: OCSB_=... 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 diff --git a/egress/Dockerfile b/egress/Dockerfile new file mode 100644 index 0000000..b7213d8 --- /dev/null +++ b/egress/Dockerfile @@ -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"] diff --git a/egress/defaults.conf b/egress/defaults.conf new file mode 100644 index 0000000..5848d45 --- /dev/null +++ b/egress/defaults.conf @@ -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/ diff --git a/egress/entrypoint.sh b/egress/entrypoint.sh new file mode 100755 index 0000000..c918e4a --- /dev/null +++ b/egress/entrypoint.sh @@ -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 diff --git a/lib/common.sh b/lib/common.sh new file mode 100644 index 0000000..15722c3 --- /dev/null +++ b/lib/common.sh @@ -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. /.opencode-sandbox/sandbox.conf (committed) +# 4. /.opencode-sandbox/local/sandbox.conf (git-ignored, personal) +# 5. OCSB_ 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: /-worktrees + OCSB_WT_PREFIX="" # worktree directory = + 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/ + 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 +} diff --git a/lib/doctor.sh b/lib/doctor.sh new file mode 100644 index 0000000..a9844f5 --- /dev/null +++ b/lib/doctor.sh @@ -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} +} diff --git a/lib/egress.sh b/lib/egress.sh new file mode 100644 index 0000000..e631c7c --- /dev/null +++ b/lib/egress.sh @@ -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): +# /egress/defaults.conf +# ~/.config/opencode-sandbox/egress.conf +# /.opencode-sandbox/egress.conf (committed with the project) +# /.opencode-sandbox/local/egress.conf (git-ignored) +# +# egress.conf lines (# starts a comment): +# allow ... allowed even if a block rule matches (.domain = with subdomains) +# allow-regex ... same, by regular expression +# block ... denied +# block-regex ... +# connect ... 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 [curl args] self-test: must be refused (egress verify) +# expect-pass [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 "directivearg" 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 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 +} diff --git a/lib/init.sh b/lib/init.sh new file mode 100644 index 0000000..3cf3e45 --- /dev/null +++ b/lib/init.sh @@ -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 < +# 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}" <"${UNIT_PATH}" </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 [user] | teardown" >&2; exit 1 ;; +esac diff --git a/lib/netlock.sh b/lib/netlock.sh new file mode 100644 index 0000000..8c9a4cb --- /dev/null +++ b/lib/netlock.sh @@ -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 `, 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 +} diff --git a/lib/run.sh b/lib/run.sh new file mode 100644 index 0000000..fc071e0 --- /dev/null +++ b/lib/run.sh @@ -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 +} diff --git a/lib/worktree.sh b/lib/worktree.sh new file mode 100644 index 0000000..c9fb25c --- /dev/null +++ b/lib/worktree.sh @@ -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 [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 [--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 +} diff --git a/opencode/AGENTS.md b/opencode/AGENTS.md new file mode 100644 index 0000000..735a9de --- /dev/null +++ b/opencode/AGENTS.md @@ -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. diff --git a/opencode/opencode.jsonc b/opencode/opencode.jsonc new file mode 100644 index 0000000..3b68279 --- /dev/null +++ b/opencode/opencode.jsonc @@ -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: /.cc-safety-net/policy.json. + // Does not inspect docker or kubectl + "cc-safety-net@2.7.0" + ] +} diff --git a/plugins/context-panel b/plugins/context-panel new file mode 160000 index 0000000..1eef73e --- /dev/null +++ b/plugins/context-panel @@ -0,0 +1 @@ +Subproject commit 1eef73ec225cccd908653429405cbaa6bd4b44cb diff --git a/plugins/skill-compact-resurrect b/plugins/skill-compact-resurrect new file mode 160000 index 0000000..14779f4 --- /dev/null +++ b/plugins/skill-compact-resurrect @@ -0,0 +1 @@ +Subproject commit 14779f452087bb83c99fe8a764a1ee2666030024 diff --git a/shim/xdg-open b/shim/xdg-open new file mode 100755 index 0000000..63d7d37 --- /dev/null +++ b/shim/xdg-open @@ -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 diff --git a/skills/opencode-sandbox/SKILL.md b/skills/opencode-sandbox/SKILL.md new file mode 100644 index 0000000..df2ad52 --- /dev/null +++ b/skills/opencode-sandbox/SKILL.md @@ -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. diff --git a/templates/project/cc-safety-net-policy.json b/templates/project/cc-safety-net-policy.json new file mode 100644 index 0000000..1ade866 --- /dev/null +++ b/templates/project/cc-safety-net-policy.json @@ -0,0 +1,5 @@ +{ + "destructive_command_protection": { + "overrides": {} + } +} diff --git a/templates/project/egress.conf b/templates/project/egress.conf new file mode 100644 index 0000000..f826325 --- /dev/null +++ b/templates/project/egress.conf @@ -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 = the domain and all subdomains +# allow-regex ... +# block ... +# block-regex ... +# connect ... tunnel to a non-443 port or an internal address +# expect-block self-test (opencode-sandbox egress verify) +# expect-pass +# +# 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/ diff --git a/templates/project/hooks/wt-post-add.example b/templates/project/hooks/wt-post-add.example new file mode 100755 index 0000000..eb20182 --- /dev/null +++ b/templates/project/hooks/wt-post-add.example @@ -0,0 +1,10 @@ +#!/bin/sh +# Runs after `opencode-sandbox wt add [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 diff --git a/templates/project/hooks/wt-pre-rm.example b/templates/project/hooks/wt-pre-rm.example new file mode 100755 index 0000000..2fc3a11 --- /dev/null +++ b/templates/project/hooks/wt-pre-rm.example @@ -0,0 +1,8 @@ +#!/bin/sh +# Runs before `opencode-sandbox wt rm [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 diff --git a/templates/project/sandbox.conf b/templates/project/sandbox.conf new file mode 100644 index 0000000..4eecbb5 --- /dev/null +++ b/templates/project/sandbox.conf @@ -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=../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