Background
The current CI (.github/workflows/main.yml) runs build-macos, tidy-macos, and scan-macos on GitHub-hosted macos-15 runners. Those runners are themselves virtualized and do not expose Hypervisor.framework: hv_vm_create returns HV_UNSUPPORTED, so the build-macos job stops at make elfuse + entitlement check, and the following are never exercised in CI:
make test-hello (assembly smoke test through the shim)
make check (aarch64 unit tests + busybox + syscall coverage)
make test-multi-vcpu (multi-vCPU HVF validation)
bash tests/test-matrix.sh all (full corpus across elfuse-aarch64 / qemu-aarch64 / elfuse-x86_64 modes, including the seven Rosetta sub-suites)
Regressions in the HVF bring-up path, syscall surface, fork/clone IPC, Rosetta integration, and FUSE transport therefore only surface in local developer runs. This issue tracks provisioning a self-hosted Apple Silicon runner so the matrix runs on every accepted PR.
Hardware available: M4-based Mac mini (detect_x86_64_host_class already handles M4 for the Rosetta
fast-path baseline).
Goals
- Run all HVF-dependent jobs on real hardware, including
tests/test-matrix.sh all.
- Keep the hosted Linux
lint and macOS build-macos / tidy-macos / scan-macos jobs unchanged.
- Keep the repository public, but tightly limit which workflow runs reach the self-hosted M4. The runner is opt-in per run, not opt-out.
Security model
The repository stays public. Public-repo + self-hosted-runner is a documented RCE vector: anyone who can land code that executes on the runner gets a shell on the M4. The mitigation stack below treats the YAML guard, the GitHub setting, and the maintainer label as independent layers; none of them is sufficient on its own.
Dedicated macOS account
The M4 must have a dedicated local account whose only purpose is running the GitHub Actions agent. This is the primary isolation boundary between runner workloads and any personal data on the machine.
Required properties of the ghrunner account:
- Standard (non-admin) account.
sysadminctl -addUser ghrunner -fullName "GH Actions Runner" (interactive password prompt). Do not run dseditgroup -o edit -a ghrunner -t user admin.
- No iCloud sign-in. Not linked to any Apple ID. No iMessage, no iCloud Drive, no Keychain sync.
- Separate login Keychain. Default behavior; just confirm by inspecting
~/Library/Keychains/login.keychain-db is local-only
after first login.
- No auto-login. The runner runs as a launchd LaunchDaemon (set up via
sudo ./svc.sh install ghrunner), so it starts at boot without a GUI session.
- FileVault enabled on the boot volume. Required for at-rest protection of the runner workspace.
- No Full Disk Access, Screen Recording, Accessibility, Input Monitoring, or Developer Tools privacy grants for
ghrunner. The agent does not need them; if a future step does, grant only the minimum.
- Spotlight indexing disabled on
~/actions-runner/_work/: sudo mdutil -i off ~/actions-runner/_work after first configuration. The fixture trees are large, churn often, and indexing them just wastes IO.
- Limit ownership scope: the
ghrunner home directory must be mode 700 and owned by ghrunner:staff. Personal data on the primary user's account is protected by macOS's per-user permissions, but treat it as an additional layer, not the only one.
Limiting which runs reach the M4
The repository is public; PRs from anyone can be opened. The runtime-macos job must NOT run on those by default. The three controls below run in series:
- Repository setting: Settings > Actions > General > "Require approval for all outside collaborators" (or stricter:
"Require approval for first-time contributors who are new to GitHub"). Fork-PR workflow runs cannot start until a maintainer clicks "Approve and run". Even at that point, only the workflows on the PR head are eligible to run.
- Workflow YAML guard. The
runtime-macos job is fenced by an if: clause that requires both (a) the PR head ref to live in sysprog21/elfuse, AND (b) the PR to carry the ci-hvf label. The label is applied by a maintainer only after reading the PR diff. Hosted-runner jobs (lint, build-macos, tidy, scan) are not gated and continue to run on every PR including from forks.
- Maintainer discipline. Never push fork PR commits to a branch in
sysprog21/elfuse without a code read. Never apply ci-hvf without reading the diff. Never approve fork PR workflow runs that modify .github/workflows/.
Forbidden events: do not extend the job to pull_request_target, workflow_run, or a workflow_dispatch that checks out arbitrary refs. pull_request_target paired with actions/checkout of the PR head is the canonical way to break every guard above.
needs: build-macos exists for signal/ordering only. It is not a security boundary; malicious code can pass make elfuse and execute arbitrary commands in make check or the matrix.
Host preparation (M4 Mac mini)
After the dedicated account exists, log in as ghrunner and do the following:
- macOS: latest stable (>= 15.x). Apple Silicon only.
- Command-line tools:
xcode-select --install
- Homebrew (arm64 prefix
/opt/homebrew).
- Install Rosetta for Linux (required for
elfuse-x86_64 mode):
softwareupdate --install-rosetta --agree-to-license
test -x /Library/Apple/usr/libexec/oah/RosettaLinux/rosetta
tests/test-matrix.sh checks this exact path before running the
Rosetta sub-suites; without it the suites skip silently.
- Runtime/test dependencies via Homebrew:
binutils (GNU objcopy; mk/shim.mk requires the GNU variant; available at /opt/homebrew/opt/binutils/bin/objcopy)
make (GNU make, installed as gmake by Homebrew; either invoke as gmake in the workflow or prepend
/opt/homebrew/opt/make/libexec/gnubin to the runner PATH)
llvm (clang-tidy + scan-build, for parity with hosted jobs)
qemu (for the qemu-aarch64 ground-truth mode)
coreutils (provides gtimeout, expected by
tests/lib/test-runner.sh on macOS)
python (3.x; for scripts/check-syscall-coverage.py and
scripts/gen-syscall-dispatch.py)
wget, curl, jq
- Cross-toolchains under
/opt/toolchain (default expected by
mk/toolchain.mk:32, LINUX_TOOLCHAIN ?= /opt/toolchain/aarch64-linux-gnu):
/opt/toolchain/aarch64-none-elf/ (bare-metal, for the
assembly smoke test)
/opt/toolchain/aarch64-linux-gnu/ with
bin/aarch64-linux-gnu-gcc and
aarch64-unknown-linux-gnu/sysroot/
/opt/toolchain/ is owned root:wheel mode 755; toolchains are read-only from ghrunner's perspective. Pin the exact toolchain source, version, and SHA-256 of the tarball in this issue once chosen so the runner image is reproducible.
- HVF entitlement (no host configuration needed -- the binary just needs the embedded entitlement that
make elfuse already adds).
Verify after first build:
codesign -d --entitlements - build/elfuse 2>&1 \
| grep com.apple.security.hypervisor
- Filesystem: APFS, >= 30 GiB free for
externals/test-fixtures/ (Alpine packages, linux-virt kernel,
Rosetta fixtures fetched by tests/fetch-fixtures.sh). Note that fetch-fixtures.sh downloads pinned-by-name but does not verify SHA-256 against the index; treat the on-disk fixture set as the source of truth and cache it across runs.
- Power:
sudo pmset -c sleep 0 disksleep 0 displaysleep 10.
Runner registration
Pick the latest runner from
https://github.com/actions/runner/releases and record the version
(X.Y.Z) and SHA-256 of the asset in this issue. Asset filename omits the leading v.
As ghrunner:
mkdir -p ~/actions-runner && cd ~/actions-runner
curl -L -o actions-runner-osx-arm64.tar.gz \
https://github.com/actions/runner/releases/download/vX.Y.Z/actions-runner-osx-arm64-X.Y.Z.tar.gz
shasum -a 256 actions-runner-osx-arm64.tar.gz # compare to release page
tar xzf actions-runner-osx-arm64.tar.gz
./config.sh --url https://github.com/sysprog21/elfuse \
--token <registration-token-from-Settings/Actions/Runners> \
--name macmini-m4-hvf \
--labels self-hosted,macOS,arm64,m4,hvf \
--work _work
Install as a LaunchDaemon (this is the form documented by GitHub for macOS):
sudo ./svc.sh install ghrunner
sudo ./svc.sh start
sudo ./svc.sh status
Verify in repo Settings > Actions > Runners that the runner appears as Idle with the expected labels.
Workflow changes (.github/workflows/main.yml)
Append the new job. The if: clause is the second layer of the defense-in-depth stack: same-repo head ref AND ci-hvf label. The concurrency group prevents two runs from contending for the single M4 if a maintainer pushes follow-up commits.
runtime-macos:
name: Runtime (macOS Apple Silicon, HVF)
needs: build-macos
if: >
github.event_name == 'push' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
contains(github.event.pull_request.labels.*.name, 'ci-hvf'))
runs-on: [self-hosted, macOS, arm64, hvf]
timeout-minutes: 60
concurrency:
group: runtime-macos-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
LINUX_TOOLCHAIN: /opt/toolchain/aarch64-linux-gnu
GNU_OBJCOPY: /opt/homebrew/opt/binutils/bin/objcopy
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Build elfuse
run: gmake elfuse
- name: test-hello
run: gmake test-hello
- name: test-multi-vcpu
run: gmake test-multi-vcpu
- name: make check
run: gmake check
- name: Test matrix
run: bash tests/test-matrix.sh all
Allowed events here are exactly push (which only fires for refs already in this repo, i.e. post-merge to main or maintainer-pushed branches) and same-repo pull_request carrying the ci-hvf label. Do not add pull_request_target, workflow_run, or arbitrary-ref workflow_dispatch.
Operational concerns
- Disk hygiene:
_work/ accumulates checkouts and fixtures. Add a daily LaunchDaemon job that prunes runs older than 7 days. Keep externals/test-fixtures/ outside _work/ so it survives prunes.
- Secrets: this workflow needs none beyond the auto-injected
GITHUB_TOKEN. Do not add repository secrets to the runner host, and never store anything sensitive in the ghrunner home.
- Monitoring: enable runner-offline notifications in repo Settings.
- Updates: the runner agent auto-updates by default; keep that on. macOS minor updates require a manual reboot; the LaunchDaemon brings the runner back up after boot without needing a user session.
References
Checklist
Background
The current CI (.github/workflows/main.yml) runs
build-macos,tidy-macos, andscan-macoson GitHub-hostedmacos-15runners. Those runners are themselves virtualized and do not expose Hypervisor.framework:hv_vm_createreturnsHV_UNSUPPORTED, so the build-macos job stops atmake elfuse+ entitlement check, and the following are never exercised in CI:make test-hello(assembly smoke test through the shim)make check(aarch64 unit tests + busybox + syscall coverage)make test-multi-vcpu(multi-vCPU HVF validation)bash tests/test-matrix.sh all(full corpus across elfuse-aarch64 / qemu-aarch64 / elfuse-x86_64 modes, including the seven Rosetta sub-suites)Regressions in the HVF bring-up path, syscall surface, fork/clone IPC, Rosetta integration, and FUSE transport therefore only surface in local developer runs. This issue tracks provisioning a self-hosted Apple Silicon runner so the matrix runs on every accepted PR.
Hardware available: M4-based Mac mini (
detect_x86_64_host_classalready handles M4 for the Rosettafast-path baseline).
Goals
tests/test-matrix.sh all.lintand macOSbuild-macos/tidy-macos/scan-macosjobs unchanged.Security model
The repository stays public. Public-repo + self-hosted-runner is a documented RCE vector: anyone who can land code that executes on the runner gets a shell on the M4. The mitigation stack below treats the YAML guard, the GitHub setting, and the maintainer label as independent layers; none of them is sufficient on its own.
Dedicated macOS account
The M4 must have a dedicated local account whose only purpose is running the GitHub Actions agent. This is the primary isolation boundary between runner workloads and any personal data on the machine.
Required properties of the
ghrunneraccount:sysadminctl -addUser ghrunner -fullName "GH Actions Runner"(interactive password prompt). Do not rundseditgroup -o edit -a ghrunner -t user admin.~/Library/Keychains/login.keychain-dbis local-onlyafter first login.
sudo ./svc.sh install ghrunner), so it starts at boot without a GUI session.ghrunner. The agent does not need them; if a future step does, grant only the minimum.~/actions-runner/_work/:sudo mdutil -i off ~/actions-runner/_workafter first configuration. The fixture trees are large, churn often, and indexing them just wastes IO.ghrunnerhome directory must be mode 700 and owned byghrunner:staff. Personal data on the primary user's account is protected by macOS's per-user permissions, but treat it as an additional layer, not the only one.Limiting which runs reach the M4
The repository is public; PRs from anyone can be opened. The runtime-macos job must NOT run on those by default. The three controls below run in series:
"Require approval for first-time contributors who are new to GitHub"). Fork-PR workflow runs cannot start until a maintainer clicks "Approve and run". Even at that point, only the workflows on the PR head are eligible to run.
runtime-macosjob is fenced by anif:clause that requires both (a) the PR head ref to live insysprog21/elfuse, AND (b) the PR to carry theci-hvflabel. The label is applied by a maintainer only after reading the PR diff. Hosted-runner jobs (lint, build-macos, tidy, scan) are not gated and continue to run on every PR including from forks.sysprog21/elfusewithout a code read. Never applyci-hvfwithout reading the diff. Never approve fork PR workflow runs that modify.github/workflows/.Forbidden events: do not extend the job to
pull_request_target,workflow_run, or aworkflow_dispatchthat checks out arbitrary refs.pull_request_targetpaired withactions/checkoutof the PR head is the canonical way to break every guard above.needs: build-macosexists for signal/ordering only. It is not a security boundary; malicious code can passmake elfuseand execute arbitrary commands inmake checkor the matrix.Host preparation (M4 Mac mini)
After the dedicated account exists, log in as
ghrunnerand do the following:xcode-select --install/opt/homebrew).elfuse-x86_64mode):tests/test-matrix.shchecks this exact path before running theRosetta sub-suites; without it the suites skip silently.
binutils(GNUobjcopy;mk/shim.mkrequires the GNU variant; available at/opt/homebrew/opt/binutils/bin/objcopy)make(GNU make, installed asgmakeby Homebrew; either invoke asgmakein the workflow or prepend/opt/homebrew/opt/make/libexec/gnubinto the runner PATH)llvm(clang-tidy + scan-build, for parity with hosted jobs)qemu(for theqemu-aarch64ground-truth mode)coreutils(providesgtimeout, expected bytests/lib/test-runner.shon macOS)python(3.x; forscripts/check-syscall-coverage.pyandscripts/gen-syscall-dispatch.py)wget,curl,jq/opt/toolchain(default expected bymk/toolchain.mk:32,LINUX_TOOLCHAIN ?= /opt/toolchain/aarch64-linux-gnu):/opt/toolchain/aarch64-none-elf/(bare-metal, for theassembly smoke test)
/opt/toolchain/aarch64-linux-gnu/withbin/aarch64-linux-gnu-gccandaarch64-unknown-linux-gnu/sysroot//opt/toolchain/is owned root:wheel mode 755; toolchains are read-only fromghrunner's perspective. Pin the exact toolchain source, version, and SHA-256 of the tarball in this issue once chosen so the runner image is reproducible.make elfusealready adds).Verify after first build:
externals/test-fixtures/(Alpine packages, linux-virt kernel,Rosetta fixtures fetched by
tests/fetch-fixtures.sh). Note thatfetch-fixtures.shdownloads pinned-by-name but does not verify SHA-256 against the index; treat the on-disk fixture set as the source of truth and cache it across runs.sudo pmset -c sleep 0 disksleep 0 displaysleep 10.Runner registration
Pick the latest runner from
https://github.com/actions/runner/releases and record the version
(X.Y.Z) and SHA-256 of the asset in this issue. Asset filename omits the leading
v.As
ghrunner:Install as a LaunchDaemon (this is the form documented by GitHub for macOS):
Verify in repo Settings > Actions > Runners that the runner appears as Idle with the expected labels.
Workflow changes (.github/workflows/main.yml)
Append the new job. The
if:clause is the second layer of the defense-in-depth stack: same-repo head ref ANDci-hvflabel. Theconcurrencygroup prevents two runs from contending for the single M4 if a maintainer pushes follow-up commits.Allowed events here are exactly
push(which only fires for refs already in this repo, i.e. post-merge tomainor maintainer-pushed branches) and same-repopull_requestcarrying theci-hvflabel. Do not addpull_request_target,workflow_run, or arbitrary-refworkflow_dispatch.Operational concerns
_work/accumulates checkouts and fixtures. Add a daily LaunchDaemon job that prunes runs older than 7 days. Keepexternals/test-fixtures/outside_work/so it survives prunes.GITHUB_TOKEN. Do not add repository secrets to the runner host, and never store anything sensitive in theghrunnerhome.References
https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/about-self-hosted-runners#self-hosted-runner-security
https://docs.github.com/en/actions/managing-workflow-runs/approving-workflow-runs-from-public-forks
https://docs.github.com/en/actions/hosting-your-own-runners/managing-self-hosted-runners/configuring-the-self-hosted-runner-application-as-a-service?platform=mac
(
com.apple.security.hypervisor):https://developer.apple.com/documentation/hypervisor
https://developer.apple.com/documentation/virtualization/running-intel-binaries-in-linux-vms-with-rosetta
Checklist
(or stricter) enabled.
ci-hvfcreated.ghrunnermacOS account created: standard, no iCloud,FileVault on, no Full Disk Access, mdutil off on
_work/.verified.
self-hosted,macOS,arm64,m4,hvf.sudo ./svc.sh install ghrunner)and starts at boot.
runtime-macosjob added behind the same-repo +ci-hvflabel gate.
ci-hvf.