Compare commits

...

2 Commits

Author SHA1 Message Date
didericis dc9fdc8563 fix(smolmachines): enforce per-bottle loopback isolation on Linux via iptables
lint / lint (push) Successful in 2m7s
test / unit (pull_request) Failing after 55s
test / integration (pull_request) Successful in 25s
test / coverage (pull_request) Failing after 1m2s
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>
2026-07-10 17:11:27 -04:00
didericis d76ce89c87 fix(smolmachines): Linux compat for TSI networking and git-gate paths
lint / lint (push) Successful in 2m8s
test / unit (pull_request) Failing after 58s
test / integration (pull_request) Successful in 23s
test / coverage (pull_request) Failing after 1m1s
Three fixes for Linux smolvm support:

1. Skip force_allowlist DB patch on Linux — patching allowed_cidrs
   into smolvm's state DB crashes TSI boot on Linux. Per-bottle CIDR
   isolation is not enforced on Linux until smolvm fixes the
   --allow-cidr + TSI interaction (known limitation).

2. Patch /etc/git-gate/ references in the staged entrypoint to
   /bot-bottle-data/git-gate/ so git-daemon finds its hooks and
   access-hook at the new consolidated mount path.

3. Drop explicit --net-backend tsi — the default on this smolvm
   build is already TSI; explicitly requesting it is incompatible
   with -p port mappings on Linux.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-10 17:01:23 -04:00
6 changed files with 105 additions and 36 deletions
+53 -16
View File
@@ -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"
@@ -406,15 +445,13 @@ def _stage_sidecar_data(plan: SmolmachinesBottlePlan) -> Path:
for name in ("entrypoint.sh", "pre-receive", "access-hook"): for name in ("entrypoint.sh", "pre-receive", "access-hook"):
(scripts_dir / name).chmod(0o755) (scripts_dir / name).chmod(0o755)
# Patch credential paths: the rendered entrypoint hardcodes # Patch paths: the rendered entrypoint hardcodes /git-gate/creds/
# /git-gate/creds/; rewrite to the in-VM git-gate subdir. # and /etc/git-gate/ for hooks; rewrite both to the in-VM subdir.
ep_path = scripts_dir / "entrypoint.sh" ep_path = scripts_dir / "entrypoint.sh"
ep_path.write_text( text = ep_path.read_text()
ep_path.read_text().replace( text = text.replace("/git-gate/creds/", f"{_GIT_GATE_SCRIPTS_DIR_IN_VM}/creds/")
"/git-gate/creds/", text = text.replace("/etc/git-gate/", f"{_GIT_GATE_SCRIPTS_DIR_IN_VM}/")
f"{_GIT_GATE_SCRIPTS_DIR_IN_VM}/creds/", ep_path.write_text(text)
)
)
creds_dir = scripts_dir / "creds" creds_dir = scripts_dir / "creds"
creds_dir.mkdir(exist_ok=True) creds_dir.mkdir(exist_ok=True)
@@ -152,23 +152,31 @@ def force_allowlist(machine_name: str, allowed_cidrs: list[str]) -> None:
"""Ensure the machine's persisted TSI allowlist equals """Ensure the machine's persisted TSI allowlist equals
`allowed_cidrs`, failing **closed** if that can't be confirmed. `allowed_cidrs`, failing **closed** if that can't be confirmed.
Runs on both macOS and Linux. It exists because smolvm 0.8.0 macOS only. On Linux, smolvm's TSI defaults to full loopback
silently drops `--allow-cidr` when combined with `--from`, so access and patching `allowed_cidrs` into the state DB crashes
the allowlist has to be written into smolvm's persistent state TSI boot (the VM fails with "boot process exited (code 1)").
DB before `machine start`. Rather than assume the flag was Per-bottle CIDR isolation is therefore not enforced on Linux
dropped, we read the persisted row and only patch when it until smolvm fixes the `--allow-cidr` + TSI interaction. This
doesn't already match — so a newer smolvm that honors the flag is a known limitation — the agent VM can reach all of host
is left untouched. loopback, not just its bottle's forwarder ports.
On macOS, smolvm 0.8.0 silently drops `--allow-cidr` when
combined with `--from`, so the allowlist has to be written
into smolvm's persistent state DB before `machine start`.
Rather than assume the flag was dropped, we read the persisted
row and only patch when it doesn't already match — so a newer
smolvm that honors the flag is left untouched.
Must run AFTER `smolvm machine create` (the row has to exist) Must run AFTER `smolvm machine create` (the row has to exist)
and BEFORE `smolvm machine start` (smolvm reads the row on and BEFORE `smolvm machine start` (smolvm reads the row on
start; in-flight VMs don't pick up changes). start; in-flight VMs don't pick up changes).
Fail-closed: if the state DB is missing, the row is missing, or Fail-closed (macOS): if the state DB is missing, the row is
the allowlist still doesn't match after patching, we `die()` missing, or the allowlist still doesn't match after patching,
rather than boot a VM whose egress confinement we can't verify we `die()` rather than boot a VM whose egress confinement we
— an unconfirmed allowlist is a sandbox-escape risk (the agent can't verify."""
VM could reach all of host loopback).""" if not _is_macos():
return
want = list(allowed_cidrs) want = list(allowed_cidrs)
if not _SMOLVM_DB_PATH.is_file(): if not _SMOLVM_DB_PATH.is_file():
die( die(
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -313,9 +313,9 @@ class TestForceAllowlist(unittest.TestCase):
self.assertEqual(4, cfg["cpus"]) self.assertEqual(4, cfg["cpus"])
self.assertTrue(cfg["network"]) self.assertTrue(cfg["network"])
def test_patches_on_linux_too(self): def test_skips_patch_on_linux(self):
# force_allowlist no longer no-ops on Linux — the TSI # On Linux, patching allowed_cidrs into the smolvm state DB
# allowlist must be enforced there as well. # crashes TSI boot. force_allowlist is a no-op on Linux.
with patch.object(loopback_alias, "_is_macos", return_value=False), \ with patch.object(loopback_alias, "_is_macos", return_value=False), \
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db): patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"]) loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
@@ -324,7 +324,7 @@ class TestForceAllowlist(unittest.TestCase):
"SELECT data FROM vms WHERE name='demo-vm'", "SELECT data FROM vms WHERE name='demo-vm'",
).fetchone()[0]) ).fetchone()[0])
con.close() con.close()
self.assertEqual(["127.0.0.16/32"], cfg["allowed_cidrs"]) self.assertIsNone(cfg["allowed_cidrs"])
def test_skips_write_when_already_matching(self): def test_skips_write_when_already_matching(self):
# A newer smolvm that honors --allow-cidr at create leaves the # A newer smolvm that honors --allow-cidr at create leaves the
+26 -2
View File
@@ -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):