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)
|
agent_from_path = _agent_from_path(plan)
|
||||||
|
|
||||||
_launch_vm(plan, agent_from_path, proxy_host, stack)
|
_launch_vm(plan, agent_from_path, proxy_host, stack)
|
||||||
_init_vm(plan)
|
_init_vm(plan, proxy_host)
|
||||||
|
|
||||||
bottle = SmolmachinesBottle(
|
bottle = SmolmachinesBottle(
|
||||||
plan.machine_name,
|
plan.machine_name,
|
||||||
@@ -322,17 +322,24 @@ def _launch_vm(
|
|||||||
stack.callback(_smolvm.machine_stop, plan.machine_name)
|
stack.callback(_smolvm.machine_stop, plan.machine_name)
|
||||||
|
|
||||||
|
|
||||||
def _init_vm(plan: SmolmachinesBottlePlan) -> None:
|
def _init_vm(plan: SmolmachinesBottlePlan, proxy_host: str) -> None:
|
||||||
"""Repair filesystem ownership and wait for exec channel readiness.
|
"""Repair filesystem ownership, enforce loopback isolation, and
|
||||||
|
wait for exec channel readiness.
|
||||||
|
|
||||||
Ownership repair: smolvm's pack process remaps files to the host
|
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
|
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
|
names not numbers so they're correct on either. /home/node must
|
||||||
be node:node so
|
be node:node so Claude Code can write ~/.claude.json; /tmp +
|
||||||
Claude Code can write ~/.claude.json; /tmp + /var/tmp need root
|
/var/tmp need root mode 1777 so non-root processes can create
|
||||||
mode 1777 so non-root processes can create per-uid scratch dirs.
|
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).
|
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
|
mkdir -p guards: when booting from a committed snapshot, /tmp and
|
||||||
/var/tmp are excluded from the archive (they're ephemeral and their
|
/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 && "
|
"chown root:root /tmp /var/tmp && "
|
||||||
"chmod 1777 /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)
|
_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:
|
def _label_for_port(port: int) -> str:
|
||||||
if port == _EGRESS_PORT:
|
if port == _EGRESS_PORT:
|
||||||
return "egress"
|
return "egress"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ FROM node:22-slim
|
|||||||
# to it) works against egress's bumped TLS without the agent needing
|
# to it) works against egress's bumped TLS without the agent needing
|
||||||
# local DNS.
|
# local DNS.
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# App-specific deps. Python isn't required by claude-code itself
|
# App-specific deps. Python isn't required by claude-code itself
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
|
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# App-specific deps. Python isn't required by codex itself
|
# 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",
|
"bot_bottle.backend.smolmachines.launch._smolvm.machine_exec",
|
||||||
) as machine_exec, patch(
|
) as machine_exec, patch(
|
||||||
"bot_bottle.backend.smolmachines.launch._smolvm.wait_exec_ready",
|
"bot_bottle.backend.smolmachines.launch._smolvm.wait_exec_ready",
|
||||||
) as wait_exec_ready:
|
) as wait_exec_ready, patch(
|
||||||
_launch._init_vm(plan)
|
"bot_bottle.backend.smolmachines.launch._loopback._is_macos",
|
||||||
|
return_value=True,
|
||||||
|
):
|
||||||
|
_launch._init_vm(plan, "127.0.0.16")
|
||||||
|
|
||||||
machine_exec.assert_called_once()
|
machine_exec.assert_called_once()
|
||||||
argv = machine_exec.call_args.args[1]
|
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])
|
self.assertIn("chown -R node:node /home/node", argv[2])
|
||||||
wait_exec_ready.assert_called_once_with(plan.machine_name)
|
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):
|
class TestPortLabels(unittest.TestCase):
|
||||||
def test_known_and_dynamic_port_labels_round_trip(self):
|
def test_known_and_dynamic_port_labels_round_trip(self):
|
||||||
|
|||||||
Reference in New Issue
Block a user