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:
2026-10-30 01:00:02 -07:00
commit 64c222a084
29 changed files with 1945 additions and 0 deletions
+223
View File
@@ -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
}