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:
+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
|
||||
}
|
||||
Reference in New Issue
Block a user