opencode-sandbox: OpenCode 2 in a sandbox for any project
Runs OpenCode 2 with full permissions while containing its mistakes: - egress filter: squid in a container per project; squid.conf is rendered from layered egress.conf files (package, user, project, local) with allow/block/connect rules and expect-pass/expect-block self-tests - netlock: nftables rejects direct egress for the opencode-sandbox group (installed once with sudo as a systemd unit) - bubblewrap: read-only host, writable project, worktrees and state; credentials hidden; ssh keys via the host's ssh-agent; session bus replaced by an xdg-dbus-proxy limited to notifications; no permission prompts (sudo and systemd-run denied); refuses to run unsandboxed - per-project state in ~/.local/share/opencode-sandbox/<name> - worktrees: `wt add|rm|list` with project hooks - skills mounted per project; the opencode-sandbox skill explains the environment to the agent - plugins as submodules: context-panel, skill-compact-resurrect; global config adds opencode-notifier and cc-safety-net - README in English and Russian
This commit is contained in:
+158
@@ -0,0 +1,158 @@
|
||||
# shellcheck shell=bash
|
||||
# Shared helpers: messages, project discovery, configuration, state paths.
|
||||
|
||||
ocsb_die() {
|
||||
echo "opencode-sandbox: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
ocsb_warn() { echo "⚠ $*" >&2; }
|
||||
ocsb_info() { echo "→ $*"; }
|
||||
|
||||
# --- configuration ------------------------------------------------------------------------
|
||||
# Layers, later wins:
|
||||
# 1. defaults below
|
||||
# 2. ~/.config/opencode-sandbox/sandbox.conf (user, all projects)
|
||||
# 3. <project>/.opencode-sandbox/sandbox.conf (committed)
|
||||
# 4. <project>/.opencode-sandbox/local/sandbox.conf (git-ignored, personal)
|
||||
# 5. OCSB_<SETTING> from the environment (scalars only, one run)
|
||||
# Config files are bash: scalars and arrays. OCSB_PROJECT_DIR is already set when they load.
|
||||
|
||||
OCSB_SCALARS=(
|
||||
OCSB_NAME OCSB_WEB_PORT OCSB_EGRESS_PORT OCSB_NETLOCK_GROUP OCSB_SANDBOX OCSB_PANEL
|
||||
OCSB_NOTIFICATIONS OCSB_OPENCODE_BIN OCSB_ENV_FILE OCSB_KUBECONFIG OCSB_PASSWORD_FILE
|
||||
OCSB_WORKTREES_DIR OCSB_WT_PREFIX OCSB_WT_BASE OCSB_WT_SLUG_REGEX OCSB_DOCKER_GW
|
||||
OCSB_STATE_DIR OCSB_OPENCODE_CONFIG OCSB_EGRESS_BASE
|
||||
)
|
||||
OCSB_ARRAYS=(OCSB_RW OCSB_RW_HOME OCSB_HIDE OCSB_HIDE_HOME OCSB_SKILLS OCSB_SKILL_DIRS OCSB_PLUGINS OCSB_ENV)
|
||||
|
||||
ocsb_defaults() {
|
||||
OCSB_NAME="" # default: basename of the project directory
|
||||
OCSB_WEB_PORT=4196 # browser port (context panel); OpenCode itself listens on +1
|
||||
OCSB_EGRESS_PORT=3128 # squid port on 127.0.0.1 and the docker0 gateway
|
||||
OCSB_NETLOCK_GROUP=opencode-sandbox
|
||||
OCSB_SANDBOX=1 # bubblewrap; 0 = egress filter and netlock only
|
||||
OCSB_PANEL=1 # "Tokens" panel in the web UI (plugins/context-panel)
|
||||
OCSB_NOTIFICATIONS=1 # desktop notifications through a filtered D-Bus proxy
|
||||
OCSB_OPENCODE_BIN="" # default: `opencode` from PATH
|
||||
OCSB_ENV_FILE="" # KEY=VALUE file with tokens, exported to OpenCode
|
||||
OCSB_KUBECONFIG="" # kubeconfig for the agent (~/.kube/config stays hidden)
|
||||
OCSB_PASSWORD_FILE="" # web UI password; default: generated in the state dir
|
||||
OCSB_WORKTREES_DIR="" # default: <parent of project>/<project>-worktrees
|
||||
OCSB_WT_PREFIX="" # worktree directory = <prefix><slug>
|
||||
OCSB_WT_BASE="" # branch new worktrees start from; default: current HEAD
|
||||
OCSB_WT_SLUG_REGEX="" # ERE with one group: slug from the branch name
|
||||
OCSB_DOCKER_GW="" # default: address of docker0
|
||||
OCSB_STATE_DIR="" # default: ~/.local/share/opencode-sandbox/<name>
|
||||
OCSB_OPENCODE_CONFIG="" # global OpenCode config; default: user's or the package's
|
||||
OCSB_RW=() # extra paths the agent may write (relative to the project)
|
||||
# Home directories that stay writable for toolchains and caches (only if they exist)
|
||||
OCSB_RW_HOME=(.cache .npm .bun .bundle .gem .asdf .local/share/mise .cargo .rustup go
|
||||
.m2 .gradle .docker/buildx .kube/cache)
|
||||
OCSB_HIDE=() # extra paths hidden from the agent (relative to the project)
|
||||
# Hidden home paths: credentials of other tools, browsers, other agents
|
||||
OCSB_HIDE_HOME=(.gnupg .aws .azure .config/gcloud .kube/config .docker/config.json .netrc
|
||||
.git-credentials .config/gh/hosts.yml .password-store .local/share/keyrings .mozilla
|
||||
.config/google-chrome .config/chromium .config/BraveSoftware .claude .codex
|
||||
.config/opencode .local/share/opencode)
|
||||
OCSB_SKILLS=(opencode-sandbox) # skills mounted for the agent (names of skill directories)
|
||||
OCSB_SKILL_DIRS=() # extra directories to look for skills in
|
||||
OCSB_PLUGINS=(skill-compact-resurrect/skill-compact-resurrect.v2.ts) # files in plugins/
|
||||
OCSB_ENV=() # extra KEY=VALUE for OpenCode
|
||||
}
|
||||
|
||||
ocsb_find_project() {
|
||||
local dir=${1:-$PWD}
|
||||
while [[ ${dir} != / ]]; do
|
||||
[[ -f ${dir}/.opencode-sandbox/sandbox.conf ]] && { echo "${dir}"; return 0; }
|
||||
dir=$(dirname "${dir}")
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ocsb_load_project [--optional]: find the project, load config layers, derive paths
|
||||
ocsb_load_project() {
|
||||
local optional=0 saved=() v f
|
||||
[[ ${1:-} == --optional ]] && optional=1
|
||||
for v in "${OCSB_SCALARS[@]}"; do
|
||||
[[ -n ${!v+x} ]] && saved+=("${v}=${!v}")
|
||||
done
|
||||
ocsb_defaults
|
||||
|
||||
OCSB_PROJECT_DIR=""
|
||||
if [[ -n ${OCSB_PROJECT:-} ]]; then
|
||||
OCSB_PROJECT_DIR=$(cd "${OCSB_PROJECT}" && pwd)
|
||||
else
|
||||
OCSB_PROJECT_DIR=$(ocsb_find_project) || true
|
||||
fi
|
||||
if [[ -z ${OCSB_PROJECT_DIR} || ! -f ${OCSB_PROJECT_DIR}/.opencode-sandbox/sandbox.conf ]]; then
|
||||
[[ ${optional} == 1 ]] || ocsb_die "no .opencode-sandbox/sandbox.conf here or above (run: opencode-sandbox init)"
|
||||
fi
|
||||
export OCSB_PROJECT_DIR
|
||||
|
||||
for f in "${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/sandbox.conf" \
|
||||
${OCSB_PROJECT_DIR:+"${OCSB_PROJECT_DIR}/.opencode-sandbox/sandbox.conf"} \
|
||||
${OCSB_PROJECT_DIR:+"${OCSB_PROJECT_DIR}/.opencode-sandbox/local/sandbox.conf"}; do
|
||||
# shellcheck disable=SC1090
|
||||
[[ -f ${f} ]] && source "${f}"
|
||||
done
|
||||
for v in "${saved[@]}"; do
|
||||
printf -v "${v%%=*}" '%s' "${v#*=}"
|
||||
done
|
||||
ocsb_derive
|
||||
}
|
||||
|
||||
ocsb_derive() {
|
||||
local base
|
||||
base=$(basename "${OCSB_PROJECT_DIR:-default}")
|
||||
OCSB_NAME=${OCSB_NAME:-${base}}
|
||||
OCSB_NAME=$(printf '%s' "${OCSB_NAME}" | tr -c 'a-zA-Z0-9_.-' '-' | tr '[:upper:]' '[:lower:]')
|
||||
OCSB_STATE_DIR=${OCSB_STATE_DIR:-${XDG_DATA_HOME:-${HOME}/.local/share}/opencode-sandbox/${OCSB_NAME}}
|
||||
if [[ -n ${OCSB_PROJECT_DIR} ]]; then
|
||||
OCSB_WORKTREES_DIR=${OCSB_WORKTREES_DIR:-$(dirname "${OCSB_PROJECT_DIR}")/${base}-worktrees}
|
||||
OCSB_WORKTREES_DIR=$(ocsb_abs "${OCSB_WORKTREES_DIR}")
|
||||
fi
|
||||
if [[ -z ${OCSB_DOCKER_GW} ]]; then
|
||||
OCSB_DOCKER_GW=$(ip -4 -o addr show docker0 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
|
||||
OCSB_DOCKER_GW=${OCSB_DOCKER_GW:-172.17.0.1}
|
||||
fi
|
||||
OCSB_CONTAINER="opencode-sandbox-egress-${OCSB_NAME}"
|
||||
OCSB_IMAGE="opencode-sandbox-egress"
|
||||
}
|
||||
|
||||
# Path relative to the project (or ~/..., or absolute) → absolute path
|
||||
ocsb_abs() {
|
||||
local p=$1
|
||||
# shellcheck disable=SC2088 # a literal "~" comes from config files
|
||||
case "${p}" in
|
||||
/*) ;;
|
||||
"~") p=${HOME} ;;
|
||||
"~/"*) p=${HOME}/${p#"~/"} ;;
|
||||
*) p=${OCSB_PROJECT_DIR}/${p} ;;
|
||||
esac
|
||||
realpath -m "${p}"
|
||||
}
|
||||
|
||||
ocsb_print_config() {
|
||||
local v
|
||||
echo "# project: ${OCSB_PROJECT_DIR:-none}"
|
||||
for v in "${OCSB_SCALARS[@]}"; do
|
||||
printf '%s=%q\n' "${v}" "${!v}"
|
||||
done
|
||||
for v in "${OCSB_ARRAYS[@]}"; do
|
||||
local -n arr=${v}
|
||||
printf '%s=(' "${v}"
|
||||
[[ ${#arr[@]} -gt 0 ]] && printf ' %q' "${arr[@]}"
|
||||
printf ' )\n'
|
||||
unset -n arr
|
||||
done
|
||||
}
|
||||
|
||||
# OpenCode binary: OCSB_OPENCODE_BIN (a path, relative to the project, or a command) or PATH
|
||||
ocsb_opencode_bin() {
|
||||
local b=${OCSB_OPENCODE_BIN:-opencode}
|
||||
if [[ ${b} == */* || ${b} == "~"* ]]; then
|
||||
ocsb_abs "${b}"
|
||||
else
|
||||
command -v "${b}" || true
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# shellcheck shell=bash
|
||||
# opencode-sandbox doctor: dependencies and host setup
|
||||
|
||||
ocsb_doctor() {
|
||||
local fail=0 c
|
||||
ocsb_load_project --optional
|
||||
echo "Required:"
|
||||
for c in docker bwrap sg curl jq ncat git; do
|
||||
if command -v "${c}" >/dev/null; then echo " ✓ ${c}"; else echo " ✗ ${c}"; fail=1; fi
|
||||
done
|
||||
local bin
|
||||
bin=$(ocsb_opencode_bin)
|
||||
if [[ -x ${bin} ]]; then
|
||||
echo " ✓ opencode: ${bin} ($("${bin}" --version 2>/dev/null | head -1))"
|
||||
elif [[ -z ${OCSB_PROJECT_DIR} ]]; then
|
||||
echo " - opencode: not in PATH (fine if projects set OCSB_OPENCODE_BIN; run doctor inside a project to check)"
|
||||
else
|
||||
echo " ✗ opencode: ${bin:-not in PATH} (set OCSB_OPENCODE_BIN in sandbox.conf or put opencode in PATH)"
|
||||
fail=1
|
||||
fi
|
||||
echo "Optional:"
|
||||
command -v xdg-dbus-proxy >/dev/null && echo " ✓ xdg-dbus-proxy (notifications)" || echo " - xdg-dbus-proxy: no desktop notifications in the sandbox"
|
||||
if ssh-add -l >/dev/null 2>&1; then echo " ✓ ssh-agent has keys"; else echo " - ssh-agent has no keys: git over SSH fails in the sandbox (ssh-add)"; fi
|
||||
[[ -d ${OCSB_HOME}/plugins/context-panel/.git || -f ${OCSB_HOME}/plugins/context-panel/.git ]] &&
|
||||
echo " ✓ plugins checked out" || echo " - plugins missing: git -C ${OCSB_HOME} submodule update --init"
|
||||
echo "Host:"
|
||||
if bwrap --ro-bind / / --dev /dev true 2>/dev/null; then echo " ✓ unprivileged bubblewrap works"
|
||||
else echo " ✗ bubblewrap cannot create a sandbox (user namespaces disabled?)"; fail=1; fi
|
||||
source "${OCSB_HOME}/lib/netlock.sh"
|
||||
ocsb_netlock status
|
||||
if [[ -n ${OCSB_PROJECT_DIR} ]]; then
|
||||
echo "Project: ${OCSB_PROJECT_DIR} (name ${OCSB_NAME}, state ${OCSB_STATE_DIR})"
|
||||
source "${OCSB_HOME}/lib/egress.sh"
|
||||
ocsb_egress status
|
||||
else
|
||||
echo "Project: none here (opencode-sandbox init)"
|
||||
fi
|
||||
return ${fail}
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
# shellcheck shell=bash
|
||||
# Egress filter: squid in a container on the host network, listening on loopback and the
|
||||
# docker0 gateway. squid.conf is rendered from egress.conf layers (later layers add rules):
|
||||
# <package>/egress/defaults.conf
|
||||
# ~/.config/opencode-sandbox/egress.conf
|
||||
# <project>/.opencode-sandbox/egress.conf (committed with the project)
|
||||
# <project>/.opencode-sandbox/local/egress.conf (git-ignored)
|
||||
#
|
||||
# egress.conf lines (# starts a comment):
|
||||
# allow <domain>... allowed even if a block rule matches (.domain = with subdomains)
|
||||
# allow-regex <regex>... same, by regular expression
|
||||
# block <domain>... denied
|
||||
# block-regex <regex>...
|
||||
# connect <host:port>... raw CONNECT tunnel to a non-443 port or an internal IP
|
||||
# (git over SSH, Kubernetes API); host may be an IP or CIDR
|
||||
# expect-block <url> [curl args] self-test: must be refused (egress verify)
|
||||
# expect-pass <url> [curl args] self-test: must go through
|
||||
# Built in, after all of the above: non-80/443 ports, names without a dot, private networks
|
||||
# are denied; everything else is allowed (blacklist policy).
|
||||
|
||||
ocsb_egress_layers() {
|
||||
local f
|
||||
for f in "${OCSB_HOME}/egress/defaults.conf" \
|
||||
"${XDG_CONFIG_HOME:-${HOME}/.config}/opencode-sandbox/egress.conf" \
|
||||
"${OCSB_PROJECT_DIR}/.opencode-sandbox/egress.conf" \
|
||||
"${OCSB_PROJECT_DIR}/.opencode-sandbox/local/egress.conf"; do
|
||||
[[ -f ${f} ]] && echo "${f}"
|
||||
done
|
||||
}
|
||||
|
||||
# Prints "directive<TAB>arg" for every argument of every rule line
|
||||
ocsb_egress_rules() {
|
||||
local f line directive rest a args
|
||||
while IFS= read -r f; do
|
||||
while IFS= read -r line || [[ -n ${line} ]]; do
|
||||
line=${line%%#*}
|
||||
read -r directive rest <<<"${line}"
|
||||
[[ -z ${directive} ]] && continue
|
||||
case "${directive}" in
|
||||
allow | allow-regex | block | block-regex | connect)
|
||||
read -ra args <<<"${rest}"
|
||||
for a in "${args[@]}"; do printf '%s\t%s\n' "${directive}" "${a}"; done
|
||||
;;
|
||||
expect-block | expect-pass)
|
||||
printf '%s\t%s\n' "${directive}" "${rest}"
|
||||
;;
|
||||
*) ocsb_die "${f}: unknown directive '${directive}'" ;;
|
||||
esac
|
||||
done <"${f}"
|
||||
done < <(ocsb_egress_layers)
|
||||
}
|
||||
|
||||
ocsb_is_ip() { [[ $1 =~ ^[0-9.]+(/[0-9]+)?$ || $1 =~ ^[0-9a-fA-F:]+:[0-9a-fA-F:]*(/[0-9]+)?$ ]]; }
|
||||
|
||||
ocsb_egress_render() {
|
||||
local out=${OCSB_STATE_DIR}/egress/squid.conf rules allow=() allow_re=() block=() block_re=()
|
||||
local connect=() d a i=0 host port
|
||||
mkdir -p "${OCSB_STATE_DIR}/egress"
|
||||
rules=$(ocsb_egress_rules)
|
||||
while IFS=$'\t' read -r d a; do
|
||||
case "${d}" in
|
||||
allow) allow+=("${a}") ;;
|
||||
allow-regex) allow_re+=("${a}") ;;
|
||||
block) block+=("${a}") ;;
|
||||
block-regex) block_re+=("${a}") ;;
|
||||
connect) connect+=("${a}") ;;
|
||||
esac
|
||||
done <<<"${rules}"
|
||||
|
||||
{
|
||||
echo "# Rendered by opencode-sandbox from egress.conf layers — do not edit, it is overwritten."
|
||||
echo "http_port 127.0.0.1:${OCSB_EGRESS_PORT}"
|
||||
echo "http_port ${OCSB_DOCKER_GW}:${OCSB_EGRESS_PORT}"
|
||||
echo "visible_hostname opencode-sandbox-egress"
|
||||
echo "pid_filename /tmp/squid.pid"
|
||||
echo "cache deny all"
|
||||
echo "forwarded_for off"
|
||||
echo "access_log /var/log/squid/access.log"
|
||||
echo "logfile_rotate 10"
|
||||
echo
|
||||
echo "acl CONNECT method CONNECT"
|
||||
echo "acl SSL_ports port 443"
|
||||
echo "acl Safe_ports port 80 443"
|
||||
for a in "${connect[@]}"; do
|
||||
host=${a%:*}
|
||||
port=${a##*:}
|
||||
[[ ${host} != "${a}" && ${port} =~ ^[0-9]+$ ]] || ocsb_die "connect ${a}: expected host:port"
|
||||
host=${host#[}
|
||||
host=${host%]}
|
||||
i=$((i + 1))
|
||||
if ocsb_is_ip "${host}"; then
|
||||
echo "acl connect_${i}_host dst ${host}"
|
||||
else
|
||||
echo "acl connect_${i}_host dstdomain ${host}"
|
||||
fi
|
||||
echo "acl connect_${i}_port port ${port}"
|
||||
done
|
||||
[[ ${#allow[@]} -gt 0 ]] && echo "acl allowed dstdomain ${allow[*]}"
|
||||
[[ ${#allow_re[@]} -gt 0 ]] && echo "acl allowed_re dstdom_regex -i ${allow_re[*]}"
|
||||
[[ ${#block[@]} -gt 0 ]] && echo "acl blocked dstdomain ${block[*]}"
|
||||
[[ ${#block_re[@]} -gt 0 ]] && echo "acl blocked_re dstdom_regex -i ${block_re[*]}"
|
||||
echo "acl unqualified dstdom_regex ^[^.]+\$"
|
||||
echo "acl private_dst dst 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 169.254.0.0/16 127.0.0.0/8 fc00::/7 fe80::/10 ::1"
|
||||
echo
|
||||
for ((i = 1; i <= ${#connect[@]}; i++)); do
|
||||
echo "http_access allow CONNECT connect_${i}_host connect_${i}_port"
|
||||
done
|
||||
echo "http_access deny !Safe_ports"
|
||||
echo "http_access deny CONNECT !SSL_ports"
|
||||
[[ ${#allow[@]} -gt 0 ]] && echo "http_access allow allowed"
|
||||
[[ ${#allow_re[@]} -gt 0 ]] && echo "http_access allow allowed_re"
|
||||
[[ ${#block[@]} -gt 0 ]] && echo "http_access deny blocked"
|
||||
[[ ${#block_re[@]} -gt 0 ]] && echo "http_access deny blocked_re"
|
||||
echo "http_access deny unqualified"
|
||||
echo "http_access deny private_dst"
|
||||
echo "http_access allow all"
|
||||
} >"${out}.tmp"
|
||||
# Replace via rename into the mounted directory: squid -k reconfigure then sees the new file
|
||||
mv "${out}.tmp" "${out}"
|
||||
}
|
||||
|
||||
ocsb_egress_src_hash() {
|
||||
cat "${OCSB_HOME}/egress/Dockerfile" "${OCSB_HOME}/egress/entrypoint.sh" | sha256sum | cut -c1-16
|
||||
}
|
||||
|
||||
ocsb_egress_running() {
|
||||
[[ $(docker inspect -f '{{.State.Running}}' "${OCSB_CONTAINER}" 2>/dev/null) == true ]]
|
||||
}
|
||||
|
||||
ocsb_egress_up() {
|
||||
local hash
|
||||
hash=$(ocsb_egress_src_hash)
|
||||
ocsb_egress_render
|
||||
if [[ $(docker image inspect -f '{{index .Config.Labels "opencode-sandbox.src-hash"}}' "${OCSB_IMAGE}" 2>/dev/null) != "${hash}" ]]; then
|
||||
ocsb_info "egress: building image ${OCSB_IMAGE}"
|
||||
docker build -q --label "opencode-sandbox.src-hash=${hash}" -t "${OCSB_IMAGE}" "${OCSB_HOME}/egress" >/dev/null
|
||||
fi
|
||||
if ocsb_egress_running &&
|
||||
[[ $(docker inspect -f '{{index .Config.Labels "opencode-sandbox.src-hash"}}' "${OCSB_CONTAINER}") == "${hash}" ]]; then
|
||||
ocsb_info "egress: ${OCSB_CONTAINER} running, reloading rules"
|
||||
docker exec "${OCSB_CONTAINER}" squid -k reconfigure -f /etc/opencode-sandbox/squid.conf
|
||||
sleep 1
|
||||
return
|
||||
fi
|
||||
docker rm -f "${OCSB_CONTAINER}" >/dev/null 2>&1 || true
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null; then
|
||||
ocsb_die "port ${OCSB_EGRESS_PORT} is busy (another project's filter?) — set OCSB_EGRESS_PORT in sandbox.conf"
|
||||
fi
|
||||
ocsb_info "egress: starting ${OCSB_CONTAINER} on 127.0.0.1:${OCSB_EGRESS_PORT}"
|
||||
docker run -d --name "${OCSB_CONTAINER}" --restart unless-stopped \
|
||||
--label "opencode-sandbox.src-hash=${hash}" \
|
||||
-v "${OCSB_STATE_DIR}/egress:/etc/opencode-sandbox:ro,z" \
|
||||
--network host "${OCSB_IMAGE}" >/dev/null
|
||||
local i
|
||||
for i in $(seq 30); do
|
||||
(exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null && return 0
|
||||
sleep 0.2
|
||||
done
|
||||
docker logs --tail 20 "${OCSB_CONTAINER}" >&2
|
||||
ocsb_die "egress: squid did not start"
|
||||
}
|
||||
|
||||
# One self-test: want = block|pass
|
||||
ocsb_egress_check() {
|
||||
local want=$1 url=$2 connect http got
|
||||
shift 2
|
||||
read -r connect http < <(curl -s -o /dev/null -w '%{http_connect} %{http_code}' \
|
||||
--max-time 15 -x "http://127.0.0.1:${OCSB_EGRESS_PORT}" "$@" "${url}" </dev/null)
|
||||
if [[ ${connect} == 403 || ${http} == 403 ]]; then got=block
|
||||
elif [[ ${connect} == 200 || ${http} != 000 ]]; then got=pass
|
||||
else got=error; fi
|
||||
if [[ ${got} == "${want}" ]]; then
|
||||
printf ' ✓ %-5s %s\n' "${want}" "${url}"
|
||||
else
|
||||
printf ' ✗ %-5s %s → %s (connect=%s http=%s)\n' "${want}" "${url}" "${got}" "${connect}" "${http}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
ocsb_egress_verify() {
|
||||
local fail=0 d a url args
|
||||
echo "Egress self-test (127.0.0.1:${OCSB_EGRESS_PORT}):"
|
||||
ocsb_egress_check block https://localhost-name-without-dot/ || fail=1
|
||||
ocsb_egress_check block https://10.255.255.1/ || fail=1
|
||||
ocsb_egress_check block telnet://example.com:22 -p || fail=1
|
||||
while IFS=$'\t' read -r d a; do
|
||||
[[ ${d} == expect-* ]] || continue
|
||||
read -r url args <<<"${a}"
|
||||
# shellcheck disable=SC2086 # args are curl options from egress.conf
|
||||
ocsb_egress_check "${d#expect-}" "${url}" ${args} || fail=1
|
||||
done < <(ocsb_egress_rules)
|
||||
return ${fail}
|
||||
}
|
||||
|
||||
ocsb_egress() {
|
||||
local sub=${1:-status}
|
||||
case "${sub}" in
|
||||
up) ocsb_egress_up ;;
|
||||
down)
|
||||
docker rm -f "${OCSB_CONTAINER}" >/dev/null 2>&1 || true
|
||||
echo "✓ ${OCSB_CONTAINER} removed"
|
||||
;;
|
||||
status)
|
||||
if ocsb_egress_running; then
|
||||
echo " container ${OCSB_CONTAINER}: running"
|
||||
else
|
||||
echo " container ${OCSB_CONTAINER}: not running (opencode-sandbox egress up)"
|
||||
fi
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/${OCSB_EGRESS_PORT}") 2>/dev/null; then
|
||||
echo " port 127.0.0.1:${OCSB_EGRESS_PORT}: open"
|
||||
else
|
||||
echo " port 127.0.0.1:${OCSB_EGRESS_PORT}: closed"
|
||||
fi
|
||||
;;
|
||||
logs) docker logs -f --tail 50 "${OCSB_CONTAINER}" ;;
|
||||
verify) ocsb_egress_verify ;;
|
||||
render)
|
||||
ocsb_egress_render
|
||||
cat "${OCSB_STATE_DIR}/egress/squid.conf"
|
||||
;;
|
||||
*) ocsb_die "egress: unknown subcommand ${sub} (up|down|status|logs|verify|render)" ;;
|
||||
esac
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# shellcheck shell=bash
|
||||
# opencode-sandbox init: add .opencode-sandbox/ to the current git repository
|
||||
|
||||
ocsb_init() {
|
||||
local root t=${OCSB_HOME}/templates/project f
|
||||
root=$(git rev-parse --show-toplevel 2>/dev/null) || ocsb_die "run init inside a git repository"
|
||||
if [[ -f ${root}/.opencode-sandbox/sandbox.conf ]]; then
|
||||
ocsb_die "${root}/.opencode-sandbox already exists"
|
||||
fi
|
||||
mkdir -p "${root}/.opencode-sandbox/hooks" "${root}/.opencode-sandbox/local"
|
||||
cp "${t}/sandbox.conf" "${t}/egress.conf" "${root}/.opencode-sandbox/"
|
||||
printf '/local/\n' >"${root}/.opencode-sandbox/.gitignore"
|
||||
for f in "${t}"/hooks/*.example; do
|
||||
cp "${f}" "${root}/.opencode-sandbox/hooks/"
|
||||
done
|
||||
if [[ ! -e ${root}/.cc-safety-net/policy.json ]]; then
|
||||
mkdir -p "${root}/.cc-safety-net"
|
||||
cp "${t}/cc-safety-net-policy.json" "${root}/.cc-safety-net/policy.json"
|
||||
fi
|
||||
cat <<EOF
|
||||
✓ created ${root}/.opencode-sandbox/
|
||||
sandbox.conf settings (ports, writable paths, skills, worktrees) — commit it
|
||||
egress.conf domains for this project — commit it
|
||||
hooks/ worktree hooks: drop .example and chmod +x to enable
|
||||
local/ personal overrides (sandbox.conf, egress.conf), git-ignored
|
||||
|
||||
Next:
|
||||
opencode-sandbox doctor check the host
|
||||
opencode-sandbox run start OpenCode in the sandbox
|
||||
EOF
|
||||
}
|
||||
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# Netlock, run as root (opencode-sandbox netlock setup|teardown calls it through sudo).
|
||||
# Processes whose primary group is GROUP may only talk to loopback (where the egress filter
|
||||
# listens) and to DNS servers from resolv.conf; everything else is rejected. The rules are
|
||||
# loaded by a systemd unit and survive reboots.
|
||||
# netlock-root.sh setup <group> <user>
|
||||
# netlock-root.sh teardown
|
||||
set -euo pipefail
|
||||
|
||||
TABLE=opencode_sandbox
|
||||
CONF_DIR=/etc/opencode-sandbox
|
||||
RULES="${CONF_DIR}/netlock.nft"
|
||||
UNIT=opencode-sandbox-netlock.service
|
||||
UNIT_PATH="/etc/systemd/system/${UNIT}"
|
||||
|
||||
[[ ${EUID} -eq 0 ]] || { echo "must run as root" >&2; exit 1; }
|
||||
|
||||
setup() {
|
||||
local group=$1 user=${2:-} gid ns dns_rules=""
|
||||
getent group "${group}" >/dev/null || groupadd "${group}"
|
||||
gid=$(getent group "${group}" | cut -d: -f3)
|
||||
if [[ -n ${user} ]] && ! id -nG "${user}" | tr ' ' '\n' | grep -qx "${group}"; then
|
||||
usermod -aG "${group}" "${user}"
|
||||
echo " ${user} added to group ${group} (log out and in, or use the new group via sg)"
|
||||
fi
|
||||
|
||||
# The loopback resolver (systemd-resolved) is covered by the loopback rule; non-local
|
||||
# nameservers are allowed one by one, not port 53 anywhere
|
||||
while read -r ns; do
|
||||
case "${ns}" in
|
||||
127.* | ::1) ;;
|
||||
*:*) dns_rules+=" meta skgid ${gid} ip6 daddr ${ns} meta l4proto { tcp, udp } th dport 53 accept"$'\n' ;;
|
||||
*) dns_rules+=" meta skgid ${gid} ip daddr ${ns} meta l4proto { tcp, udp } th dport 53 accept"$'\n' ;;
|
||||
esac
|
||||
done < <(awk '$1 == "nameserver" { print $2 }' /etc/resolv.conf)
|
||||
|
||||
mkdir -p "${CONF_DIR}"
|
||||
cat >"${RULES}" <<EOF
|
||||
# Generated by opencode-sandbox (lib/netlock-root.sh) — edit the script, not this file.
|
||||
table inet ${TABLE} {
|
||||
chain output {
|
||||
type filter hook output priority 0; policy accept;
|
||||
meta skgid ${gid} ip daddr 127.0.0.0/8 accept
|
||||
meta skgid ${gid} ip6 daddr ::1 accept
|
||||
${dns_rules} meta skgid ${gid} reject with icmpx admin-prohibited
|
||||
}
|
||||
}
|
||||
EOF
|
||||
chmod 644 "${RULES}"
|
||||
|
||||
cat >"${UNIT_PATH}" <<EOF
|
||||
[Unit]
|
||||
Description=opencode-sandbox netlock (nftables, group ${group})
|
||||
Wants=network-pre.target
|
||||
Before=network-pre.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStartPre=-/usr/sbin/nft delete table inet ${TABLE}
|
||||
ExecStart=/usr/sbin/nft -f ${RULES}
|
||||
ExecStop=/usr/sbin/nft delete table inet ${TABLE}
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable "${UNIT}" >/dev/null 2>&1
|
||||
systemctl restart "${UNIT}"
|
||||
echo "✓ netlock: group ${group} (gid ${gid}), unit ${UNIT} enabled"
|
||||
echo " allowed: 127.0.0.0/8, ::1${dns_rules:+, DNS servers from resolv.conf}"
|
||||
}
|
||||
|
||||
teardown() {
|
||||
if systemctl list-unit-files "${UNIT}" >/dev/null 2>&1; then
|
||||
systemctl disable --now "${UNIT}" >/dev/null 2>&1 || true
|
||||
rm -f "${UNIT_PATH}"
|
||||
systemctl daemon-reload
|
||||
fi
|
||||
nft list table inet "${TABLE}" >/dev/null 2>&1 && nft delete table inet "${TABLE}"
|
||||
rm -rf "${CONF_DIR}"
|
||||
echo "✓ netlock removed (unit ${UNIT}, table ${TABLE}); the group is kept"
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
setup) shift; setup "$@" ;;
|
||||
teardown) teardown ;;
|
||||
*) echo "usage: $0 setup <group> [user] | teardown" >&2; exit 1 ;;
|
||||
esac
|
||||
@@ -0,0 +1,75 @@
|
||||
# shellcheck shell=bash
|
||||
# Netlock: direct egress (ping, curl by IP, ssh, anything not via the egress filter) is
|
||||
# rejected for processes running with the netlock group as their primary group. OpenCode
|
||||
# is started through `sg <group>`, so the agent and everything it spawns are covered.
|
||||
|
||||
OCSB_NETLOCK_UNIT=opencode-sandbox-netlock.service
|
||||
OCSB_NETLOCK_PROBE=${OCSB_NETLOCK_PROBE:-1.1.1.1/443}
|
||||
|
||||
ocsb_netlock_active() { systemctl is-active --quiet "${OCSB_NETLOCK_UNIT}"; }
|
||||
|
||||
ocsb_netlock_member() {
|
||||
local members
|
||||
getent group "${OCSB_NETLOCK_GROUP}" >/dev/null || return 1
|
||||
members=$(getent group "${OCSB_NETLOCK_GROUP}" | cut -d: -f4)
|
||||
[[ ",${members}," == *",${USER},"* ]]
|
||||
}
|
||||
|
||||
ocsb_netlock_setup() {
|
||||
sudo "${OCSB_HOME}/lib/netlock-root.sh" setup "${OCSB_NETLOCK_GROUP}" "${USER}"
|
||||
}
|
||||
|
||||
# Fails unless direct TCP from the group is rejected (not merely timing out)
|
||||
ocsb_netlock_check() {
|
||||
local rc=0
|
||||
ocsb_netlock_member || ocsb_die "${USER} is not in group ${OCSB_NETLOCK_GROUP} (opencode-sandbox netlock setup)"
|
||||
sg "${OCSB_NETLOCK_GROUP}" -c "timeout 10 bash -c 'exec 3<>/dev/tcp/${OCSB_NETLOCK_PROBE}'" 2>/dev/null || rc=$?
|
||||
case ${rc} in
|
||||
0) ocsb_die "netlock is NOT working: direct TCP to ${OCSB_NETLOCK_PROBE/\//:} from ${OCSB_NETLOCK_GROUP} succeeded" ;;
|
||||
124) ocsb_die "netlock check inconclusive: direct TCP to ${OCSB_NETLOCK_PROBE/\//:} hung instead of being rejected" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ocsb_netlock_verify() {
|
||||
local code
|
||||
echo "Netlock self-test (group ${OCSB_NETLOCK_GROUP}):"
|
||||
if sg "${OCSB_NETLOCK_GROUP}" -c 'curl --noproxy "*" -s -o /dev/null --max-time 5 https://example.com/' 2>/dev/null; then
|
||||
echo " ✗ direct HTTPS bypassing the filter went through"; return 1
|
||||
fi
|
||||
echo " ✓ direct HTTPS bypassing the filter is blocked"
|
||||
if sg "${OCSB_NETLOCK_GROUP}" -c 'ping -c1 -W1 1.1.1.1' >/dev/null 2>&1; then
|
||||
echo " ✗ ping went through"; return 1
|
||||
fi
|
||||
echo " ✓ ping is blocked"
|
||||
if [[ -n ${OCSB_PROJECT_DIR} ]]; then
|
||||
# What is checked is that the group reaches the proxy and the proxy opens the tunnel
|
||||
# (CONNECT 200), not that a particular remote site answers in time
|
||||
code=$(sg "${OCSB_NETLOCK_GROUP}" -c "curl -sx http://127.0.0.1:${OCSB_EGRESS_PORT} -o /dev/null -w '%{http_connect} %{http_code}' --max-time 20 https://example.com/" || true)
|
||||
case "${code}" in
|
||||
200\ * | *\ [23]??) echo " ✓ HTTPS through the filter → tunnel open (connect/http: ${code})" ;;
|
||||
*) echo " ✗ HTTPS through the filter → connect/http: ${code:-none} (is the egress filter up?)"; return 1 ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
ocsb_netlock() {
|
||||
local sub=${1:-status}
|
||||
case "${sub}" in
|
||||
setup) ocsb_netlock_setup ;;
|
||||
teardown) sudo "${OCSB_HOME}/lib/netlock-root.sh" teardown ;;
|
||||
status)
|
||||
if getent group "${OCSB_NETLOCK_GROUP}" >/dev/null; then
|
||||
echo " group ${OCSB_NETLOCK_GROUP}: exists$(ocsb_netlock_member && echo ", ${USER} is a member")"
|
||||
else
|
||||
echo " group ${OCSB_NETLOCK_GROUP}: missing (opencode-sandbox netlock setup)"
|
||||
fi
|
||||
if systemctl is-enabled --quiet "${OCSB_NETLOCK_UNIT}" 2>/dev/null; then
|
||||
echo " unit ${OCSB_NETLOCK_UNIT}: $(systemctl is-active "${OCSB_NETLOCK_UNIT}")"
|
||||
else
|
||||
echo " unit ${OCSB_NETLOCK_UNIT}: not installed (opencode-sandbox netlock setup)"
|
||||
fi
|
||||
;;
|
||||
verify) ocsb_netlock_verify ;;
|
||||
*) ocsb_die "netlock: unknown subcommand ${sub} (setup|teardown|status|verify)" ;;
|
||||
esac
|
||||
}
|
||||
+288
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
# shellcheck shell=bash
|
||||
# Worktrees for parallel agent sessions. Generic part here; project specifics (ports, .env,
|
||||
# starting a stack, seeding) go into hooks:
|
||||
# .opencode-sandbox/hooks/wt-post-add after `git worktree add`, cwd = the new worktree
|
||||
# .opencode-sandbox/hooks/wt-pre-rm before removal (stop the stack), cwd = the worktree
|
||||
# Hooks get WT_DIR, WT_BRANCH, WT_SLUG, MAIN_ROOT in the environment and the extra
|
||||
# arguments of `wt add` / `wt rm` as their arguments. A failing hook aborts the command.
|
||||
|
||||
ocsb_wt_main_root() {
|
||||
git -C "${OCSB_PROJECT_DIR}" rev-parse --path-format=absolute --git-common-dir | sed 's|/\.git$||'
|
||||
}
|
||||
|
||||
ocsb_wt_slug() {
|
||||
local branch=$1 slug=""
|
||||
if [[ -n ${OCSB_WT_SLUG_REGEX} && ${branch} =~ ${OCSB_WT_SLUG_REGEX} ]]; then
|
||||
slug=${BASH_REMATCH[1]}
|
||||
fi
|
||||
[[ -n ${slug} ]] || slug=$(printf '%s' "${branch}" | tr '/' '-' | tr -c 'a-zA-Z0-9._-' '-')
|
||||
echo "${slug}"
|
||||
}
|
||||
|
||||
ocsb_wt_hook() {
|
||||
local name=$1 dir=$2
|
||||
shift 2
|
||||
local hook=${OCSB_PROJECT_DIR}/.opencode-sandbox/hooks/${name}
|
||||
[[ -x ${hook} ]] || return 0
|
||||
ocsb_info "hook ${name}"
|
||||
(cd "${dir}" && "${hook}" "$@")
|
||||
}
|
||||
|
||||
ocsb_wt_add() {
|
||||
[[ $# -ge 1 ]] || ocsb_die "usage: opencode-sandbox wt add <branch> [hook args]"
|
||||
local branch=$1 main slug dir base
|
||||
shift
|
||||
git check-ref-format --branch "${branch}" >/dev/null 2>&1 || ocsb_die "invalid branch name: ${branch}"
|
||||
main=$(ocsb_wt_main_root)
|
||||
slug=$(ocsb_wt_slug "${branch}")
|
||||
dir=${OCSB_WORKTREES_DIR}/${OCSB_WT_PREFIX}${slug}
|
||||
[[ -e ${dir} ]] && ocsb_die "already exists: ${dir}"
|
||||
base=${OCSB_WT_BASE:-HEAD}
|
||||
if [[ ${base} != HEAD ]] && git -C "${main}" rev-parse -q --verify "origin/${base}" >/dev/null; then
|
||||
echo " ${base} is $(git -C "${main}" rev-list --count "${base}..origin/${base}") commit(s) behind origin/${base} (git fetch if needed)"
|
||||
fi
|
||||
mkdir -p "${OCSB_WORKTREES_DIR}"
|
||||
git -C "${main}" worktree add -b "${branch}" "${dir}" "${base}"
|
||||
export WT_DIR=${dir} WT_BRANCH=${branch} WT_SLUG=${slug} MAIN_ROOT=${main}
|
||||
ocsb_wt_hook wt-post-add "${dir}" "$@"
|
||||
echo "✓ worktree ${dir} (branch ${branch}); remove: opencode-sandbox wt rm ${slug}"
|
||||
}
|
||||
|
||||
ocsb_wt_rm() {
|
||||
[[ $# -ge 1 ]] || ocsb_die "usage: opencode-sandbox wt rm <name|path> [--force]"
|
||||
local name=$1 force=0 main dir branch status a extra=()
|
||||
shift
|
||||
for a in "$@"; do
|
||||
if [[ ${a} == --force ]]; then force=1; else extra+=("${a}"); fi
|
||||
done
|
||||
main=$(ocsb_wt_main_root)
|
||||
case "${name}" in
|
||||
/*) dir=${name} ;;
|
||||
*) dir=${OCSB_WORKTREES_DIR}/${OCSB_WT_PREFIX}${name} ;;
|
||||
esac
|
||||
[[ -d ${dir} ]] || ocsb_die "no such worktree: ${dir}"
|
||||
[[ $(realpath "${dir}") != "$(realpath "${main}")" ]] || ocsb_die "refusing to remove the main worktree"
|
||||
branch=$(git -C "${dir}" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
|
||||
export WT_DIR=${dir} WT_BRANCH=${branch} WT_SLUG=${name##*/} MAIN_ROOT=${main}
|
||||
ocsb_wt_hook wt-pre-rm "${dir}" "${extra[@]}"
|
||||
status=$(git -C "${dir}" status --porcelain)
|
||||
if [[ -n ${status} && ${force} -ne 1 ]]; then
|
||||
echo "${status}" >&2
|
||||
ocsb_die "worktree has uncommitted changes — commit them or pass --force (they will be lost)"
|
||||
fi
|
||||
if [[ ${force} -eq 1 ]]; then
|
||||
git -C "${main}" worktree remove --force "${dir}"
|
||||
else
|
||||
git -C "${main}" worktree remove "${dir}"
|
||||
fi
|
||||
git -C "${main}" worktree prune
|
||||
echo "✓ removed ${dir} (branch ${branch:-?} kept)"
|
||||
}
|
||||
|
||||
ocsb_wt() {
|
||||
local sub=${1:-list}
|
||||
[[ $# -gt 0 ]] && shift
|
||||
case "${sub}" in
|
||||
add) ocsb_wt_add "$@" ;;
|
||||
rm) ocsb_wt_rm "$@" ;;
|
||||
list) git -C "${OCSB_PROJECT_DIR}" worktree list ;;
|
||||
*) ocsb_die "wt: unknown subcommand ${sub} (add|rm|list)" ;;
|
||||
esac
|
||||
}
|
||||
Reference in New Issue
Block a user