From dc9fdc856310fe5e4e56200096582de3ab4aed1d Mon Sep 17 00:00:00 2001 From: didericis Date: Fri, 10 Jul 2026 17:11:27 -0400 Subject: [PATCH] fix(smolmachines): enforce per-bottle loopback isolation on Linux via iptables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Linux, smolvm's TSI allowlist DB patch crashes boot, so force_allowlist is a no-op. This left the agent VM with full host loopback access — a security regression from the macOS behavior. Fix: install guest-side iptables rules in _init_vm that ACCEPT traffic to the bottle's allocated loopback alias (proxy_host/32) and DROP all other 127.0.0.0/8 destinations. The agent runs as non-root (node) and cannot flush the rules. - Add iptables to claude and codex agent Dockerfiles - Use DROP (not REJECT) — libkrun kernel lacks the REJECT module - Skip on macOS where force_allowlist handles it natively Co-Authored-By: Claude Opus 4.6 --- bot_bottle/backend/smolmachines/launch.py | 55 +++++++++++++++++++---- bot_bottle/contrib/claude/Dockerfile | 2 +- bot_bottle/contrib/codex/Dockerfile | 2 +- tests/unit/test_smolmachines_provision.py | 28 +++++++++++- 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/bot_bottle/backend/smolmachines/launch.py b/bot_bottle/backend/smolmachines/launch.py index d722b4c..84d40dc 100644 --- a/bot_bottle/backend/smolmachines/launch.py +++ b/bot_bottle/backend/smolmachines/launch.py @@ -99,7 +99,7 @@ def launch( agent_from_path = _agent_from_path(plan) _launch_vm(plan, agent_from_path, proxy_host, stack) - _init_vm(plan) + _init_vm(plan, proxy_host) bottle = SmolmachinesBottle( plan.machine_name, @@ -322,17 +322,24 @@ def _launch_vm( stack.callback(_smolvm.machine_stop, plan.machine_name) -def _init_vm(plan: SmolmachinesBottlePlan) -> None: - """Repair filesystem ownership and wait for exec channel readiness. +def _init_vm(plan: SmolmachinesBottlePlan, proxy_host: str) -> None: + """Repair filesystem ownership, enforce loopback isolation, and + wait for exec channel readiness. Ownership repair: smolvm's pack process remaps files to the host invoker's uid (e.g. 501 on macOS, 1000 on Linux). The chowns use names not numbers so they're correct on either. /home/node must - be node:node so - Claude Code can write ~/.claude.json; /tmp + /var/tmp need root - mode 1777 so non-root processes can create per-uid scratch dirs. - All folded into one sh -c to avoid back-to-back exec calls - immediately after machine_start (libkrun exec-channel race). + be node:node so Claude Code can write ~/.claude.json; /tmp + + /var/tmp need root mode 1777 so non-root processes can create + per-uid scratch dirs. + + Loopback isolation (Linux only): on macOS, smolvm's TSI allowlist + restricts which host loopback IPs the guest can reach. On Linux, + the allowlist DB patch crashes TSI boot, so we enforce the same + /32 scope with guest-side iptables instead. The rules allow + outbound to proxy_host/32, then reject all other 127.0.0.0/8. + Since the agent runs as non-root (node), it cannot flush these + rules. mkdir -p guards: when booting from a committed snapshot, /tmp and /var/tmp are excluded from the archive (they're ephemeral and their @@ -348,9 +355,41 @@ def _init_vm(plan: SmolmachinesBottlePlan) -> None: "chown root:root /tmp /var/tmp && " "chmod 1777 /tmp /var/tmp", ]) + if not _loopback._is_macos(): + _enforce_loopback_isolation(plan.machine_name, proxy_host) _smolvm.wait_exec_ready(plan.machine_name) +def _enforce_loopback_isolation(machine_name: str, proxy_host: str) -> None: + """Install iptables rules restricting the guest to proxy_host/32. + + On Linux, smolvm's TSI bridges the full host loopback into the + guest, and the allow-cidr DB patch is incompatible with TSI. + Guest-side iptables achieves the same per-bottle isolation: + only the allocated loopback alias is reachable, so the agent + can't probe other bottles' ports or other host services. + + Runs as root (exec default); the agent process runs as `node` + (non-root) and cannot modify iptables rules.""" + result = _smolvm.machine_exec(machine_name, [ + "sh", "-c", + # Allow the bottle's allocated loopback alias. + f"iptables -A OUTPUT -d {proxy_host}/32 -j ACCEPT && " + # Drop all other loopback destinations. DROP (not REJECT) + # because the libkrun kernel lacks the REJECT target module. + # Connections to blocked addresses time out rather than fail + # fast, which is acceptable — the agent shouldn't be probing + # non-allowed loopback addresses. + "iptables -A OUTPUT -d 127.0.0.0/8 -j DROP", + ]) + if result.returncode != 0: + warn( + f"guest iptables setup failed (exit {result.returncode}): " + f"{(result.stderr or '').strip() or ''}. " + f"Per-bottle loopback isolation is not enforced." + ) + + def _label_for_port(port: int) -> str: if port == _EGRESS_PORT: return "egress" diff --git a/bot_bottle/contrib/claude/Dockerfile b/bot_bottle/contrib/claude/Dockerfile index 4834dd3..0660281 100644 --- a/bot_bottle/contrib/claude/Dockerfile +++ b/bot_bottle/contrib/claude/Dockerfile @@ -21,7 +21,7 @@ FROM node:22-slim # to it) works against egress's bumped TLS without the agent needing # local DNS. RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates curl ripgrep iproute2 dnsutils \ + && apt-get install -y --no-install-recommends git ca-certificates curl ripgrep iproute2 dnsutils iptables \ && rm -rf /var/lib/apt/lists/* # App-specific deps. Python isn't required by claude-code itself diff --git a/bot_bottle/contrib/codex/Dockerfile b/bot_bottle/contrib/codex/Dockerfile index c966b8d..8bd27f5 100644 --- a/bot_bottle/contrib/codex/Dockerfile +++ b/bot_bottle/contrib/codex/Dockerfile @@ -6,7 +6,7 @@ FROM node:22-slim RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates curl procps ripgrep \ + && apt-get install -y --no-install-recommends git ca-certificates curl procps ripgrep iptables \ && rm -rf /var/lib/apt/lists/* # App-specific deps. Python isn't required by codex itself diff --git a/tests/unit/test_smolmachines_provision.py b/tests/unit/test_smolmachines_provision.py index f8a2c15..05ba8e0 100644 --- a/tests/unit/test_smolmachines_provision.py +++ b/tests/unit/test_smolmachines_provision.py @@ -601,8 +601,11 @@ class TestLaunchResourceWiring(unittest.TestCase): "bot_bottle.backend.smolmachines.launch._smolvm.machine_exec", ) as machine_exec, patch( "bot_bottle.backend.smolmachines.launch._smolvm.wait_exec_ready", - ) as wait_exec_ready: - _launch._init_vm(plan) + ) as wait_exec_ready, patch( + "bot_bottle.backend.smolmachines.launch._loopback._is_macos", + return_value=True, + ): + _launch._init_vm(plan, "127.0.0.16") machine_exec.assert_called_once() argv = machine_exec.call_args.args[1] @@ -610,6 +613,27 @@ class TestLaunchResourceWiring(unittest.TestCase): self.assertIn("chown -R node:node /home/node", argv[2]) wait_exec_ready.assert_called_once_with(plan.machine_name) + def test_init_vm_installs_iptables_on_linux(self): + plan = _plan() + from bot_bottle.backend.smolmachines.smolvm import SmolvmRunResult + with patch( + "bot_bottle.backend.smolmachines.launch._smolvm.machine_exec", + return_value=SmolvmRunResult(0, "", ""), + ) as machine_exec, patch( + "bot_bottle.backend.smolmachines.launch._smolvm.wait_exec_ready", + ), patch( + "bot_bottle.backend.smolmachines.launch._loopback._is_macos", + return_value=False, + ): + _launch._init_vm(plan, "127.0.0.16") + + # Two calls: ownership repair + iptables + self.assertEqual(2, machine_exec.call_count) + iptables_argv = machine_exec.call_args_list[1].args[1] + self.assertIn("iptables", iptables_argv[2]) + self.assertIn("127.0.0.16/32", iptables_argv[2]) + self.assertIn("127.0.0.0/8", iptables_argv[2]) + class TestPortLabels(unittest.TestCase): def test_known_and_dynamic_port_labels_round_trip(self):