fix(smolmachines): enforce per-bottle loopback isolation on Linux via iptables
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 '<no stderr>'}. "
|
||||
f"Per-bottle loopback isolation is not enforced."
|
||||
)
|
||||
|
||||
|
||||
def _label_for_port(port: int) -> str:
|
||||
if port == _EGRESS_PORT:
|
||||
return "egress"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user