Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc9fdc8563 | |||
| d76ce89c87 |
@@ -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"
|
||||
@@ -406,15 +445,13 @@ def _stage_sidecar_data(plan: SmolmachinesBottlePlan) -> Path:
|
||||
for name in ("entrypoint.sh", "pre-receive", "access-hook"):
|
||||
(scripts_dir / name).chmod(0o755)
|
||||
|
||||
# Patch credential paths: the rendered entrypoint hardcodes
|
||||
# /git-gate/creds/; rewrite to the in-VM git-gate subdir.
|
||||
# Patch paths: the rendered entrypoint hardcodes /git-gate/creds/
|
||||
# and /etc/git-gate/ for hooks; rewrite both to the in-VM subdir.
|
||||
ep_path = scripts_dir / "entrypoint.sh"
|
||||
ep_path.write_text(
|
||||
ep_path.read_text().replace(
|
||||
"/git-gate/creds/",
|
||||
f"{_GIT_GATE_SCRIPTS_DIR_IN_VM}/creds/",
|
||||
)
|
||||
)
|
||||
text = ep_path.read_text()
|
||||
text = text.replace("/git-gate/creds/", f"{_GIT_GATE_SCRIPTS_DIR_IN_VM}/creds/")
|
||||
text = text.replace("/etc/git-gate/", f"{_GIT_GATE_SCRIPTS_DIR_IN_VM}/")
|
||||
ep_path.write_text(text)
|
||||
|
||||
creds_dir = scripts_dir / "creds"
|
||||
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
|
||||
`allowed_cidrs`, failing **closed** if that can't be confirmed.
|
||||
|
||||
Runs on both macOS and Linux. It exists because 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.
|
||||
macOS only. On Linux, smolvm's TSI defaults to full loopback
|
||||
access and patching `allowed_cidrs` into the state DB crashes
|
||||
TSI boot (the VM fails with "boot process exited (code 1)").
|
||||
Per-bottle CIDR isolation is therefore not enforced on Linux
|
||||
until smolvm fixes the `--allow-cidr` + TSI interaction. This
|
||||
is a known limitation — the agent VM can reach all of host
|
||||
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)
|
||||
and BEFORE `smolvm machine start` (smolvm reads the row on
|
||||
start; in-flight VMs don't pick up changes).
|
||||
|
||||
Fail-closed: if the state DB is missing, the row is missing, or
|
||||
the allowlist still doesn't match after patching, we `die()`
|
||||
rather than boot a VM whose egress confinement we can't verify
|
||||
— an unconfirmed allowlist is a sandbox-escape risk (the agent
|
||||
VM could reach all of host loopback)."""
|
||||
Fail-closed (macOS): if the state DB is missing, the row is
|
||||
missing, or the allowlist still doesn't match after patching,
|
||||
we `die()` rather than boot a VM whose egress confinement we
|
||||
can't verify."""
|
||||
if not _is_macos():
|
||||
return
|
||||
want = list(allowed_cidrs)
|
||||
if not _SMOLVM_DB_PATH.is_file():
|
||||
die(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -313,9 +313,9 @@ class TestForceAllowlist(unittest.TestCase):
|
||||
self.assertEqual(4, cfg["cpus"])
|
||||
self.assertTrue(cfg["network"])
|
||||
|
||||
def test_patches_on_linux_too(self):
|
||||
# force_allowlist no longer no-ops on Linux — the TSI
|
||||
# allowlist must be enforced there as well.
|
||||
def test_skips_patch_on_linux(self):
|
||||
# On Linux, patching allowed_cidrs into the smolvm state DB
|
||||
# crashes TSI boot. force_allowlist is a no-op on Linux.
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=False), \
|
||||
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
|
||||
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'",
|
||||
).fetchone()[0])
|
||||
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):
|
||||
# A newer smolvm that honors --allow-cidr at create leaves the
|
||||
|
||||
@@ -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