Files
opencode-sandbox/lib/run.sh
T
k.denisov 01fc1627c1 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
2026-10-30 01:31:36 -07:00

372 lines
17 KiB
Bash

# 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
}
# Files the host trusts and runs outside the sandbox stay read-only for the agent: the
# sandbox config and hooks, the safety-net policies, the egress rules, git hooks and config
# (core.fsmonitor runs on any git status — a shell prompt is enough). .git is first bound
# onto itself: a mount point cannot be renamed, so it cannot be swapped for a planted one.
ocsb_protect_args() {
local d=${OCSB_PROJECT_DIR} p hooks
mkdir -p "${d}/.cc-safety-net" "${OCSB_STATE_DIR}/cc-safety-net"
for p in "${d}/.opencode-sandbox" "${d}/.cc-safety-net" "${OCSB_STATE_DIR}/cc-safety-net" "${OCSB_STATE_DIR}/egress"; do
OCSB_BWRAP+=(--ro-bind "${p}" "${p}")
done
if [[ -d ${d}/.git ]]; then
mkdir -p "${d}/.git/hooks"
OCSB_BWRAP+=(--bind "${d}/.git" "${d}/.git")
for p in "${d}"/.git/{hooks,config} "${d}"/.git/modules/*/{hooks,config}; do
[[ -e ${p} ]] && OCSB_BWRAP+=(--ro-bind "${p}" "${p}")
done
fi
# core.hooksPath inside the project (husky and the like)
hooks=$(git -C "${d}" config --get core.hooksPath 2>/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
uid=$(id -u)
OCSB_BWRAP=(
--die-with-parent --new-session --unshare-pid --unshare-ipc --unshare-uts
--ro-bind / / --dev /dev --proc /proc
--tmpfs "/run/user/${uid}"
)
# /tmp: the host's holds sockets that lead out of the sandbox (tmux, X11, browsers,
# other agents) — by default the agent gets its own, kept in the state directory.
# Mounted before the project and the state, so they stay visible if they live under /tmp
if [[ ${OCSB_TMP} == shared ]]; then
OCSB_BWRAP+=(--bind /tmp /tmp)
else
mkdir -p "${OCSB_STATE_DIR}/tmp"
chmod 700 "${OCSB_STATE_DIR}/tmp"
find "${OCSB_STATE_DIR}/tmp" -mindepth 1 -mtime +7 -delete 2>/dev/null || true
OCSB_BWRAP+=(--bind "${OCSB_STATE_DIR}/tmp" /tmp)
fi
OCSB_BWRAP+=(
--bind "${OCSB_PROJECT_DIR}" "${OCSB_PROJECT_DIR}"
--bind "${OCSB_WORKTREES_DIR}" "${OCSB_WORKTREES_DIR}"
--bind "${OCSB_STATE_DIR}" "${OCSB_STATE_DIR}"
)
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
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
if [[ ${OCSB_DISPLAY} == 1 && -n ${WAYLAND_DISPLAY:-} && -S ${XDG_RUNTIME_DIR:-/run/user/${uid}}/${WAYLAND_DISPLAY} ]]; then
OCSB_BWRAP+=(--ro-bind "${XDG_RUNTIME_DIR:-/run/user/${uid}}/${WAYLAND_DISPLAY}" "/run/user/${uid}/${WAYLAND_DISPLAY}")
# GPU for rendering; without it browsers fall back to software
[[ -d /dev/dri ]] && OCSB_BWRAP+=(--dev-bind /dev/dri /dev/dri)
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")
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.
# Denies are repeated after "*" so they still win.
# Display: by default none (GUI programs fail fast, browsers run headless). With
# OCSB_DISPLAY=1 only the Wayland socket: unlike X11, a Wayland client cannot read other
# windows or inject input into them. X11 (and the X cookie) stays out either way.
env+=("DISPLAY=" "XAUTHORITY=")
if [[ ${OCSB_DISPLAY} == 1 && -n ${WAYLAND_DISPLAY:-} && -S ${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/${WAYLAND_DISPLAY} ]]; then
env+=("WAYLAND_DISPLAY=${WAYLAND_DISPLAY}" "XDG_RUNTIME_DIR=/run/user/$(id -u)")
else
[[ ${OCSB_DISPLAY} == 1 ]] && ocsb_warn "OCSB_DISPLAY=1 but no Wayland session — no windows in the sandbox"
env+=("WAYLAND_DISPLAY=")
fi
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
[[ ${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
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
}