mcqemu/tests/test_sandbox.py
Ryan Malloy 5a2d4703ab Close the remaining review findings: injection, isolation, unbounded work
QEMU command line:
- escape commas in every interpolated path (qopt); a path like
  'data,readonly=on.qcow2' previously injected a drive option
- reject extra_args flags that breach VM isolation (host filesystem
  passthrough, host block devices, spawning chardevs, -runas) and document
  the parameter as operator-only
- detect duplicate host ports across port_forwards instead of failing at
  QEMU launch; auto ports no longer collide with each other

Sandbox isolation:
- sandbox_vm now blocks guest-initiated traffic by default (restrict=on),
  with allow_network=True to opt in. Verified end to end: with identical
  guest network state, a default sandbox reaches neither a host loopback
  service nor the internet, while allow_network=True reaches both
- note in the docstring that the guest agent answers before the guest has
  finished booting

Bounded work per call:
- vm_serial_read seeks a 256KB window from the end instead of reading a
  console log that grows without bound into memory
- cap vm_type_text length and vm_mouse_move deltas
- screenshots get a unique filename and are cleaned up, so a concurrent
  capture cannot swap the frame under vm_click

Identity and liveness:
- attach_vm requires an actual unix socket and stores the resolved path
- attached VMs are judged by connecting, not by a stat that a stale socket
  file would pass
- refuse to act on a PID whose cmdline proves it is a different VM
- a sandbox's base image counts as in use while its overlay is live
- fix a latent NameError in vm_mouse_move's homing branch
2026-08-17 16:17:05 -06:00

158 lines
5.1 KiB
Python

"""Port-forward resolution and one-shot sandbox tools."""
import socket
import pytest
from fastmcp import Client
from fastmcp.exceptions import ToolError
from conftest import result_data, write_registry
from mcqemu.launcher import resolve_port_forwards
from mcqemu.server import mcp
from test_lifecycle import seeded_record
def test_resolve_explicit_free_port():
assert resolve_port_forwards(["18765:22"]) == ["18765:22"]
def test_resolve_auto_picks_free_port():
(entry,) = resolve_port_forwards(["auto:22"])
host, guest = entry.split(":")
assert guest == "22"
assert 1 <= int(host) <= 65535
def test_resolve_bare_guest_port_means_auto():
(entry,) = resolve_port_forwards(["80"])
assert entry.endswith(":80")
def test_resolve_busy_port_fails_fast():
with socket.socket() as blocker:
blocker.bind(("", 0))
busy = blocker.getsockname()[1]
with pytest.raises(ToolError, match="already in use"):
resolve_port_forwards([f"{busy}:22"])
def test_resolve_rejects_garbage():
for bad in ["22->2222", "x:y", "auto:notaport", "0:0"]:
with pytest.raises(ToolError):
resolve_port_forwards([bad])
@pytest.fixture
def base_image(tmp_path):
"""A real minimal qcow2 to serve as sandbox base."""
import subprocess
path = tmp_path / "base.qcow2"
subprocess.run(
["qemu-img", "create", "-f", "qcow2", str(path), "256M"],
check=True,
capture_output=True,
)
return path
@pytest.fixture
def fake_launch(dirs, monkeypatch):
"""Replace the real spawn with one that registers a plausible record."""
async def fake_launch_vm(name, disks, memory_mb, cpus, port_forwards, ctx, **kwargs):
from mcqemu.launcher import resolve_port_forwards
from mcqemu.tools._common import app
state = app(ctx)
record = seeded_record(
dirs, name, accel="kvm", config={"disks": disks, "port_forwards": port_forwards}
)
state.registry.add(record)
return {
"name": name,
"status": "running",
"accel": "kvm",
"port_forwards": resolve_port_forwards(port_forwards),
"restrict_net": kwargs.get("restrict_net"),
}
monkeypatch.setattr("mcqemu.tools.sandbox.launch_vm", fake_launch_vm)
@pytest.fixture
def agent_up(monkeypatch):
async def responding(record):
return True
monkeypatch.setattr("mcqemu.tools.sandbox._agent_responding", responding)
async def test_sandbox_vm_creates_overlay_and_waits(dirs, base_image, fake_launch, agent_up):
async with Client(mcp) as client:
data = result_data(await client.call_tool("sandbox_vm", {"base_image": str(base_image)}))
assert data["name"] == "sandbox"
assert data["sandbox"] is True
assert data["guest_agent"] == "responding"
assert data["port_forwards"][0].endswith(":22")
overlay = dirs.state / "vms" / "sandbox" / "overlay.qcow2"
assert overlay.exists()
assert data["overlay"] == str(overlay)
async def test_sandbox_auto_names_avoid_live_collisions(
dirs, base_image, fake_launch, agent_up, all_pids_alive
):
write_registry(dirs, seeded_record(dirs, "sandbox"))
async with Client(mcp) as client:
data = result_data(await client.call_tool("sandbox_vm", {"base_image": str(base_image)}))
assert data["name"] == "sandbox-2"
async def test_sandbox_auto_name_reuses_dead(
dirs, base_image, fake_launch, agent_up, all_pids_dead
):
write_registry(dirs, seeded_record(dirs, "sandbox"))
async with Client(mcp) as client:
data = result_data(await client.call_tool("sandbox_vm", {"base_image": str(base_image)}))
assert data["name"] == "sandbox"
async def test_sandbox_agent_timeout_reports_hint(dirs, base_image, fake_launch, monkeypatch):
async def never(record):
return False
monkeypatch.setattr("mcqemu.tools.sandbox._agent_responding", never)
async with Client(mcp) as client:
data = result_data(
await client.call_tool("sandbox_vm", {"base_image": str(base_image), "wait_agent_s": 0})
)
assert data["guest_agent"] == "unavailable"
assert "qemu-guest-agent" in data["agent_hint"]
async def test_sandbox_destroy_removes_overlay(dirs, all_pids_dead):
overlay = dirs.state / "vms" / "sb" / "overlay.qcow2"
overlay.parent.mkdir(parents=True)
overlay.write_bytes(b"fake")
write_registry(
dirs,
seeded_record(
dirs, "sb", config={"disks": [str(overlay)], "sandbox_overlay": str(overlay)}
),
)
async with Client(mcp) as client:
data = result_data(await client.call_tool("sandbox_destroy", {"name": "sb"}))
assert data["destroyed"] is True
assert data["overlay_deleted"] is True
assert not overlay.exists()
result = result_data(await client.call_tool("list_vms", {}))
assert result["vms"] == []
async def test_sandbox_destroy_refuses_non_sandbox(dirs, all_pids_dead):
write_registry(dirs, seeded_record(dirs, "vm1"))
async with Client(mcp) as client:
with pytest.raises(ToolError, match="not created by sandbox_vm"):
await client.call_tool("sandbox_destroy", {"name": "vm1"})