Files
opencode-sandbox/lib/netlock-root.sh
T
k.denisov 64c222a084 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
2026-10-30 01:00:02 -07:00

90 lines
3.1 KiB
Bash
Executable File

#!/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