diff --git a/Dockerfile b/Dockerfile index fc68657..e96eea6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,9 @@ RUN apt-get update && apt-get install -y \ ca-certificates gnupg lsb-release \ python3 python3-pip python3-venv \ maven \ + golang \ chromium-browser \ + gping bind9-dnsutils iproute2 iputils-ping \ xclip \ xsel \ && rm -rf /var/lib/apt/lists/* diff --git a/README.md b/README.md index e68d903..53c9daa 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,16 @@ sandbox-code [options] [-- command...] |---|---| | `-w`, `--workspace PATH` | Directory to mount as `/workspace` (default: current directory) | | `--bash` | Start an interactive bash shell instead of OpenCode | +| `--version` | Print OpenCode version and exit | | `--ssh` | Mount `~/.ssh` into the container (read-only) | | `--github` | Mount `~/.ssh` (read-only) and `~/.config/gh` (writable) | +| `--x11` | Mount X11 socket for clipboard support (enables copy/paste) | +| `--no-network` | Disable all networking (`--network none`) | +| `--network NAME` | Use a specific Docker network (default: bridge) | +| `--blacklist` | Isolate from local/Tailscale subnets, allow internet (`blacklist-networks.conf`) | +| `--whitelist` | Allow only listed CIDRs, block everything else (`whitelist-networks.conf`) | +| `--clean-rules` | Remove firewall rules created by `--blacklist` / `--whitelist` | +| `--no-git` | Hide `.git` directory (tmpfs over `/workspace/.git`) | | `--reset` | Delete all persistent data before starting | | `--no-cache` | Force a full Docker image rebuild without layer cache | @@ -83,6 +91,33 @@ To wipe everything and start fresh: sandbox-code --reset ``` +## Network isolation + +Use `--blacklist` or `--whitelist` to restrict outbound traffic from the container. +Both require `sudo` to apply iptables/nftables rules on the host. + +| Flag | Config file | Behaviour | +|---|---|---| +| `--blacklist` | `blacklist-networks.conf` | Block listed CIDRs, allow everything else | +| `--whitelist` | `whitelist-networks.conf` | Allow only listed CIDRs, block everything else | + +Config file format (first two lines = Docker network name + subnet, rest = CIDRs): + +``` +sandbox-code-blacklist +172.30.0.0/16 +# comments start with # +10.0.0.0/8 +192.168.0.0/16 +100.64.0.0/10 +``` + +Clean up firewall rules: + +```bash +sandbox-code --clean-rules +``` + ## Image contents | Tool | Version | diff --git a/blacklist-networks.conf b/blacklist-networks.conf new file mode 100644 index 0000000..d7f1953 --- /dev/null +++ b/blacklist-networks.conf @@ -0,0 +1,10 @@ +sandbox-code-caged +172.30.0.0/16 +# Blocked CIDRs — one per line +10.0.0.0/8 +172.16.0.0/12 +192.168.0.0/16 +# Tailscale / CGNAT +100.64.0.0/10 +# Link-local +169.254.0.0/16 \ No newline at end of file diff --git a/sandbox-code.py b/sandbox-code.py index 09a9422..53ceaaf 100755 --- a/sandbox-code.py +++ b/sandbox-code.py @@ -69,6 +69,37 @@ def main(): action="store_true", help="Mount X11 socket for clipboard support (enables copy/paste)", ) + parser.add_argument( + "--no-network", + action="store_true", + help="Disable all networking (--network none)", + ) + parser.add_argument( + "--network", + type=str, + default=None, + help="Docker network to use (default: bridge)", + ) + parser.add_argument( + "--blacklist", + action="store_true", + help="Isolate from local/Tailscale subnets, allow internet", + ) + parser.add_argument( + "--whitelist", + action="store_true", + help="Allow only listed CIDRs, block everything else (inverted blacklist)", + ) + parser.add_argument( + "--clean-rules", + action="store_true", + help="Remove firewall rules created by --blacklist / --whitelist", + ) + parser.add_argument( + "--no-git", + action="store_true", + help="Hide .git directory (tmpfs over /workspace/.git)", + ) parser.add_argument( "command", nargs=argparse.REMAINDER, @@ -77,6 +108,23 @@ def main(): args = parser.parse_args() + network_opts = [args.blacklist, args.whitelist, args.no_network, bool(args.network)] + if sum(network_opts) > 1: + pieces = [] + if args.blacklist: + pieces.append("--blacklist") + if args.whitelist: + pieces.append("--whitelist") + if args.no_network: + pieces.append("--no-network") + if args.network: + pieces.append("--network") + parser.error(f"{', '.join(pieces)} are mutually exclusive") + + if args.clean_rules: + _clean_all_rules(script_dir) + return + if args.version: subprocess.run( ["docker", "run", "--rm", "--entrypoint", "opencode", @@ -135,26 +183,22 @@ def main(): # X11 support for clipboard if args.x11: - # Pass DISPLAY environment variable if "DISPLAY" in os.environ: docker_cmd.extend(["-e", f'DISPLAY={os.environ["DISPLAY"]}']) docker_cmd.extend(["-v", "/tmp/.X11-unix:/tmp/.X11-unix"]) print("[INFO] X11 support enabled (DISPLAY={})".format(os.environ["DISPLAY"])) else: print("[WARNING] --x11 requested but DISPLAY not set in environment", file=sys.stderr) - - # Also try to detect and pass XAUTHORITY if available + if "XAUTHORITY" in os.environ: docker_cmd.extend(["-e", f'XAUTHORITY={os.environ["XAUTHORITY"]}']) docker_cmd.extend(["-v", f'{os.environ["XAUTHORITY"]}:{os.environ["XAUTHORITY"]}']) - - # Additional common Xauthority location + xauth_path = pathlib.Path.home() / ".Xauthority" if xauth_path.exists(): docker_cmd.extend(["-v", f"{xauth_path}:/home/ubuntu/.Xauthority:ro"]) docker_cmd.extend(["-e", "XAUTHORITY=/home/ubuntu/.Xauthority"]) - # Wayland support (if using Wayland with XWayland) if "WAYLAND_DISPLAY" in os.environ: runtime_dir = os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000") wayland_display = os.environ["WAYLAND_DISPLAY"] @@ -163,6 +207,33 @@ def main(): docker_cmd.extend(["-e", "XDG_RUNTIME_DIR=/tmp"]) print("[INFO] Wayland socket mounted for XWayland support") + # --- Network isolation --- + if args.blacklist or args.whitelist: + mode = "blacklist" if args.blacklist else "whitelist" + conf_path = os.path.join(script_dir, f"{mode}-networks.conf") + network_name, subnet, cidrs = _load_filter_config(conf_path) + + result = subprocess.run( + ["docker", "network", "inspect", network_name], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + if result.returncode != 0: + subprocess.run( + ["docker", "network", "create", "-d", "bridge", + "--subnet", subnet, network_name], + check=True, + ) + print(f"[INFO] Created Docker network '{network_name}'") + + _apply_filter_rules(mode, network_name, cidrs) + + docker_cmd.extend(["--network", network_name]) + docker_cmd.extend(["--dns", "1.1.1.1", "--dns", "8.8.8.8"]) + elif args.no_network: + docker_cmd.extend(["--network", "none"]) + elif args.network: + docker_cmd.extend(["--network", args.network]) + mounts = set() def add_mount(src, dst, readonly=True): @@ -187,6 +258,9 @@ def add_mount(src, dst, readonly=True): if var in os.environ: docker_cmd.extend(["-e", var]) + if args.no_git: + docker_cmd.extend(["--tmpfs", "/workspace/.git:ro,noexec,nosuid"]) + docker_cmd.append("sandbox-code:latest") if command: @@ -207,5 +281,234 @@ def add_mount(src, dst, readonly=True): sys.exit(0) +# --------------------------------------------------------------------------- +# helpers +# --------------------------------------------------------------------------- + +def _bridge_iface(network): + result = subprocess.run( + ["docker", "network", "inspect", network, + "--format", "{{index .Options \"com.docker.network.bridge.name\"}}"], + capture_output=True, text=True, check=True, + ) + iface = result.stdout.strip() + if iface: + return iface + result = subprocess.run( + ["docker", "network", "inspect", network, + "--format", "{{.Id}}"], + capture_output=True, text=True, check=True, + ) + return f"br-{result.stdout.strip()[:12]}" + + +def _nft_available(): + return shutil.which("nft") is not None + + +def _iptables_available(): + return shutil.which("iptables") is not None + + +def _load_filter_config(path): + if not os.path.isfile(path): + print(f"[ERROR] Config file not found: {path}", file=sys.stderr) + sys.exit(1) + lines = [] + with open(path) as f: + for raw in f: + line = raw.strip() + if not line or line.startswith("#"): + continue + lines.append(line) + if len(lines) < 3: + print(f"[ERROR] Config file too short ({len(lines)} lines, need at least 3)", file=sys.stderr) + sys.exit(1) + return lines[0], lines[1], lines[2:] + + +# -- rule helpers ------------------------------------------------------------ + +_TAG = "sandbox-code" +_IPTABLES_CHAINS = ("FORWARD", "INPUT") +_NFT_TABLE = f"inet {_TAG}" + + +def _rule_exists_iptables(chain, iface, cidr, action): + rc = subprocess.run( + ["sudo", "iptables", "-C", chain, + "-i", iface, "-d", cidr, "-j", action, + "-m", "comment", "--comment", _TAG], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ).returncode + return rc == 0 + + +def _rule_add_iptables(chain, iface, cidr, action): + return subprocess.run( + ["sudo", "iptables", "-I", chain, "1", + "-i", iface, "-d", cidr, "-j", action, + "-m", "comment", "--comment", _TAG], + ).returncode + + +def _rule_del_iptables(chain, iface, cidr, action): + return subprocess.run( + ["sudo", "iptables", "-D", chain, + "-i", iface, "-d", cidr, "-j", action, + "-m", "comment", "--comment", _TAG], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ).returncode + + +# -- blacklist --------------------------------------------------------------- + +def _add_blacklist_iptables(iface, cidrs): + ok = True + for chain in _IPTABLES_CHAINS: + for cidr in cidrs: + if not _rule_exists_iptables(chain, iface, cidr, "DROP"): + if _rule_add_iptables(chain, iface, cidr, "DROP") != 0: + ok = False + return ok + + +def _add_blacklist_nft(iface, cidrs): + subprocess.run(["sudo", "nft", "add", "table", _NFT_TABLE], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + ok = True + for hook in ("forward", "input"): + chain_name = f"{hook}_bl" + subprocess.run(["sudo", "nft", "add", "chain", _NFT_TABLE, chain_name, + f"{{ type filter hook {hook} priority 0; }}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for cidr in cidrs: + rc = subprocess.run( + ["sudo", "nft", "add", "rule", _NFT_TABLE, chain_name, + f"iifname {iface} ip daddr {cidr} drop"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ).returncode + if rc != 0: + ok = False + return ok + + +def _del_blacklist_iptables(iface, cidrs): + for chain in _IPTABLES_CHAINS: + for cidr in cidrs: + _rule_del_iptables(chain, iface, cidr, "DROP") + + +# -- whitelist --------------------------------------------------------------- + +def _add_whitelist_iptables(iface, cidrs): + ok = True + for chain in _IPTABLES_CHAINS: + for cidr in cidrs: + if not _rule_exists_iptables(chain, iface, cidr, "ACCEPT"): + if _rule_add_iptables(chain, iface, cidr, "ACCEPT") != 0: + ok = False + if not _rule_exists_iptables(chain, iface, "0.0.0.0/0", "DROP"): + rc = subprocess.run( + ["sudo", "iptables", "-A", chain, + "-i", iface, "-d", "0.0.0.0/0", "-j", "DROP", + "-m", "comment", "--comment", _TAG], + ).returncode + if rc != 0: + ok = False + return ok + + +def _add_whitelist_nft(iface, cidrs): + subprocess.run(["sudo", "nft", "add", "table", _NFT_TABLE], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + ok = True + for hook in ("forward", "input"): + chain_name = f"{hook}_wl" + subprocess.run(["sudo", "nft", "add", "chain", _NFT_TABLE, chain_name, + f"{{ type filter hook {hook} priority 0; policy drop; }}"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for cidr in cidrs: + rc = subprocess.run( + ["sudo", "nft", "add", "rule", _NFT_TABLE, chain_name, + f"iifname {iface} ip daddr {cidr} accept"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ).returncode + if rc != 0: + ok = False + return ok + + +def _del_whitelist_iptables(iface, cidrs): + for chain in _IPTABLES_CHAINS: + for cidr in cidrs: + _rule_del_iptables(chain, iface, cidr, "ACCEPT") + _rule_del_iptables(chain, iface, "0.0.0.0/0", "DROP") + + +# -- orchestrator ------------------------------------------------------------ + +def _apply_filter_rules(mode, network, cidrs): + try: + iface = _bridge_iface(network) + except subprocess.CalledProcessError: + print(f"[ERROR] Cannot inspect network '{network}'", file=sys.stderr) + return + + add_ipt, add_nft = ( + (_add_blacklist_iptables, _add_blacklist_nft) if mode == "blacklist" + else (_add_whitelist_iptables, _add_whitelist_nft) + ) + + if _nft_available(): + if add_nft(iface, cidrs): + print(f"[INFO] {mode} applied via nftables: {', '.join(cidrs)}") + return + if _iptables_available(): + print("[INFO] nftables failed, falling back to iptables") + if add_ipt(iface, cidrs): + print(f"[INFO] {mode} applied via iptables: {', '.join(cidrs)}") + return + + if _iptables_available(): + if add_ipt(iface, cidrs): + print(f"[INFO] {mode} applied via iptables: {', '.join(cidrs)}") + return + + print(f"[WARNING] Could not apply {mode} firewall rules (sudo failed).", file=sys.stderr) + + +# -- cleanup ----------------------------------------------------------------- + +def _clean_all_rules(script_dir): + cleaned = False + + for mode in ("blacklist", "whitelist"): + conf_path = os.path.join(script_dir, f"{mode}-networks.conf") + if not os.path.isfile(conf_path): + continue + network, _, cidrs = _load_filter_config(conf_path) + try: + iface = _bridge_iface(network) + except subprocess.CalledProcessError: + print(f"[INFO] Network '{network}' not found, skipping {mode} cleanup") + continue + + if _iptables_available(): + fn_del = _del_blacklist_iptables if mode == "blacklist" else _del_whitelist_iptables + fn_del(iface, cidrs) + cleaned = True + + if _nft_available(): + subprocess.run(["sudo", "nft", "delete", "table", _NFT_TABLE], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + cleaned = True + + if cleaned: + print("[INFO] Firewall rules removed") + else: + print("[WARNING] No firewall tool (iptables/nft) available, nothing cleaned", file=sys.stderr) + + if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/whitelist-networks.conf b/whitelist-networks.conf new file mode 100644 index 0000000..28d8c0e --- /dev/null +++ b/whitelist-networks.conf @@ -0,0 +1,5 @@ +sandbox-code-whitelist +172.31.0.0/16 +# Allowed CIDRs — only traffic to these destinations is permitted +# Add your API providers / trusted endpoints here +0.0.0.0/0 \ No newline at end of file