From f942e0149c6ea4c5439b59986edaba83070158cc Mon Sep 17 00:00:00 2001 From: Phil Calvin Date: Sat, 11 Apr 2026 03:57:08 +0000 Subject: [PATCH 1/7] =?UTF-8?q?Add=20HVF=E2=86=92TCG=20fallback=20on=20mac?= =?UTF-8?q?OS,=20force=20TCG=20in=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub-hosted macOS runners don't support HVF (QEMU aborts with HV_UNSUPPORTED). DarwinBackend now auto-detects HVF via sysctl and falls back to TCG, matching LinuxBackend's KVM detection pattern. QEMU_ACCEL env var overrides detection for CI or debugging. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 3 ++- vm.py | 15 ++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 947fa82..dc69ec4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,7 @@ jobs: e2e-macos: runs-on: macos-latest - timeout-minutes: 15 + timeout-minutes: 20 steps: - uses: actions/checkout@v4 - name: Install uv @@ -68,6 +68,7 @@ jobs: run: uv run pytest tests/test_e2e.py -v -s env: VM_STATE_DIR: ${{ runner.temp }}/vm-state + QEMU_ACCEL: tcg # HVF is unavailable on GitHub-hosted macOS runners - name: Upload logs on failure if: failure() diff --git a/vm.py b/vm.py index 02adef5..5673f8a 100755 --- a/vm.py +++ b/vm.py @@ -147,18 +147,27 @@ def launch_qemu(self, qemu_args: list[str]) -> subprocess.Popen: # --------------------------------------------------------------------------- class DarwinBackend(Backend): - """macOS backend: HVF acceleration, Homebrew firmware paths.""" + """macOS backend: HVF acceleration (with TCG fallback), Homebrew firmware paths.""" def __init__(self, brew: Path, arch: Arch, proxy_port: int = PROXY_PORT, ssh_host_port: int = SSH_HOST_PORT) -> None: super().__init__(arch, proxy_port, ssh_host_port) self._brew = brew + override = os.environ.get("QEMU_ACCEL") + if override: + self._accel = override + else: + r = subprocess.run(["sysctl", "-n", "kern.hv_support"], + capture_output=True, text=True) + self._accel = "hvf" if r.returncode == 0 and r.stdout.strip() == "1" else "tcg" @property def machine_args(self) -> list[str]: if self.arch == Arch.ARM64: - return ["-machine", "virt,accel=hvf", "-cpu", "host"] - return ["-machine", "q35,accel=hvf", "-cpu", "host"] + cpu = "host" if self._accel == "hvf" else "cortex-a57" + return ["-machine", f"virt,accel={self._accel}", "-cpu", cpu] + cpu = "host" if self._accel == "hvf" else "qemu64" + return ["-machine", f"q35,accel={self._accel}", "-cpu", cpu] def prepare_efi(self, state_dir: Path) -> tuple[Path, Path]: code_src = self._brew / "share/qemu/edk2-aarch64-code.fd" From 386af1158f56af12719e10a6e0066725a8ae8a15 Mon Sep 17 00:00:00 2001 From: Phil Calvin Date: Sat, 11 Apr 2026 03:59:20 +0000 Subject: [PATCH 2/7] Check /dev/kvm permissions, not just existence GitHub runners have /dev/kvm but it isn't accessible to the runner user. Use os.access() with R_OK|W_OK instead of Path.exists() so QEMU falls back to TCG instead of crashing with "Permission denied." Also respect QEMU_ACCEL env var like DarwinBackend. Co-Authored-By: Claude Opus 4.6 --- vm.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/vm.py b/vm.py index 5673f8a..ec87645 100755 --- a/vm.py +++ b/vm.py @@ -187,7 +187,13 @@ class LinuxBackend(Backend): def __init__(self, arch: Arch, proxy_port: int = PROXY_PORT, ssh_host_port: int = SSH_HOST_PORT) -> None: super().__init__(arch, proxy_port, ssh_host_port) - self._accel = "kvm" if Path("/dev/kvm").exists() else "tcg" + override = os.environ.get("QEMU_ACCEL") + if override: + self._accel = override + elif os.access("/dev/kvm", os.R_OK | os.W_OK): + self._accel = "kvm" + else: + self._accel = "tcg" @property def machine_args(self) -> list[str]: From 800a84a4550db478f2c9c8ce67d95081b3a622f1 Mon Sep 17 00:00:00 2001 From: Phil Calvin Date: Sat, 11 Apr 2026 04:01:44 +0000 Subject: [PATCH 3/7] Bump GitHub Actions to v5 (Node.js 24) actions/checkout@v4 and actions/upload-artifact@v4 use the deprecated Node.js 20 runtime. v5 uses Node.js 24. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc69ec4..52aa648 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: unit-tests: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv run: | curl -fL https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-unknown-linux-gnu.tar.gz \ @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv run: | curl -fL https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-unknown-linux-gnu.tar.gz \ @@ -44,7 +44,7 @@ jobs: - name: Upload logs on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: e2e-linux-logs path: | @@ -55,7 +55,7 @@ jobs: runs-on: macos-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install uv run: | curl -fL https://github.com/astral-sh/uv/releases/latest/download/uv-aarch64-apple-darwin.tar.gz \ @@ -72,7 +72,7 @@ jobs: - name: Upload logs on failure if: failure() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: e2e-macos-logs path: | From 6339d186febbf5f4bcecea38a5079fb236fad7e1 Mon Sep 17 00:00:00 2001 From: Phil Calvin Date: Sat, 11 Apr 2026 04:05:45 +0000 Subject: [PATCH 4/7] Handle SSH timeout in cloud-init polling loop Under TCG on CI, cloud-init status can take longer than 15s to respond while packages are installing. Catch TimeoutExpired and retry instead of crashing the test. Also bumped the per-poll timeout from 15s to 30s. Co-Authored-By: Claude Opus 4.6 --- tests/test_e2e.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 2dd4394..869945e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -240,7 +240,12 @@ def test_cloud_init_success(running_vm): deadline = time.monotonic() + 300 last_detail = "" while time.monotonic() < deadline: - r = _vm_ssh("cloud-init status --long 2>&1", timeout=15) + try: + r = _vm_ssh("cloud-init status --long 2>&1", timeout=30) + except subprocess.TimeoutExpired: + remaining = int(deadline - time.monotonic()) + _progress(f"cloud-init ({remaining}s left): (SSH timed out, retrying)") + continue remaining = int(deadline - time.monotonic()) # Compact multi-line status into a single progress line. detail = " | ".join( From b3d0970204977a60cae7400a0385330ae896de58 Mon Sep 17 00:00:00 2001 From: Phil Calvin Date: Sat, 11 Apr 2026 04:08:46 +0000 Subject: [PATCH 5/7] Rename README header to less-lethal: userspace LLM agent VM Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index af8d730..75c6df5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Agent VM +# less-lethal: userspace LLM agent VM A sandboxed Debian VM with no direct internet access. All traffic is forced through a host-side [mitmproxy](https://mitmproxy.org/) that enforces an allowlist, giving full visibility and control over what the guest can reach. Runs on macOS (Hypervisor.framework) and Linux (KVM or software emulation). No sudo required. From ee72b9fe2aaea5e02c2e583a6a1df8a5322bd38e Mon Sep 17 00:00:00 2001 From: Phil Calvin Date: Sat, 11 Apr 2026 04:29:55 +0000 Subject: [PATCH 6/7] Show mitmproxy startup errors instead of failing silently Poll for up to 3s instead of sleeping 1s to reliably catch fast failures like port-in-use. Print the actual mitmdump log output so the user sees the real error. Co-Authored-By: Claude Opus 4.6 --- vm.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/vm.py b/vm.py index ec87645..f346a30 100755 --- a/vm.py +++ b/vm.py @@ -461,13 +461,17 @@ def start_mitmproxy(proxy_port: int = PROXY_PORT) -> subprocess.Popen: log_file = log_path.open("w") print(f"Starting mitmproxy on port {proxy_port} (log: .vm/mitmdump.log)...") proc = subprocess.Popen(cmd, stdout=log_file, stderr=log_file) - time.sleep(1) - if proc.poll() is not None: - log_file.flush() - sys.exit( - f"mitmdump failed to start (exit code {proc.returncode}). " - f"Check {log_path} — port {proxy_port} may already be in use." - ) + + # Poll for up to 3 seconds to catch fast failures (e.g. port in use). + for _ in range(15): + time.sleep(0.2) + if proc.poll() is not None: + log_file.flush() + log_tail = log_path.read_text(errors="replace").strip() + sys.exit( + f"mitmdump failed to start (exit code {proc.returncode}).\n" + f"{log_tail}" + ) return proc From f4863e61ee175d1bd99e396cf5e787a80dea28f1 Mon Sep 17 00:00:00 2001 From: Phil Calvin Date: Sat, 11 Apr 2026 04:44:23 +0000 Subject: [PATCH 7/7] Extend cloud-init polling deadline to 600s MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 300s is too tight for macOS TCG on GitHub runners — cloud-init was still installing packages when the deadline expired. The job-level timeout (20min) is the real safety net. Co-Authored-By: Claude Opus 4.6 --- tests/test_e2e.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 869945e..97bb6f3 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -237,7 +237,7 @@ def test_cloud_init_success(running_vm): SSH subprocess open during the entire cloud-init run (which includes package installation and can take several minutes in TCG mode). """ - deadline = time.monotonic() + 300 + deadline = time.monotonic() + 600 last_detail = "" while time.monotonic() < deadline: try: