# 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 }