From 01fc1627c18bb65faf48202e7cc93bb24b51712f Mon Sep 17 00:00:00 2001 From: raptorggi Date: Fri, 30 Oct 2026 01:31:36 -0700 Subject: [PATCH] Protect host-run files from the agent; trust registry; git audit - read-only in the sandbox: .opencode-sandbox/, .cc-safety-net/, .git/hooks, .git/config, an in-project core.hooksPath, the egress rules and the safety-net home; .git is pinned by a bind onto itself so it cannot be swapped - only trusted projects are loaded (init / trust): a .opencode-sandbox/ in a worktree or a subdirectory is no longer sourced on the host - run (before and after the session) and doctor report planted .git entries, redirected worktrees and per-worktree overrides - hide Firefox, Claude, VS Code, Cursor and Codex configs --- README.md | 21 +++++++++++- README.ru.md | 22 +++++++++++- bin/opencode-sandbox | 4 +++ lib/common.sh | 36 +++++++++++++++++--- lib/doctor.sh | 2 ++ lib/init.sh | 2 ++ lib/run.sh | 58 +++++++++++++++++++++++++++++++- skills/opencode-sandbox/SKILL.md | 6 ++++ 8 files changed, 143 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b54a8a4..b345791 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,11 @@ browser opens at `http://127.0.0.1:4196/`; log in as `opencode` with the passwor `~/.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 gets the same sandbox. `sandbox.conf` and the hooks are bash that runs on your host, so +only trusted projects are loaded: `init` trusts the project it creates; in a clone, read +`.opencode-sandbox/` and run `opencode-sandbox trust` (the list is +`~/.config/opencode-sandbox/projects`). A `.opencode-sandbox/` in a worktree or a +subdirectory is ignored. ## Project files @@ -233,6 +237,14 @@ Inside the sandbox: browsers' singleton sockets, other tools), so the agent gets its own, stored in `/tmp` (files older than 7 days are removed on start). Look there for its screenshots and logs. Docker mounts of `/tmp` paths would point at the host's `/tmp` — mount project paths. +- **Read-only inside the project:** what the host runs outside the sandbox — `.opencode-sandbox/`, + `.cc-safety-net/`, `.git/hooks`, `.git/config` (`core.fsmonitor` there runs on any + `git status`, a shell prompt is enough), `core.hooksPath` if it is in the project — and the + egress rules and safety-net home in the state directory. `.git` itself is a mount point, so + it cannot be renamed and replaced. Git still works; only `git config` writes fail (e.g. + `push -u` cannot record the upstream). After each session `run` reports any other `.git` in + the project and worktrees, worktrees whose `.git` points elsewhere and per-worktree + overrides; `doctor` does the same. - **Hidden:** `OCSB_HIDE_HOME`, `OCSB_HIDE`, private keys in `~/.ssh` (`config`, `known_hosts` and `*.pub` stay visible; the host's ssh-agent signs). - **Network:** `HTTP(S)_PROXY` point to the filter. `GIT_SSH_COMMAND` sends ssh through it with @@ -269,6 +281,12 @@ place, requests to production — not to hold a determined attacker. - **Allowed hosts are a channel.** Data can leave through any host the rules allow. - **The project is writable.** Mistakes inside it are real; cc-safety-net blocks the common destructive commands, git keeps history — commit often. +- **Files you run yourself.** Hooks, configs and git metadata are protected (see above), but + the agent edits code: a changed `Makefile`, `package.json` script or `bin/` file runs with + your rights when you run it. Look at `git diff` before running project commands on the host. +- **Planted repositories are reported, not prevented.** A `.git` the agent creates in a new + directory or a new worktree is found by the check after the session; don't run git there + until you have looked. - With `OCSB_TMP=shared` the host's `/tmp` and its sockets are reachable. - With `OCSB_DISPLAY=1` the agent can open windows on your desktop and use the clipboard while its window is focused. @@ -304,6 +322,7 @@ docker rmi opencode-sandbox-egress ``` opencode-sandbox init add .opencode-sandbox/ to the current repository +opencode-sandbox trust [dir] trust an existing .opencode-sandbox/ (a clone) opencode-sandbox config effective settings opencode-sandbox doctor dependencies and host setup opencode-sandbox run [opencode args] everything, then OpenCode diff --git a/README.ru.md b/README.ru.md index 91e0e98..f93bc85 100644 --- a/README.ru.md +++ b/README.ru.md @@ -69,7 +69,11 @@ opencode-sandbox run `~/.local/share/opencode-sandbox/<проект>/password` (выводится при первом запуске). Закоммитьте `.opencode-sandbox/` (кроме `local/`) и `.cc-safety-net/` — каждый, кто -склонирует проект, получит ту же песочницу. +склонирует проект, получит ту же песочницу. `sandbox.conf` и хуки — это bash, который +выполняется на вашем хосте, поэтому загружаются только доверенные проекты: `init` доверяет +созданному проекту, а в клоне прочитайте `.opencode-sandbox/` и выполните +`opencode-sandbox trust` (список — `~/.config/opencode-sandbox/projects`). +`.opencode-sandbox/` в worktree или подкаталоге игнорируется. ## Файлы проекта @@ -233,6 +237,14 @@ opencode-sandbox run (tmux, X11, сокеты браузеров, других программ), поэтому у агента свой `/tmp` — в `<состояние>/tmp` (файлы старше 7 дней удаляются при запуске). Скриншоты и логи агента ищите там. Docker смонтирует путь из `/tmp` из `/tmp` хоста — монтируйте пути проекта. +- **Только чтение внутри проекта:** то, что хост выполняет вне песочницы, — `.opencode-sandbox/`, + `.cc-safety-net/`, `.git/hooks`, `.git/config` (`core.fsmonitor` оттуда срабатывает при любом + `git status`, хватит prompt'а оболочки), `core.hooksPath`, если он внутри проекта, — а также + правила egress и домашний каталог safety-net в каталоге состояния. Сам `.git` — точка + монтирования, его нельзя переименовать и подменить. git работает; не проходит только запись + `git config` (например, `push -u` не запишет upstream). После каждой сессии `run` сообщает + о других `.git` в проекте и worktree, о worktree, чей `.git` указывает в другое место, и о + переопределениях для отдельных worktree; `doctor` проверяет то же. - **Скрыто:** `OCSB_HIDE_HOME`, `OCSB_HIDE`, приватные ключи в `~/.ssh` (`config`, `known_hosts` и `*.pub` видны; подписывает ssh-agent хоста). - **Сеть:** `HTTP(S)_PROXY` указывают на фильтр. `GIT_SSH_COMMAND` пускает ssh через него @@ -269,6 +281,13 @@ opencode-sandbox run правила. - **Проект доступен на запись.** Ошибки внутри него настоящие; cc-safety-net блокирует типичные разрушительные команды, git хранит историю — коммитьте чаще. +- **Файлы, которые вы запускаете сами.** Хуки, конфиги и метаданные git защищены (см. выше), + но код агент правит: изменённый `Makefile`, скрипт в `package.json` или файл в `bin/` + выполнится с вашими правами, когда вы его запустите. Смотрите `git diff`, прежде чем + запускать команды проекта на хосте. +- **Подложенные репозитории обнаруживаются, а не предотвращаются.** `.git`, созданный агентом + в новом каталоге или новом worktree, найдёт проверка после сессии; не запускайте там git, + пока не посмотрели. - С `OCSB_TMP=shared` доступны `/tmp` хоста и его сокеты. - С `OCSB_DISPLAY=1` агент может открывать окна на вашем экране и пользоваться буфером обмена, пока его окно в фокусе. @@ -304,6 +323,7 @@ docker rmi opencode-sandbox-egress ``` opencode-sandbox init добавить .opencode-sandbox/ в текущий репозиторий +opencode-sandbox trust [каталог] доверять существующему .opencode-sandbox/ (клон) opencode-sandbox config итоговые настройки opencode-sandbox doctor зависимости и настройки хоста opencode-sandbox run [аргументы opencode] всё сразу, затем OpenCode diff --git a/bin/opencode-sandbox b/bin/opencode-sandbox index be7098c..e6b17e6 100755 --- a/bin/opencode-sandbox +++ b/bin/opencode-sandbox @@ -16,6 +16,7 @@ Usage: opencode-sandbox [args] Project setup init Create .opencode-sandbox/ in the current git repository + trust [dir] Trust an existing .opencode-sandbox/ (a clone): its config runs on the host config Print the effective configuration doctor Check dependencies and host setup @@ -46,6 +47,9 @@ case "${command_name}" in source "${OCSB_HOME}/lib/init.sh" ocsb_init "$@" ;; + trust) + ocsb_trust "$@" + ;; config) ocsb_load_project ocsb_print_config diff --git a/lib/common.sh b/lib/common.sh index 3c96120..ceaa8f9 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -55,19 +55,44 @@ ocsb_defaults() { 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 .Xauthority) + .config/opencode .local/share/opencode .Xauthority .config/mozilla .config/Claude .config/Code + .config/Cursor .config/Codex) 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 } +# Trusted projects (init / trust), one physical path per line. Only these are loaded: +# sandbox.conf is bash run on the host, and the agent can write anywhere in the project +# tree, so a .opencode-sandbox/ found in a worktree or a subdirectory proves nothing. +ocsb_trust_file() { echo "${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/projects"; } +ocsb_trusted() { [[ -f $(ocsb_trust_file) ]] && grep -Fxq -- "$1" "$(ocsb_trust_file)"; } +ocsb_trust() { + local dir f + dir=$(cd "${1:-.}" && pwd -P) || ocsb_die "no such directory: ${1}" + [[ -f ${dir}/.opencode-sandbox/sandbox.conf ]] || ocsb_die "no ${dir}/.opencode-sandbox/sandbox.conf" + [[ -z ${OCSB_IN_SANDBOX:-} ]] || ocsb_die "trust is a host command" + ocsb_trusted "${dir}" && { echo "already trusted: ${dir}"; return 0; } + f=$(ocsb_trust_file) + mkdir -p "$(dirname "${f}")" + echo "${dir}" >>"${f}" + echo "✓ trusted: ${dir}" +} + +# Nearest trusted project at or above the directory ocsb_find_project() { - local dir=${1:-$PWD} + local dir untrusted="" + dir=$(cd "${1:-.}" && pwd -P) while [[ ${dir} != / ]]; do - [[ -f ${dir}/.opencode-sandbox/sandbox.conf ]] && { echo "${dir}"; return 0; } + if [[ -f ${dir}/.opencode-sandbox/sandbox.conf ]]; then + ocsb_trusted "${dir}" && { echo "${dir}"; return 0; } + untrusted=${untrusted:-${dir}} + fi dir=$(dirname "${dir}") done + [[ -z ${untrusted} ]] || + ocsb_warn "${untrusted} is not a trusted project: read its .opencode-sandbox/ (sandbox.conf and hooks run on the host), then: opencode-sandbox trust ${untrusted}" return 1 } @@ -82,12 +107,13 @@ ocsb_load_project() { OCSB_PROJECT_DIR="" if [[ -n ${OCSB_PROJECT:-} ]]; then - OCSB_PROJECT_DIR=$(cd "${OCSB_PROJECT}" && pwd) + OCSB_PROJECT_DIR=$(cd "${OCSB_PROJECT}" && pwd -P) + ocsb_trusted "${OCSB_PROJECT_DIR}" || ocsb_die "OCSB_PROJECT=${OCSB_PROJECT} is not a trusted project (opencode-sandbox trust)" 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)" + [[ ${optional} == 1 ]] || ocsb_die "no trusted .opencode-sandbox/ here or above (new project: opencode-sandbox init)" fi export OCSB_PROJECT_DIR diff --git a/lib/doctor.sh b/lib/doctor.sh index a9844f5..cbc76d5 100644 --- a/lib/doctor.sh +++ b/lib/doctor.sh @@ -32,6 +32,8 @@ ocsb_doctor() { echo "Project: ${OCSB_PROJECT_DIR} (name ${OCSB_NAME}, state ${OCSB_STATE_DIR})" source "${OCSB_HOME}/lib/egress.sh" ocsb_egress status + source "${OCSB_HOME}/lib/run.sh" + if ocsb_git_audit; then echo " ✓ no planted git metadata in the project and worktrees"; else fail=1; fi else echo "Project: none here (opencode-sandbox init)" fi diff --git a/lib/init.sh b/lib/init.sh index 3cf3e45..0efcb01 100644 --- a/lib/init.sh +++ b/lib/init.sh @@ -17,12 +17,14 @@ ocsb_init() { mkdir -p "${root}/.cc-safety-net" cp "${t}/cc-safety-net-policy.json" "${root}/.cc-safety-net/policy.json" fi + ocsb_trust "${root}" >/dev/null cat </dev/null) || true + if [[ -n ${hooks} ]]; then + hooks=$(cd "${d}" && realpath -m -- "${hooks/#\~/${HOME}}") + [[ ${hooks} == "${d}"/* && -e ${hooks} ]] && OCSB_BWRAP+=(--ro-bind "${hooks}" "${hooks}") + fi +} + +# Git repositories planted for the host's git. The main .git is read-only for the agent, but +# it can still create a .git anywhere it writes, point a worktree's .git elsewhere, or add +# per-worktree overrides — and a repository's config can run commands. Report all of that. +ocsb_git_audit() { + local main=${OCSB_PROJECT_DIR}/.git f gitdir found=() roots=() + [[ -d ${main} ]] || return 0 + roots=("${OCSB_PROJECT_DIR}") + [[ -d ${OCSB_WORKTREES_DIR} ]] && roots+=("$(realpath -- "${OCSB_WORKTREES_DIR}")") + while IFS= read -r -d '' f; do + [[ ${f} == "${main}" ]] && continue + if [[ -f ${f} && ! -L ${f} ]]; then + gitdir=$(sed -n '1s/^gitdir: //p' "${f}") + [[ ${gitdir} == "${main}/worktrees/"* && ${gitdir} != */..* && $(cat "${gitdir}/gitdir" 2>/dev/null) == "${f}" ]] && continue + fi + found+=("${f}") + done < <(find "${roots[@]}" -name .git -prune -print0 2>/dev/null) + for f in "${main}"/worktrees/*/; do + [[ -d ${f} ]] || continue + [[ $(cat "${f}commondir" 2>/dev/null) == ../.. ]] || found+=("${f}commondir") + [[ -e ${f}config.worktree ]] && found+=("${f}config.worktree") + done + [[ -e ${main}/commondir ]] && found+=("${main}/commondir") + ((${#found[@]} == 0)) && return 0 + ocsb_warn "unexpected git metadata — check it before running git there (a repository config can run commands):" + printf ' %s\n' "${found[@]}" >&2 + return 1 +} + # bubblewrap arguments for the sandbox (variant: read-only host, writable project) ocsb_bwrap_args() { local p f uid @@ -174,6 +227,7 @@ ocsb_bwrap_args() { OCSB_BWRAP+=(--ro-bind /dev/null "${p}") fi done + ocsb_protect_args if [[ -n ${OCSB_DBUS_PROXY_PID:-} && -S $(ocsb_dbus_sock) ]]; then OCSB_BWRAP+=(--ro-bind "$(ocsb_dbus_sock)" "/run/user/${uid}/bus") fi @@ -250,12 +304,13 @@ ocsb_run() { 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 + # On any exit (Ctrl-C too): stop the bus proxy, look for what the session planted + trap 'kill "${OCSB_DBUS_PROXY_PID:-}" 2>/dev/null; ocsb_git_audit || true' EXIT 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. @@ -282,6 +337,7 @@ ocsb_run() { for kv in "${env[@]}"; do inner+=" $(printf '%q' "${kv}")"; done for kv in "${cmd[@]}"; do inner+=" $(printf '%q' "${kv}")"; done + [[ ${OCSB_SANDBOX} == 1 ]] && { ocsb_git_audit || true; } local opened=0 line cd "${OCSB_PROJECT_DIR}" || return sg "${OCSB_NETLOCK_GROUP}" -c "${inner}" 2>&1 | while IFS= read -r line; do diff --git a/skills/opencode-sandbox/SKILL.md b/skills/opencode-sandbox/SKILL.md index 9196341..b9a0374 100644 --- a/skills/opencode-sandbox/SKILL.md +++ b/skills/opencode-sandbox/SKILL.md @@ -16,12 +16,18 @@ sandbox is on. | Toolchain caches in `~` (`.cache`, `.npm`, `.asdf`, `.bundle`, `.gem`, …) | read-write | | `/tmp` | read-write, **private** to the sandbox (on the host it is `/tmp`; files older than 7 days are cleaned) | | Everything else | read-only | +| `.opencode-sandbox/`, `.cc-safety-net/`, `.git/hooks`, `.git/config` | read-only (the host runs them) | | 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. +- `git config` writes fail (`could not lock config file`): commits, branches, worktrees, fetch + and push work; `push -u` just cannot record the upstream — use `git push origin `. + Need a sandbox, egress or safety-net rule changed? Tell the user what and why. +- Do not create git repositories (`git init`, `git clone`) inside the project or the worktrees + directory unless asked: the user is warned about every unexpected `.git` there. ## Network