feat(smolmachines): patch smolvm state DB to actually enforce per-bottle allowlist
Earlier commit framed this PR as "infrastructure landed, TSI enforcement blocked on upstream smolvm 0.8.0." Found a clean workaround that lets us enforce now. Smolvm persists each machine's config (including `allowed_cidrs`) as a JSON BLOB in `~/Library/Application Support/smolvm/server/smolvm.db`, `vms.data`. `machine create --allow-cidr X/32` silently writes `allowed_cidrs: null` to that row when combined with `--from`, but smolvm reads the row at `machine start` — so patching the row between create and start sets the allowlist for real. New `loopback_alias.force_allowlist(machine_name, cidrs)` opens the SQLite DB, JSON-decodes the row, sets `allowed_cidrs`, and writes back as BLOB (Text type silently corrupts smolvm's later reads). launch.py calls it immediately after `machine_create` and before `machine_start`. Verified end-to-end on macOS / Docker Desktop: VM allowlist after start: ["127.0.0.16/32"] VM → 127.0.0.1:3000 → BLOCKED (Permission denied) VM → 8.8.8.8:53 → BLOCKED (Permission denied) VM → 127.0.0.16:<bundle> → CONNECTED The DB-patch hack is correct only because smolvm reads `allowed_cidrs` from the row at start time (not derived in- process). When upstream honors `--allow-cidr` with `--from`, the call becomes redundant — drop the call and the workaround is gone. Tests: 4 new for `force_allowlist` (BLOB round-trip; Linux no-op; missing DB; missing row). Total 593 unit tests pass. README + PRD updated to reflect the fix landed (no longer "infrastructure pending upstream"). gitea#75 can close. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,8 +7,12 @@ inspecting running bundle containers' port bindings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from claude_bottle.backend.smolmachines import loopback_alias
|
||||
@@ -187,5 +191,88 @@ class TestAliasInUseDetection(unittest.TestCase):
|
||||
self.assertEqual(set(), loopback_alias._aliases_in_use())
|
||||
|
||||
|
||||
class TestForceAllowlist(unittest.TestCase):
|
||||
"""Smolvm 0.8.0 silently drops `--allow-cidr` with `--from`,
|
||||
so `force_allowlist` opens the state DB directly and sets
|
||||
the row's `allowed_cidrs` field. Round-trip tests against a
|
||||
real SQLite DB to lock down the BLOB encoding."""
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory(prefix="smolvm-db.")
|
||||
self.db = Path(self._tmp.name) / "smolvm.db"
|
||||
con = sqlite3.connect(str(self.db))
|
||||
con.execute(
|
||||
"CREATE TABLE vms (name TEXT PRIMARY KEY NOT NULL, data BLOB NOT NULL)"
|
||||
)
|
||||
# Mimic smolvm's row shape (the JSON keys that exist on
|
||||
# creation; allowed_cidrs is the field we patch).
|
||||
cfg = {
|
||||
"name": "demo-vm",
|
||||
"cpus": 4,
|
||||
"mem": 8192,
|
||||
"network": True,
|
||||
"allowed_cidrs": None,
|
||||
}
|
||||
con.execute(
|
||||
"INSERT INTO vms (name, data) VALUES (?, ?)",
|
||||
("demo-vm", sqlite3.Binary(json.dumps(cfg).encode())),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
def tearDown(self):
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_patches_allowed_cidrs_on_row(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db):
|
||||
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
||||
|
||||
con = sqlite3.connect(str(self.db))
|
||||
row = con.execute(
|
||||
"SELECT typeof(data), data FROM vms WHERE name='demo-vm'",
|
||||
).fetchone()
|
||||
con.close()
|
||||
# Must round-trip as BLOB (the column type smolvm reads).
|
||||
self.assertEqual("blob", row[0])
|
||||
cfg = json.loads(row[1])
|
||||
self.assertEqual(["127.0.0.16/32"], cfg["allowed_cidrs"])
|
||||
# Other fields preserved verbatim.
|
||||
self.assertEqual(4, cfg["cpus"])
|
||||
self.assertTrue(cfg["network"])
|
||||
|
||||
def test_noop_on_linux(self):
|
||||
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"])
|
||||
# DB row should be untouched.
|
||||
con = sqlite3.connect(str(self.db))
|
||||
cfg = json.loads(con.execute(
|
||||
"SELECT data FROM vms WHERE name='demo-vm'",
|
||||
).fetchone()[0])
|
||||
con.close()
|
||||
self.assertIsNone(cfg["allowed_cidrs"])
|
||||
|
||||
def test_dies_on_missing_db(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(
|
||||
loopback_alias, "_SMOLVM_DB_PATH",
|
||||
Path("/nonexistent/smolvm.db"),
|
||||
), patch.object(
|
||||
loopback_alias, "die", side_effect=SystemExit("die"),
|
||||
):
|
||||
with self.assertRaises(SystemExit):
|
||||
loopback_alias.force_allowlist("demo-vm", ["127.0.0.16/32"])
|
||||
|
||||
def test_dies_on_missing_row(self):
|
||||
with patch.object(loopback_alias, "_is_macos", return_value=True), \
|
||||
patch.object(loopback_alias, "_SMOLVM_DB_PATH", self.db), \
|
||||
patch.object(
|
||||
loopback_alias, "die", side_effect=SystemExit("die"),
|
||||
):
|
||||
with self.assertRaises(SystemExit):
|
||||
loopback_alias.force_allowlist("not-in-db", ["127.0.0.16/32"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user