Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc9fdc8563 | |||
| d76ce89c87 |
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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