From ded29a49f21a9e451f713b108f32b3b599c3f985 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Tue, 15 Sep 2026 07:54:46 -0700 Subject: [PATCH 1/5] fix(macos): reject wizard clones and document identity pinning --- images/macos/README.md | 187 +++++++++++++++++++++++++++++++++++--- images/macos/pf.anchor | 7 ++ images/macos/verify.sh | 15 +-- spikes/lume/identity.json | 57 ++++++++++++ 4 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 images/macos/pf.anchor create mode 100644 spikes/lume/identity.json diff --git a/images/macos/README.md b/images/macos/README.md index 81d427a..469c727 100644 --- a/images/macos/README.md +++ b/images/macos/README.md @@ -9,7 +9,7 @@ desktop-control credential. `provision.sh`, `verify.sh`, and `lib.sh` are shared by every train; each train directory holds only `image.yaml` and `unattended.yaml`. -## Status: built and qualified +## Status: seed qualified; remote backend blocked `macos/tahoe/desktop` exists and passes the gate on `studio-1` (Mac Studio M2 Max, 64 GB, macOS 26.6.2 / 25G83). The host is the owner's amendment to @@ -18,6 +18,10 @@ mini, with the backend confined to a dedicated `agentcompute` account. Migrating to a mini is moving that account's `~/.lume` directory and re-issuing one SSH key. +First-boot clones require the identity-pinning step below. The clone gate now +rejects Setup Assistant rather than skipping the desktop check. The local +host procedure is qualified; it is not evidence of a deployed MCP backend. + `macos/sequoia/desktop` is recorded but **not** the qualified train. Sequoia's last full restore image is 15.6.1 (24G90, 2025-08-20) — a year behind that train's security level — and its unattended install behaves no better than @@ -42,6 +46,90 @@ current, matches the host's own build, and has a publisher-independent digest. the `sshd_config.d` drop-in repeats every limit somewhere the account cannot edit (it owns its own `authorized_keys`, and `lume serve` runs as it). +## Remote transport boundary + +The forwarding-only server key reaches the Lume API, not the host's shell or +guest SSH. Lume 0.5.3 has no HTTP routes for guest exec/SCP, arbitrary sidecar +I/O, or setting a clone's machine identifier. Its clone request always +regenerates the identifier. The current boundary therefore cannot implement +the planned remote backend. Do not enable shell access, SFTP, guest-port +forwarding, or another forwarding destination to work around it. + +The owner must approve a host-side API mechanism for those operations before +server implementation can continue. A host-global capacity authority is also +needed: `/lume/host/status` counts only running VMs visible through the daemon +account's configured storage locations, including Linux VMs. It does not +enumerate the owner's other-account macOS VMs. + +Source: Lume 0.5.3's [HTTP routes](https://github.com/trycua/cua/blob/754eec754991e1760100621e9bfe7ec1395cc7db/libs/lume/src/Server/Server.swift#L203-L401), +[clone implementation](https://github.com/trycua/cua/blob/754eec754991e1760100621e9bfe7ec1395cc7db/libs/lume/src/LumeController.swift#L296-L386), +and [host-status handler](https://github.com/trycua/cua/blob/754eec754991e1760100621e9bfe7ec1395cc7db/libs/lume/src/Server/Handlers.swift#L901-L940). + +## Restrict VNC before serving workers + +Lume's VNC listener uses an ephemeral TCP port, not port 5900. The observed +host range is 49152–65535 (`sysctl net.inet.ip.portrange.first +net.inet.ip.portrange.last`). `pf.anchor` admits that range only through +loopback and the current Tailscale interface. + +**Impact:** this range also contains non-Lume applications. Review +`sudo lsof -nP -iTCP -sTCP:LISTEN` before applying it. Do not apply it if +another application requires LAN access in this range; resolve that conflict +with the owner first. These rules were syntax-checked, not installed during +the identity experiment. + +1. Stop all worker VMs through the HTTP API so no pre-existing VNC states + survive the policy change. Leave the seed stopped. +2. Find the current Tailscale interface with + `route -n get `; use its `interface` value below. + It was `utun9` during the experiment, but the number can change. +3. Validate and install only the anchor file: + + ```sh + tailnet_if=utun9 # replace with the observed interface + sudo pfctl -n -D "tailnet_if=$tailnet_if" -f images/macos/pf.anchor + sudo install -o root -g wheel -m 0644 images/macos/pf.anchor \ + /etc/pf.anchors/io.gilman.agentcompute + ``` + +4. Back up `/etc/pf.conf`. Add `anchor "io.gilman.agentcompute" quick` + immediately before its existing `anchor "com.apple/*"` filter anchor. + Preserve every existing scrub, NAT, redirect, and Apple anchor. Compare + the live root rules (`sudo pfctl -sr`, `sudo pfctl -sn`) with the file: + reloading it can discard dynamically inserted root rules. If they differ, + stop and reconcile with the owner rather than losing another service's + rules. Check `sudo pfctl -nf /etc/pf.conf` before the maintenance-window + reload with `sudo pfctl -f /etc/pf.conf`. Never use a global flush. +5. Load the child rules: + + ```sh + sudo pfctl -a io.gilman.agentcompute \ + -D "tailnet_if=$tailnet_if" -f /etc/pf.anchors/io.gilman.agentcompute + sudo pfctl -a io.gilman.agentcompute -sr + sudo pfctl -s info + ``` + + If PF is disabled, enable it with `sudo pfctl -E` and retain its reference + token. Never use `pfctl -d`: other host services also depend on PF. +6. Start one disposable worker. Read its actual `vncUrl` port and verify a + fresh TCP connection from loopback and an allowed tailnet peer succeeds, + while fresh connections to both LAN addresses fail. Also test IPv6 if a + wildcard IPv6 listener exists. Stop the worker after checking. + +The child rules must be reloaded after a reboot or Tailscale interface +change, **before** starting workers. The main anchor declaration alone does +not load them. An unattended host boot loader is not installed by this +procedure. If the interface cannot be identified or the anchor is absent, +do not serve workers. + +Rollback: stop workers, then unload only this anchor with +`sudo pfctl -a io.gilman.agentcompute -F rules`. Release only the PF reference +token acquired by this procedure with `sudo pfctl -X `. Restore the +saved main configuration if its declaration must also be removed. + +Upstream bind-address request: +[trycua/cua#3878](https://github.com/trycua/cua/issues/3878). + ## Build a seed All commands run on the Mac host from a checkout of this repository. The pin @@ -192,17 +280,91 @@ stop the VM and leave it stopped. ```sh curl -sS -X POST http://127.0.0.1:7777/lume/vms/clone -H 'Content-Type: application/json' \ -d '{"name":"ac-seed-macos-tahoe-desktop","newName":"ac-smoke-1"}' +``` + +Before the clone's **first boot**, copy only `machineIdentifier` from the +stopped seed. Keep the clone's generated MAC; copying the MAC would create a +network collision. Run this local host procedure as the `agentcompute` +account, not through the forwarding-only server key: + +```sh +sudo -u agentcompute -H /bin/bash -euo pipefail <<'PIN' +store="$HOME/.lume" +seed="$store/ac-seed-macos-tahoe-desktop/config.json" +clone="$store/ac-smoke-1/config.json" +identifier=$(/usr/bin/plutil -extract machineIdentifier raw -o - "$seed") +tmp=$(/usr/bin/mktemp "$store/ac-smoke-1/.config.XXXXXX") +trap 'rm -f "$tmp"' EXIT +cp "$clone" "$tmp" +/usr/bin/plutil -replace machineIdentifier -string "$identifier" "$tmp" +jq -e --arg id "$identifier" '.machineIdentifier == $id' "$tmp" >/dev/null +mv -f "$tmp" "$clone" +PIN +``` + +The rename replaces the configuration atomically on the same filesystem. +Never run it while the clone is running. Then start through HTTP: + +```sh curl -sS -X POST http://127.0.0.1:7777/lume/vms/ac-smoke-1/run -H 'Content-Type: application/json' \ -d '{"noDisplay":true}' +``` + +Wait until `GET /lume/vms/ac-smoke-1` reports a NAT address and SSH accepts +connections. Start the already-installed Driver LaunchAgent if it is pending; +this does not grant or alter TCC permissions: + +```sh +ip=$(curl -fsS http://127.0.0.1:7777/lume/vms/ac-smoke-1 | jq -er .ipAddress) +sudo -u agentcompute ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new \ + -i /Users/agentcompute/.ssh/guest_ed25519 "lume@$ip" \ + 'launchctl kickstart gui/$(id -u)/com.trycua.cua_driver_daemon' sudo -u agentcompute -H images/macos/verify.sh --vm ac-smoke-1 --clone \ --recipe images/macos/tahoe/desktop/image.yaml \ --identity /Users/agentcompute/.ssh/guest_ed25519 ``` -Measured over four clone cycles: clone 2–3 s, boot to NAT address 10–11 s, -Driver answering with both grants about 30 s, `verify.sh --clone` 6–7 s, host -allocation ~26.5 GB per running clone (APFS copy-on-write; the seed's own -allocation does not change). No clone ever asked for consent again. +The earlier unpinned measurements were clone 2–3 s, NAT address 10–11 s, +and host allocation about 26.5 GB per clone. Those runs skipped the wizard +check and are not desktop-readiness measurements. + +On 2026-09-15, a fresh pre-boot-pinned clone and a second pinned clone both +ran concurrently, kept their distinct MACs, retained both TCC grants, and +passed the strict gate after the Driver kickstart. Screenshots showed the +desktop, not Setup Assistant. A fresh unpinned control still showed the +wizard: the old gate exited 0, while the corrected gate exited 1. +The comparison and gate output are retained in +[`spikes/lume/identity.json`](../../spikes/lume/identity.json). + +The pinned UUID matched the seed. System loginwindow and system/user Setup +Assistant plists were identical in the initial seed/control comparison; +the clone had acquired an additional UUID-named ByHost loginwindow plist. +Changing only `machineIdentifier` restored the seed's guest UUID and serial. +No dismissal agent or TCC database modification was used. Rebooting an +already-booted unpinned clone also cleared the wizard in one experiment: +use **fresh first-boot controls**, not a reboot alone, to qualify pinning. + +Both pinned and unpinned clones sometimes left the Driver LaunchAgent in +`pended nondemand spawn = speculative`, with zero runs. `launchctl kickstart` +started the existing app-owned daemon; both grants then remained true. + +## Host-wide guest-count rule + +Before clone/start, reserve capacity against the host's running macOS guests +and pending starts, including the owner's VMs and the seed if running. Refuse +the third before sending a mutating Lume request: + +```text +macOS guest limit reached (2 per host); the owner's macOS VMs share this budget +``` + +Creation, restart/start, and reaper recovery need the same host-wide authority +and serialized reservations. Counting only `ac-*` names or the backend +account's `lume ls` is insufficient. Enumeration is needed to establish the +count, so “before any API call” means before any **mutating** API call, not +before the inventory read. Lume 0.5.3 supplies no authoritative cross-account +count; this gate is a server prerequisite, not implemented by these scripts. +See [trycua/cua#3880](https://github.com/trycua/cua/issues/3880). ## Measurements @@ -222,15 +384,10 @@ allocation does not change). No clone ever asked for consent again. ## Findings -1. **A clone re-runs Setup Assistant.** The seed stays clean across its own - reboots once `AccountInfo:FirstLogins:lume` is raised in - `/Library/Preferences/com.apple.loginwindow.plist`, but every clone boots - into the Apple Account step again, and killing it logs the session out. The - Driver is unaffected — grants, accessibility tree and capture all pass — so - `verify.sh --clone` skips only the desktop-session check. An agent handed - such a clone sees the wizard, so this is a real defect for the desktop - image, not a cosmetic one. The documented `com.apple.SetupAssistant` - `DidSee*` keys do not suppress it on 26.6.2. +1. **Clone identity triggers first-boot Setup Assistant.** Pinning the seed's + `machineIdentifier` before first boot removed the wizard while preserving + the clone's generated MAC. The strict `verify.sh --clone` gate always + checks the desktop. See the clone smoke procedure and its control evidence. 2. **Lume's API accepts a third macOS guest and silently does nothing.** `POST /lume/vms//run` returns `202 {"status":"pending"}`; the VM stays `stopped` and only `lume serve`'s log carries `The number of virtual @@ -240,7 +397,7 @@ allocation does not change). No clone ever asked for consent again. and the guest re-parses the result, so `lume ssh vm /bin/bash -c '