Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ 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 \
| tar xz -C /usr/local/bin --strip-components=1
- 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@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 \
Expand All @@ -44,18 +44,18 @@ jobs:

- name: Upload logs on failure
if: failure()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: e2e-linux-logs
path: |
${{ runner.temp }}/vm-state/console.log
${{ runner.temp }}/vm-state/mitmdump.log

e2e-macos:
runs-on: macos-latest
timeout-minutes: 15
runs-on: macos-latest-xlarge
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 \
Expand All @@ -68,10 +68,11 @@ 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()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: e2e-macos-logs
path: |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down
7 changes: 6 additions & 1 deletion tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
23 changes: 19 additions & 4 deletions vm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -178,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]:
Expand Down
Loading