An isolated Lima dev VM on macOS, with all SSH auth and git signing routed through a YubiKey. The host holds the FIDO2 credentials; the VM never sees a private key. Lima reverse-forwards a user-space ssh-agent socket into the VM, scoped to github.com only.
- Ubuntu 26.04 LTS ARM64 in Lima, hardened: no host filesystem mounts, reversed agent socket forward, USB only as needed.
- Two ed25519-sk resident credentials on the YubiKey:
- auth key (PIN + touch per use) for
git push/pullto GitHub. - sign key (touch per use) for
git commit -S/git tag -s.
- auth key (PIN + touch per use) for
- Inside the VM: Docker, Rust (rustup), fish as default shell, git pre-configured to sign with the forwarded SK key,
~/.ssh/configpinned togithub.comonly.
- macOS on Apple Silicon.
- Homebrew + Lima:
brew install lima. ssh-askpasson a known path:brew install theseal/ssh-askpass/ssh-askpass.- A YubiKey with FIDO2 enabled and a PIN already set (
ykman fido access change-pin). - Fish shell on the host (the post-bootstrap instructions assume
fish/conf.d). - The following env exported in your fish config (e.g.
~/.config/fish/conf.d/ssh-askpass.fish):set -gx SSH_AUTH_SOCK $HOME/.ssh/agent.sock set -gx SSH_ASKPASS /opt/homebrew/bin/ssh-askpass set -gx SSH_ASKPASS_REQUIRE force
No package manager. Clone the repo and either add it to PATH or symlink the dispatcher:
git clone <repo-url> ~/src/alpaca
ln -s ~/src/alpaca/alpaca ~/bin/alpaca # or fish_add_path the repo diralpaca resolves its own symlinks, so it finds the sibling scripts regardless of where you symlink it from.
alpaca prerequisites install # one-time: brew-install lima, ssh-askpass, ykman, libfido2
alpaca enroll # one-time: create the two SK credentials on the YubiKey
alpaca bootstrap # build the VM and load the keys into a fresh user-space agent
alpaca doctor # verify everything end-to-endAfter bootstrap, paste the printed pubkeys into https://github.com/settings/ssh/new (one as Authentication, one as Signing).
| Command | What it does |
|---|---|
alpaca prerequisites check |
List host prerequisites (lima, theseal/ssh-askpass, ykman, libfido2) with present/missing status. Read-only; exits non-zero if anything is missing. |
alpaca prerequisites install |
Interactively brew install any missing prerequisites. Skips ones already present. |
alpaca enroll |
Interactive: generates two ed25519-sk resident keys via ssh-keygen. Touch always required; PIN-per-signature opt-in (default Y for auth, N for sign). |
alpaca bootstrap |
Destructive. Stops + deletes any existing Lima VM with the chosen name, recreates it from isolated-dev.yaml, runs vm-setup.sh inside. Replaces the user-space ssh-agent at ~/.ssh/agent.sock and re-ssh-adds both keys. |
alpaca doctor |
Read-only diagnostics across host tools, YubiKey, FIDO2 PIN status, ssh-agent env, and (if running) the VM. Prints PASS/WARN/FAIL with deduplicated next-steps. |
alpaca reset-agent |
Lightweight: tears down the host agent, restarts it, reloads both SK keys. Faster than bootstrap when you just need to re-arm the agent. The running VM picks up the new agent automatically (forward routes by path). |
vm-setup.sh is intentionally not exposed via alpaca — it runs inside the VM, invoked by bootstrap.sh.
Each underlying script in cmd/ also takes --help directly (e.g. cmd/bootstrap.sh --help).
| Variable | Used by | Purpose |
|---|---|---|
SSH_ASKPASS |
bootstrap, reset-agent | Path to ssh-askpass binary; required so the agent can prompt for the FIDO2 PIN. |
SSH_ASKPASS_REQUIRE |
bootstrap, reset-agent | Must be force or prefer. |
VM_NAME |
bootstrap, doctor | Lima instance name. Defaults to default. |
GIT_NAME, GIT_EMAIL |
bootstrap | Falls back to git config user.name / user.email if unset. |
SK_SIGN_KEY, SK_AUTH_KEY |
bootstrap | Substring matching the desired pubkey file path; skips the interactive picker. |
YAML_FILE, VM_SETUP_FILE |
bootstrap | Path overrides; default to siblings of bootstrap.sh. |
SIGN_FILE, AUTH_FILE |
enroll, doctor, reset-agent | Handle file paths. Default: $HOME/.ssh/id_ed25519_sk_{sign,auth}. |
USER_AGENT_SOCK |
doctor, reset-agent | Canonical user-space agent socket. Default: $HOME/.ssh/agent.sock. |
PROBE_VM |
doctor | Set 0 to skip the in-VM probe. |
┌──────────────┐
│ YubiKey │ ed25519-sk credentials
│ auth │ sign │ never leave the device
└──────┬───────┘
│ libfido2
│ PIN (auth only) + touch (always)
───────┼──────── trust boundary ────────
│
┌──────┴───────────────────────┐
│ macOS host │
│ ssh-askpass → ssh-agent │
│ ~/.ssh/agent.sock│
└──────────────┬───────────────┘
│ reverse-forwarded unix socket
│ (Lima exposes the agent to the VM —
│ signatures only, no key material)
┌──────────────┴───────────────┐
│ Lima VM │
│ • no host filesystem mounts │
│ • ssh pinned to github.com │
│ (~/.ssh/config in VM) │
└──────────────┬───────────────┘
│ git over ssh
▼
github.com
What the VM isolation gives you:
- No host filesystem access. The VM cannot read
~,/etc, or anything else on the Mac — there are no shared mounts. - Keys never enter the VM. Lima reverse-forwards the host's user-space ssh-agent as a Unix socket; the VM can request signatures, but the credentials live on the YubiKey.
- SSH scoped to GitHub.
~/.ssh/configin the VM pins the forwarded agent togithub.comonly — outbound SSH to anywhere else won't draw on the SK keys. - Hardware-attested signatures. The auth key has
verify-requiredset, so every signature requires a PIN via hostssh-askpassplus a YubiKey touch. The sign key requires touch only.
Residual risks:
- Kernel-level VM escape. Lima/QEMU isn't a Type-1 hypervisor — the VM shares the host kernel, so a kernel exploit can cross the boundary.
- Host compromise reaches the VM.
limactl shelland the VM's Lima-managed SSH run with your macOS user's privileges; any process running as you can drop into the VM. - Signing oracle while the YubiKey is plugged in. A compromised process in the VM can ask the forwarded agent to sign on its behalf. The auth key's PIN+touch requirement blocks silent abuse, but the sign key only requires touch — don't approve touch prompts you didn't trigger.
| Problem | Fix |
|---|---|
FIDO2 PIN exhausted (doctor reports 0 attempts) |
ykman fido reset (destroys all FIDO2 credentials), then alpaca enroll, then alpaca bootstrap. |
| Lost or replaced YubiKey | alpaca enroll on the new key, upload pubkeys to GitHub, alpaca bootstrap. |
| Agent stuck / "agent refused operation" | alpaca reset-agent. If that fails, alpaca doctor to find the missing env or socket. |
| VM in a bad state | alpaca bootstrap (it stops + deletes + recreates the VM). |
alpaca bootstrapis destructive — it deletes the named VM. If you have state inside the VM you care about, copy it out first.- Apple Silicon only (
isolated-dev.yamlpinsarch: aarch64). - Host-side post-install instructions assume fish. If you use a different shell, translate the
set -gxlines. ykmanis recommended (fordoctorto inspect FIDO2 PIN status) but not required for the bootstrap path itself — OpenSSH talks to the YubiKey vialibfido2directly.