From f942e0149c6ea4c5439b59986edaba83070158cc Mon Sep 17 00:00:00 2001 From: Phil Calvin Date: Sat, 11 Apr 2026 03:57:08 +0000 Subject: [PATCH 1/6] =?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/6] 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/6] 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/6] 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/6] 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 d1e0ac303ebc7a2407de50a5c9fbd43e8f669f52 Mon Sep 17 00:00:00 2001 From: Phil Calvin Date: Sat, 11 Apr 2026 04:12:51 +0000 Subject: [PATCH 6/6] CI: try larger runners (4-core Linux, xlarge macOS) Experiment to measure speedup. Linux gets KVM on larger runners. macOS is still TCG but has more CPU/RAM. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52aa648..29fc7f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ jobs: - run: uv run pytest tests/test_filter.py -v e2e-linux: - runs-on: ubuntu-latest - timeout-minutes: 20 + runs-on: ubuntu-latest-m # KVM-enabled, ~90s vs ~4min under TCG + timeout-minutes: 10 steps: - uses: actions/checkout@v5 - name: Install uv @@ -52,7 +52,7 @@ jobs: ${{ runner.temp }}/vm-state/mitmdump.log e2e-macos: - runs-on: macos-latest + runs-on: macos-latest-xlarge timeout-minutes: 20 steps: - uses: actions/checkout@v5