Imported from pythoninthegrass/ai_skills (
skills/macos-sandbox/SKILL.md). Install upstream withnpx skills add pythoninthegrass/ai_skills --skill macos-sandbox. Copyright stays with the author.
macos-sandbox
Resolve SKILL_DIR (do this before running the bundled script)
scripts/tart_macos.py is a direct sibling of this file in every install
layout. Set SKILL_DIR to the absolute path of the directory containing
THIS SKILL.md you just Read, e.g.:
Read ~/.claude/skills/macos-sandbox/SKILL.md → SKILL_DIR=~/.claude/skills/macos-sandbox
Why this exists
osascript-mcp (~/git/osascript-mcp) runs /usr/bin/osascript as a local
subprocess -- it always drives whatever Mac it's running on, with no
remote/sandbox mode of its own. Running it directly on the host means every
automated click, keystroke, or window move happens on Lance's real desktop,
interrupting whatever else is going on there.
This skill gives osascript-mcp a Mac of its own: a small macOS 27 ("Golden
Gate") VM under Tart, reached over SSH. A second MCP server
(osascript-vm, registered per session) proxies into the VM, so the host's
own osascript-mcp registration is untouched and automation stays
contained. Two servers, two Macs -- pick osascript-vm for anything
disruptive (typing, clicking, screenshots of a full desktop), and the host
one only when the task genuinely needs the real machine.
One-time setup: build the golden VM
"${SKILL_DIR}/scripts/tart_macos.py" doctor
Checks for tart (brew install openai/tools/tart), that tart actually
runs (not just that it's on PATH -- see the pinned-version note below),
sshpass (brew install cirruslabs/cli/sshpass), Apple silicon, and free
disk. Fix anything it flags before continuing -- it doesn't install for
you. It also warns (non-fatally) if the host's DHCP lease time isn't
shortened yet -- see the tweak just below; the warning doesn't block
golden/up.
Known break: openai/tools/tart 2.35.0+ doesn't run on macOS Sequoia (or
older). That formula version and later are built against the macOS
26/Xcode 27 Swift toolchain and crash on launch with a dyld: libswiftCompatibilitySpan.dylib error on anything pre-Tahoe
(openai/tart#1302, open,
fix unmerged as of writing). doctor runs tart --version and reports
this exact error by name rather than a generic "not found". Fix by
installing 2.34.0 directly (Homebrew now requires formulae live in a tap,
so pointing brew install at a loose .rb file won't work):
Extract to a permanent location outside any repo/scratch dir -- $PWD
means a later cleanup pass on whatever directory you happened to run this
from can delete tart.app out from under the symlink, leaving tart
"installed" but pointing at nothing:
mkdir -p ~/.local/opt ~/.local/bin
curl -L -o ~/.local/opt/tart.tar.gz https://github.com/openai/tart/releases/download/2.34.0/tart.tar.gz
tar -xzvf ~/.local/opt/tart.tar.gz -C ~/.local/opt
rm ~/.local/opt/tart.tar.gz
ln -sf ~/.local/opt/tart.app/Contents/MacOS/tart ~/.local/bin/tart
Re-check host compatibility if a later release ships the bundled-dylib fix before relying on this pin indefinitely.
Also do this once per host, per Tart's own install notes -- the built-in
macOS DHCP server hands out 86,400s leases by default, which exhausts the
address pool if a host clones and boots many short-lived VMs in one day
(more than one every ~6 minutes). --softnet works around this
automatically; without it, shrink the lease time once:
sudo defaults write /Library/Preferences/SystemConfiguration/com.apple.InternetSharing.default.plist bootpd -dict DHCPLeaseTimeSecs -int 600
Persists across reboots. If a fresh VM still can't get an IP afterward, the
lease file may already be full of old 86,400s entries --
sudo rm /var/db/dhcpd_leases and it's recreated on the next tart run.
"${SKILL_DIR}/scripts/tart_macos.py" golden
Idempotent -- if gg-golden already exists this is a no-op (pass --force
to rebuild it). On first run it:
- Clones
ghcr.io/cirruslabs/macos-golden-gate-vanilla:27.0(the smallest Golden Gate image, no brew/tooling baked in -- ~31 GB download, ~36 GB on disk). - Sizes it to 2 vCPU / 4096 MB / 1280x800 -- per the Eclectic Light testing this article was seeded from, that's comfortably enough for everyday GUI automation on Apple silicon, using well under the memory ceiling.
- Boots it headless, waits for an IP and SSH.
- Runs
scripts/grant-tcc.shover SSH to grant Accessibility, Screen Capture, Post Event, and Apple Events (System Events + Safari) permissions to SSH-drivenosascript-- no SIP disable required, the vanilla image ships with SIP on and this only needssudo sqlite3access to the per-user TCC database. - Installs
uvin the guest. - Authorizes
TART_MACOS_GITHUB_KEYS_USER's (defaultpythoninthegrass) GitHub public keys for inbound SSH (curl .../pythoninthegrass.keys >> ~/.ssh/authorized_keys) and seedsknown_hostsforgithub.com-- no private key is ever copied into the guest. This is baked intogg-goldenonce, so every ephemeral clone inherits it from first boot. - Stops the VM.
golden is idempotent by checking whether gg-golden exists at all
(tart list), not whether it's currently reachable -- an earlier version of
this check used tart ip, which only works while a VM is running, so a
normal golden run (which ends by stopping the VM) would make the next
run wrongly conclude the VM didn't exist and try to re-clone into a name
that was already taken.
If step 4 is refused (a locked-down TCC database, or a macOS point
release that moved something): re-run with --grant, which boots the VM
with a display so the permissions can be granted once by hand in System
Settings → Privacy & Security. They persist in gg-golden, so every clone
inherits them -- this is a one-time fallback, not a per-session step.
Sending Apple Events to any app other than System Events or Safari still
prompts once the first time it happens. If a task needs another app,
trigger that prompt once inside gg-golden (via --grant) so clones
inherit the grant too.
Per-session: clone, wire up, tear down
RES=$("${SKILL_DIR}/scripts/tart_macos.py" up)
NAME=$(echo "$RES" | jq -r .name)
IP=$(echo "$RES" | jq -r .ip)
Clones gg-golden into a fresh ephemeral VM (gg-sbx-<timestamp> unless
you pass a name), boots it headless (--no-graphics --no-audio --no-clipboard), mounts the current directory (or --repo PATH) at the
guest's shared repo folder -- /Volumes/My Shared Files/repo, a live
virtiofs share, no copy step -- and waits for IP + SSH. APFS clones are
sparse and copy-on-write, so this is fast and cheap on disk despite the
golden image's size. --no-repo skips the mount entirely; --repo-ro
mounts it read-only. Pass --softnet for stricter network isolation
(Tart's Softnet userspace filter) if the automation task shouldn't reach
the LAN freely.
"${SKILL_DIR}/scripts/tart_macos.py" mcp "$NAME"
Prints (doesn't run) the registration command:
claude mcp add osascript-vm -- sshpass -p admin ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -A admin@<ip> '~/.local/bin/uvx --from git+https://github.com/pythoninthegrass/osascript-mcp osascript-mcp'
The -A forwards the host's ssh-agent, so git push/pull against the
mounted repo, run from inside the guest, authenticates using the host's
already-loaded identity -- combined with golden's GitHub-keys bootstrap
above, no private key or password ever needs to reach the guest for either
direction of SSH.
Run it (or have the user approve running it) to register osascript-vm for
this session. From here, drive automation through osascript-vm's tools,
not the host's osascript MCP server. Call check_permissions first to
confirm the TCC grants landed; if anything is missing, see the --grant
fallback above rather than trying to patch it live.
When the automation is done:
"${SKILL_DIR}/scripts/tart_macos.py" down "$NAME"
Stops and deletes the ephemeral clone. down without a name, or with the
golden VM's name, refuses unless --golden is passed -- this is
deliberate, don't work around it by naming the golden VM's own name as an
"ephemeral" clone.
"${SKILL_DIR}/scripts/tart_macos.py" status
Lists gg-* VMs and their state at any point -- useful before up if a
previous session's teardown didn't run.
Optional: run.py + playbook.yml for repeatable per-repo provisioning
up + mcp + manual setup steps is enough for a one-off. When a repo needs
the same guest-side setup every time (install deps, open a specific app,
sanity-check the mount), drop a real Ansible playbook at its root and let
run.py drive the whole thing in one call:
cp "${SKILL_DIR}/playbook.example.yml" /path/to/repo/playbook.yml # edit it
cd /path/to/repo
"${SKILL_DIR}/scripts/run.py" up
This calls tart_macos.py up --repo "$PWD" under the hood, builds a
dynamic Ansible inventory from the VM's own IP (same shape as
~/git/nw_infra/pulumi/k3s/ansible/inventory.yml -- ansible_host under
hosts, connection vars under vars, no password or private key needed
since golden's GitHub-keys bootstrap already covers auth), runs
playbook.yml in-process via ansible.cli.playbook.PlaybookCLI (the same
pattern as ~/git/nw_infra/networking/dhcp/run.py), prints the mcp add
command, and remembers the VM in .macos-sandbox-state.json next to the
playbook. Tear down with no arguments:
"${SKILL_DIR}/scripts/run.py" down
run.py up skips provisioning (with a warning, not a failure) if the repo
has no playbook.yml -- it's an additive layer, not a requirement for
tart_macos.py up/down to keep working standalone.
Concurrency limit
Apple's license permits at most 2 concurrent macOS VMs per host. Check
status before spinning up a second sandbox; don't fan out more than 2
up calls without stopping one first.
Bundled scripts
scripts/tart_macos.py -- a self-contained uv run --script (PEP 723)
tool with doctor, golden, up, mcp, status, and down
subcommands, described above. Every tunable resolves through
python-decouple: CLI flag > process env > skills/macos-sandbox/.env
hardcoded default. See
.env.examplefor the full list ofTART_MACOS_*names. Runscripts/tart_macos.py -hfor the flag list, and review the script before first use.
scripts/grant-tcc.sh -- trimmed from cirruslabs/macos-image-templates'
update-tcc-database.sh, run inside the guest by golden to grant
Accessibility/Screen Capture/Post Event/Apple Events to SSH-driven
osascript without disabling SIP.
scripts/test_tart_macos.py -- the accompanying pytest suite, also a
self-contained uv run --script. Run it directly
(./scripts/test_tart_macos.py) after changing tart_macos.py.
scripts/run.py -- optional uv run --script wrapper that shells out to
tart_macos.py up/mcp/down and drives a real Ansible playbook against
the fresh VM (see the section above). scripts/test_run.py covers its pure
plumbing (state file, inventory shape, playbook resolution); it doesn't
invoke ansible-playbook itself.
playbook.example.yml -- copy to playbook.yml in the repo being
automated. A real Ansible playbook, not a custom DSL.