{"slug": "show-hn-augur-sandboxed-macos-vms-with-xcode-for-ai-coding-agents", "title": "Show HN: Augur – Sandboxed macOS VMs with Xcode for AI Coding Agents", "summary": "Developer h1d3mun3 released Augur, an open-source tool that runs Claude Code inside sandboxed macOS virtual machines with full Xcode, xcodebuild and the iOS Simulator preinstalled, plus a lightweight Linux container mode. Augur boots a real macOS guest on Apple's Virtualization.framework, exposes only the current directory, and enforces a host-side egress allowlist by default without sudo; the base VM build takes about 75 minutes from Apple-signed IPSW and Xcode XIP assets, and each project gets its own thin clone with isolated Claude history. The project documents accepted risks such as the .git/hooks host execution risk and the exfiltration ceiling in docs/decisions and docs/security-reviews, and gates releases on a full macOS-VM end-to-end run.", "body_md": "**Sandboxed macOS VMs with full Xcode for AI coding agents** — plus a lightweight Linux container mode.\n\naugur runs Claude Code inside an isolated guest where it can build, run, and **test** Apple-platform\ncode, while your host stays out of reach. Only the current directory is exposed; network egress is\nlimited to an allowlist enforced on the host.\n\n`augur up --macos` → `augur claude --macos` → the agent runs `xcodebuild test` inside the VM (5× speed)\n\n- **Xcode inside the sandbox — not just Linux.** macOS VM mode boots a real macOS guest on Apple's\nVirtualization.framework, with Xcode,`xcodebuild` , and the iOS Simulator preinstalled. Most agent\nsandboxes stop at Linux containers; augur gives the agent the full Apple toolchain without giving\nit your Mac.\n- **`xcodebuild test` works headless, over SSH.** Tests that need`testmanagerd` normally require a\nGUI login session. The base VM is built with auto-login and a virtual display, so the agent can run\nevery test type unattended. See[Running `xcodebuild test`](#running-xcodebuild-test) .\n- **One command, no GUI on macOS 27.** On a macOS 27+ host and guest,`augur build --macos` provisions the VM's account through macOS 27's`VZMacGuestProvisioningOptions` . There is no Setup\nAssistant to click through, and the admin password is randomized per build. Older pairs fall back to\na short manual step. See[ADR-0018](https://github.com/h1d3mun3/augur/blob/main/docs/decisions/0018-macos27-unattended-provisioning.md) .\n- **Built only from Apple-signed assets.** The base VM comes from an Apple IPSW plus an Xcode XIP,\nwith no third-party VM images or automation scripts. The VM backend (`augur-vm` ) is a small Swift\nCLI that ships in this repo.\n- **Egress allowlist, on by default, no `sudo`.** A host-side proxy lets the guest reach only the\ndomains you allow, DNS included. Project-supplied domains need your approval on first sight. See[Egress allowlist](#egress-allowlist-augurallowlistconf) .\n- **Per-project isolation.** Each project gets its own thin clone of the base VM or its own\ncontainer. It sees its workspace and only its own Claude history, not the rest of your home\ndirectory. Claude auth is injected through the environment, and the host's credential store is\nnever mounted.\n- **Honest about its limits.** Design decisions and accepted risks, such as the`.git/hooks` host\nexecution risk and the exfiltration ceiling, are written down in[`docs/decisions/`](https://github.com/h1d3mun3/augur/blob/main/docs/decisions) and[` docs/security-reviews/`](https://github.com/h1d3mun3/augur/blob/main/docs/security-reviews) . Releases are gated on a full macOS-VM E2E run.\n\n```\ngit clone -b release https://github.com/h1d3mun3/augur.git && cd augur\nbrew install go                          # needed for macOS VM egress filtering (augur-gvproxy)\nbash install && source ~/.zshrc          # installs augur + builds augur-vm / augur-proxy\n\n# one-time base VM build from Apple-signed assets (~75 min, unattended on macOS 27+)\naugur build --macos --ipsw ~/Downloads/macOS.ipsw --xcode-xip ~/Downloads/Xcode.xip\n\ncd ~/projects/my-ios-app\naugur claude --macos                     # Claude Code in an isolated, egress-filtered macOS VM\n```\n\nDon't need Xcode? `augur build && augur claude` gives you the lightweight\n[Container mode](#container-mode-default) instead.\n\n|  | Container mode | macOS VM mode | \n|---|---|---|\n| **Best for** | Web, backend, scripting, anything Linux | iOS / macOS / Apple-platform apps | \n| **Isolation** | Linux container (Apple Container) | Full macOS VM (Apple Virtualization.framework) | \n| **Xcode / `xcodebuild` / `xcodebuild test`** | ✗ | ✓ | \n| **iOS Simulator** | ✗ | ✓ (other platforms via `--platforms` ) | \n| **Egress allowlist** | ✓ | ✓ | \n| **One-time setup** | ~5 min (image build) | ~75 min (base VM build; unattended on macOS 27+) | \n| **Per-project start** | fast (container kept on `down` ) | thin clone of the base VM, kept on `down` | \n| **Disk usage** | ~2 GB | ~70 GB+ | \n| **Requires** | Apple Container 1.0.0+ (macOS 26+) | augur-vm (bundled), IPSW, Xcode XIP | \n\n**Workspace rule.** augur works in any directory except one that contains augur itself.\n`augur up`/` claude`/` shell` refuse to run in `$HOME`, in `~/.augur`, or in any parent of either,\nbecause the workspace is shared **read-write** and those directories hold the binaries your host\nruns (see [ADR-0014](https://github.com/h1d3mun3/augur/blob/main/docs/decisions/0014-workspace-must-not-contain-augur.md)). Use a subdirectory\ninstead.\n\nBoth modes share one install. The `release` branch is the gated stable channel:\n\n```\ngit clone -b release https://github.com/h1d3mun3/augur.git\ncd augur\nbrew install go     # macOS VM mode only: needed for egress filtering (augur-gvproxy)\nbash install\nsource ~/.zshrc     # or source ~/.bashrc\n```\n\nThe install script copies `augur` and `Dockerfile` to `~/.augur/`, refreshes the managed egress\nbaseline (`~/.augur/augur.conf.default`), builds the host egress proxy `augur-proxy` (needs Swift)\nand, on macOS, the VM backend `augur-vm` (needs Swift) and `augur-gvproxy` (needs Go), and\nconfigures `PATH`. Safe to re-run.\n\n**Stable vs. bleeding-edge.** Cloning `-b release` installs the latest release that passed the full macOS-VM E2E gate — `augur version` then reports a bare `X.Y.Z`. To follow development instead, clone `main` (the default branch); `augur version` reports `X.Y.Z-dev+<sha>` so you can always tell the two apart. Pin an exact version with `git clone --branch vX.Y.Z`. See [Cutting a release](#cutting-a-release-structural-gate).\n\naugur **never mirrors your host `~/.claude`** — that tree is executable config (hooks, skills,\ncommands), it is full of host-absolute paths that break in a Linux guest, and its permission set was\nwritten for a world with no sandbox. The full reasoning is in\n[ADR-0013](https://github.com/h1d3mun3/augur/blob/main/docs/decisions/0013-claude-config-inheritance.md).\n\nWhat you get instead is a directory you populate on purpose:\n\n```\n~/.augur/claude-profile/\n├── settings.json      → copied     (Claude rewrites it at user scope)\n├── CLAUDE.md          → copied     (/memory can edit it)\n├── keybindings.json   → copied     (the TUI owns it)\n├── commands/          → symlinked  (host edits live in Container mode — see the macOS caveat)\n├── skills/            → symlinked\n├── rules/             → symlinked\n├── output-styles/     → symlinked\n├── workflows/         → symlinked\n└── themes/            → symlinked\n```\n\n- \n**Opt-in and inert by default.** No directory, or an empty one, and augur wires nothing. Only\nnames that actually exist in the profile are ever written into the guest — ship no`settings.json` and augur never touches yours.\n- \n**Host-global, read-only.** One profile for every project on the machine: personal tooling is not\nproject-scoped. Read-only*because* it is shared — a guest able to write there would plant a hook\nor command for every project.\n- \n**In Container mode the directories are live** , in two senses. Editing a file*inside* an\nalready-wired directory (adding a command to`commands/` that's already there) needs no augur\naction at all — you're editing the live mount, and the next`augur claude` sees it.\nAdding/removing/replacing a whole entry (populating`commands/` for the first time, or deleting\nit) needs augur to re-run the wiring, which happens on**every**`augur claude` /`shell` /`up` , not\njust when the container happens to restart. (Claude Code itself only*scans* a top-level`skills/` /`commands/` directory that existed when its session started, so the very first time you\npopulate one, exit and relaunch`claude` once — that part isn't augur's to fix.)\n- \n**The three JSON/markdown files are copies** , refreshed on that same every-`claude` /`shell` /`up` cadence, because Claude Code writes user-scope`settings.json` and a read-only symlink would make\nthat an error. The profile is their source of truth: if you ship one, guest-side edits to it do\nnot survive the next wiring.\n- \n**Your own files are never deleted.** If the guest already had a real`~/.claude/commands` or`~/.claude/skills` when you first populate the profile, augur moves it aside to`<name>.pre-profile` rather than replacing it (an empty one is simply dropped).\n- \n**macOS VM mode reaches the profile over a virtiofs share.** The profile works there (it is shared\nread-only into the VM and wired the same way), but through a**virtiofs share** rather than a bind\nmount, on every macOS version.\n- \n**macOS VM mode caveat on macOS 26.x — host-side edits need a VM restart.** When the host or the\nguest runs macOS 26.x, a file the guest has**already read** keeps returning the content from that\nread after the host edits it: the guest's virtiofs client holds on to the cached copy and never\nre-fetches it. The stale copy comes from the guest's own earlier read, not from anything cached\nup front at boot — but profile files are read as soon as the guest is wired and Claude Code\nstarts, so in practice a profile edit made while the VM is running is not seen. Run`augur down --macos && augur up --macos` to pick it up. Container mode is unaffected.**On macOS 27.0+ (host and guest) the defect no longer reproduces** , and host-side edits show up\nin a running guest within about a second. That was confirmed on one machine (M1 Max, build\n26A428), not across hardware or 27.x point releases. See[ADR-0019](https://github.com/h1d3mun3/augur/blob/main/docs/decisions/0019-macos27-virtiofs-staleness-resolved.md) for what was tested and\nwhat it does not establish.On macOS 26.x, three things are worth knowing, because each is the opposite of what seems reasonable: \n  - **It is not specific to read-only shares.** The read-write workspace share behaves identically,\nso this is not only about the profile — edit a source file on the host and a running guest may\nkeep reading the old bytes.\n  - **There is no timeout to wait out.** One measured case stayed stale for**904.9 s** with zero\nnatural refreshes; every share then went fresh together 10.3 s after a guest vnode reclaim was\nforced. Waiting is the one remedy that does not work.\n  - **`down && up` works because the guest reboots with an empty vnode cache** , not because a fresh`vm run` rebuilds the share device — Virtualization.framework cannot rebuild a share device on a\nlive VM in any case.\n On macOS 26.x this is a platform defect, and augur accepts it rather than working around it — see [ADR-0017](https://github.com/h1d3mun3/augur/blob/main/docs/decisions/0017-accept-virtiofs-staleness.md) for the measurements, why the\nmitigation was removed, and what would bring it back.\nIssues[#124](https://github.com/h1d3mun3/augur/issues/124) and[#135](https://github.com/h1d3mun3/augur/issues/135) (closed by ADR-0019).\n\nYour **repository's** own `.claude/settings.json`, `CLAUDE.md`, `.claude/commands/`,\n`.claude/skills/` and `.mcp.json` already work with no setup — they arrive inside the workspace\nmount. The profile is for what a repo cannot supply because it is yours, not the project's.\n\nThese apply to both modes.\n\n**The read/write workspace mount includes `.git` — treat it as attacker-controlled.** A prompt-injected agent running inside the container or VM can write `.git/hooks/pre-commit` (or `post-checkout`, `post-merge`, …) into the mounted repo. Git runs repo-local hooks with no trust prompt, so the next `git` command *you* run on the **host** in that repo executes guest-authored code at your full host-user privilege — a complete escape of both the container/VM boundary and the egress allowlist. This isn't a bug augur can close in software: it's inherent to mounting a repo read-write so an agent can edit it. Review diffs before trusting them, and treat `.git/hooks` (and anything else the host later runs unreviewed, e.g. a `Makefile` or `.envrc`) in an augur-touched repo as attacker-controlled until inspected. See `docs/security-reviews/2026-07-10-egress.md` §6 item 12.\n\nClaude Code's `--worktree` isn't specially supported (`augur claude --worktree ...` won't forward the flag). If you want it anyway: run `augur shell` (or `augur shell --macos`), then type `claude --worktree <name>` yourself at the prompt — the worktree's files/git state persist fine, and its conversation history survives `augur down && up` (or `augur down --macos && augur up --macos`) too. Both modes mount the guest's whole `~/.claude/projects` parent to a per-project host dir — `~/.augur/claude-projects/<project>-<path hash>` in Container mode, `~/.augur/claude-projects/<vm>` in macOS VM mode — so every cwd-keyed leaf under this project (the main checkout **and** any worktree) is stored host-side and persists (only `augur destroy`/` augur destroy --macos` plus deleting the host history dir removes it). See `docs/decisions/0004-no-special-worktree-support.md` for the full trade-offs.\n\nFull macOS VM built from an Apple-signed IPSW. Supports Xcode, xcodebuild, and iOS Simulator. The VM is isolated per project — each directory gets its own thin clone of the base VM.\n\naugur ships its own VM backend (`augur-vm`), a small Swift CLI built directly on\nApple's Virtualization.framework — no third-party tools required. [Install](#install) builds it\nand installs it into `~/.augur` (on the macOS host; needs the Xcode / Swift toolchain, plus Go for\n`augur-gvproxy`, the macOS VM egress datapath).\n\n- **IPSW** — macOS restore image:[https://developer.apple.com/download/](https://developer.apple.com/download/)\n- **Xcode XIP** — Xcode installer:[https://developer.apple.com/download/all/](https://developer.apple.com/download/all/)\n\n```\naugur build --macos --ipsw ~/Downloads/macOS.ipsw --xcode-xip ~/Downloads/Xcode.xip\n```\n\nThis will:\n\n1. Create a macOS VM from your IPSW (`augur-vm create --from-ipsw` )\n2. Provision the account — **automatically** , with no GUI window, when both this host and the\nIPSW's guest OS are macOS 27+ (a randomly generated password is kept at`~/.augur/macos-admin-password` , mode 600); otherwise open the VM window for manual Setup\nAssistant completion (credentials:`admin` /`admin` , Remote Login enabled). Pass`--manual-setup` to take the manual flow even on a macOS 27+ pair. See[ADR-0018](https://github.com/h1d3mun3/augur/blob/main/docs/decisions/0018-macos27-unattended-provisioning.md) .\n3. Install Xcode, Homebrew, GitHub CLI, and Claude Code\n4. Download the iOS Simulator runtime (Xcode installed from a XIP does not bundle it)\n5. Save the result as a reusable base VM (`augur-macos-base` )\n\nBy default only the **iOS** Simulator runtime is baked in. Use `--platforms` to bake in others —\nbaking them into the base VM means every project clone gets them without re-downloading:\n\n```\n# iOS + watchOS\naugur build --macos --ipsw ... --xcode-xip ... --platforms iOS,watchOS\n\n# every platform Xcode offers (watchOS, tvOS, visionOS, …) — many extra GB\naugur build --macos --ipsw ... --xcode-xip ... --platforms all\n```\n\n**Supply chain note:** The base VM is built entirely from Apple-signed assets (IPSW + Xcode XIP).\nNo third-party automation scripts are used.\n\n```\ncd ~/projects/my-app\n\naugur up --macos        # clone base VM and start (first run clones automatically)\naugur up --macos --gui  # same, but also open a VM window (display + keyboard + pointer)\naugur claude --macos    # launch Claude Code  (starts VM if not running)\naugur shell --macos     # open a bash shell   (starts VM if not running)\naugur setup-token --macos  # get a Claude subscription token (runs in the VM, saves on the host)\naugur down --macos      # stop the VM (keeps the clone — next up is fast)\naugur destroy --macos   # stop and remove the project VM clone\naugur status --macos    # show VM status, toolchain, and auth info\naugur list --macos      # list all VMs and their state\naugur update --macos    # update CLI tools in the base VM\naugur version --macos   # show augur version (macOS mode)\n```\n\n**At most two macOS VMs can run at once.** Apple's macOS license permits up to two virtualized\nmacOS instances per Mac, and Virtualization.framework enforces that limit: starting a third\nrunning macOS guest fails. That covers every macOS guest on the host, not just augur's. Each\nproject clone counts while it is running, and so does the build VM during `augur build --macos` /\n`augur update --macos`. Stopped clones don't count, so you can keep as many as your disk allows.\nRun `augur down --macos` in a project you're not using (or check `augur list --macos`) to free a\nslot. Container mode runs Linux guests and isn't affected.\n\nOn macOS, Claude Code stores its OAuth login in the Keychain, which is unreadable over SSH and\nabsent from a freshly cloned VM. So macOS mode injects a credential through the environment on\nevery `up` (the same way it does for the GitHub token):\n\n- `CLAUDE_CODE_OAUTH_TOKEN` — subscription token; either set the env var / save it to`~/.claude_code_oauth_token` , or run**`augur setup-token --macos`** : it runs` claude setup-token` inside the guest (so you don't install Claude Code on the host), then you paste the token\nback once and augur saves it to`~/.claude_code_oauth_token` .\n- `ANTHROPIC_API_KEY` — Console API key (env or`~/.anthropic_api_key` ). Takes priority if both are set.\n\nA cloned macOS guest boots with its wall clock a fixed amount **behind** the host's (measured at ~95\nminutes on one host — a constant inherited from the base VM's saved state, not\ndrift), and it cannot fix itself: NTP is UDP/123 and macOS VM egress drops UDP by design. So augur\n**sets the guest's clock from the host's** over SSH — on `up --macos` (both a fresh boot and a\nreconcile of an already-running VM) and on `claude`/` shell --macos`, which attach without going\nthrough `up`. It runs before the token is injected, because a token minted on the host seconds ago\nlooks *not yet valid* to a guest sitting in the past. Best-effort: if it cannot be set you get a\nwarning, not a failed `up`. See\n[`docs/decisions/0015-guest-clock-from-host.md`](https://github.com/h1d3mun3/augur/blob/main/docs/decisions/0015-guest-clock-from-host.md).\n\n| Path | Description | \n|---|---|\n| Current directory | exposed at `~/workspace-<project>` in the VM (read/write, virtiofs auto-mount) | \n| `~/.gitconfig` | **copied** on VM start (unlike container mode, which mounts it read-only). augur then rewrites`credential.https://github.com.helper` in**the guest's copy** so HTTPS`git push` works off`GH_TOKEN` ; any helper the host set for`github.com` is replaced, including the pair`gh auth setup-git` writes, because those need a host path or the host Keychain the guest does not have. Your host file is never modified. | \n| `~/.config/gh/` | **not shared** , as in Container mode.`gh` works off the injected`GH_TOKEN` alone; see[API keys and authentication](#api-keys-and-authentication) for why the host's gh config stays on the host. (A share would also be inert here: it would land under`/Volumes/My Shared Files/` and nothing wires it to`~/.config/gh` inside the VM.) | \n| Claude history | **only this project's** history is shared, in a per-VM isolated dir (`~/.augur/claude-projects/<vm>` ), so other projects' transcripts stay invisible. History is not shared across modes, so a container session can't be resumed in the macOS VM or vice versa. | \n| Claude auth | **not** shared — injected via env (the macOS Keychain is unreadable over SSH; see above) | \n| Everything else | **not visible to the VM** | \n\nThe macOS guest auto-mounts the shared directory under `/Volumes/My Shared Files/workspace-<project>`; augur\nsymlinks it to `~/workspace-<project>`. The sealed system volume can't host a symlink at `/workspace`, so the\nper-project `~/workspace-<project>` path is used in the VM (container mode uses `/workspace-<project>`).\n\n`xcodebuild test` needs an Aqua (GUI) login session to reach `testmanagerd`; a headless SSH login\nhas none. Two things make that session exist at boot: the base VM is built with **auto-login**\nenabled, and the VM is always run with a **virtual display device** (macOS only starts an Aqua\nsession when a framebuffer exists — `--no-graphics` suppresses only the host-side window, not the\ndisplay device). With both in place, `xcodebuild test` works over SSH for all test types. Recommended\ninvocation (SwiftData's `@Model` macro needs `-skipMacroValidation` in a headless VM):\n\n```\nNSUnbufferedIO=YES xcodebuild test \\\n  -scheme <Scheme> \\\n  -destination 'platform=macOS,arch=arm64' \\\n  -parallel-testing-enabled NO \\\n  -skipMacroValidation \\\n  -derivedDataPath ~/DerivedData \\\n  CODE_SIGNING_ALLOWED=NO\n```\n\nIf builds are flaky from the shared mount (virtiofs is not tuned for heavy I/O — occasional\n\"project is damaged\" errors), copy the project to local disk first: `rsync -a ~/workspace-<project>/ ~/Developer/<app>/`.\n\n`augur destroy --macos` removes the *current* project's VM clone — but augur names a clone\nfrom its project directory, so if that directory is later renamed or moved, the old clone\nbecomes unreachable by `destroy` (it's still on disk, just under a name `destroy` doesn't\ncompute). `augur list --macos` still shows it; remove it directly:\n\n```\naugur list --macos       # every VM the store knows about, by name — not just this project's\naugur-vm stop <name>     # if it's running\naugur-vm delete <name>   # remove that VM/clone\n```\n\nSame story for `~/.augur/claude-projects/<vm>/` — a per-clone Claude-history directory that\n`destroy --macos` doesn't touch either; it's plain files, safe to `rm -rf` once you know the\nclone is gone for good. Both are accepted, documented trade-offs, not oversights — see\n`docs/decisions/0004-no-special-worktree-support.md` (§9, \"What actually shipped\") and\n`docs/decisions/0005-no-prune-command.md`.\n\n- macOS (Apple Silicon)\n- At most two macOS VMs running at once per Mac (Apple's limit, see [Concurrent VM limit](#concurrent-vm-limit) )\n- Xcode / Swift toolchain (to build the bundled `augur-vm` backend via`bash install` )\n- Go (`brew install go` ), so`bash install` can build`augur-gvproxy` . Egress filtering is on by\ndefault, and`augur up --macos` fails closed without it unless you pass`--no-egress` .\n- macOS IPSW (Apple-signed)\n- Xcode XIP (Apple-signed, from developer.apple.com)\n\nLightweight Linux container. Suitable for most projects that don't need Xcode.\n\nThe container is hosted by **Apple Container** (`container`, github.com/apple/container) on **macOS 26+** — native to macOS, no Docker Desktop, no licensing; each container runs in its own lightweight Linux VM. `augur status` shows the active engine.\n\nAfter [Install](#install), build the image:\n\n```\naugur build\n```\n\n**Apple Container only:** `augur build`/` augur update` implicitly starts a BuildKit \"builder\" VM (~2 CPU/2GiB) that keeps running after the build finishes, to speed up the next one. It's a single instance shared by every `container build` on the machine — not scoped to a project — so augur deliberately never stops it for you (doing so from one project's `down`/` build` could kill another project's in-flight build). If you want to free the RAM/CPU, run `container builder stop` yourself once you're sure nothing else is building.\n\n```\ncd ~/projects/my-app\n\naugur up [--swift VERSION]      # start the container\naugur claude                    # launch Claude Code\naugur shell                     # open a bash shell (for debugging)\naugur setup-token               # get a Claude subscription token (runs in the guest, saves on the host)\naugur down                      # stop the container (kept for a fast, cache-preserving restart)\naugur destroy                   # stop and remove the container entirely (+ its egress network)\naugur status                    # show status, toolchain, and auth info\naugur list                      # list all augur containers across projects, with state + address\naugur build [--swift VERSION]   # build the container image\naugur update [--swift VERSION]  # rebuild image with latest tool versions\naugur init-conf                 # scaffold ./.augur/{allowlist,resources}.conf\naugur version                   # show augur version\n```\n\n| Path | Description | \n|---|---|\n| Current directory | mounted at `/workspace-<project>` (read/write), named after the directory | \n| `~/.claude/projects/-workspace-<project>` | **only this project's** Claude history is shared (read/write) — not the rest of`~/.claude` , so other projects' transcripts and host auth/settings stay invisible | \n| `~/.claude/agents/` | **this project's** user-level custom subagent definitions (`/agents` ) are persisted (read/write), keyed per-project under`~/.augur/claude-agents/<project>` — so they survive`augur down` /`up`**and**` destroy` /recreate. Isolated per project (not the host's global`~/.claude/agents` ), so a guest can't plant a subagent read by another project. Project-level`.claude/agents/` in the repo work too, via the workspace mount. | \n| `~/.augur/claude-profile/` | **opt-in** operator profile, mounted**read-only** — your personal`commands/` ,`skills/` ,`rules/` ,`output-styles/` ,`workflows/` ,`themes/` ,`CLAUDE.md` ,`settings.json` and`keybindings.json` are wired into the guest's`~/.claude/` . Absent or empty (the default) wires nothing. See[Operator profile](#operator-profile) . | \n| `~/.config/gh/` | **not mounted** .`gh` authenticates with the host's token, injected as`GH_TOKEN`**per session** like the Claude auth below | \n| `~/.gitconfig` | mounted read-only | \n| Claude auth | injected via env ( `CLAUDE_CODE_OAUTH_TOKEN` /`ANTHROPIC_API_KEY` )**per session** : only into the`augur claude` /`augur shell` session, never into the container at creation. The values go through an`--env-file` pipe, so they are on no command line, not on disk, and not in the container's saved config (`container inspect` shows none). The host's credential store is never mounted | \n| Everything else | **not visible to the container** — including the rest of`~/.claude` and the host's`~/.claude.json` , which augur never reads, copies, or mounts ([ADR-0013](https://github.com/h1d3mun3/augur/blob/main/docs/decisions/0013-claude-config-inheritance.md) ) | \n\naugur recreates the container for its own reasons — an egress toggle, a memory change,\n`augur build`/` update`/` install-cert`. That discards the writable layer, and your up-arrow\nprompt history (`~/.claude/history.jsonl`) lives there rather than on a mount. So augur keeps a\nsmall, capped snapshot of just that file under `~/.augur/claude-carryover/<project>` (mode `0600`) —\ntaken when you exit `augur claude`/` shell`, on `augur down`, and before `build`/` update` throw the\nlayer away — and restores it into the fresh container.\n\nIt carries **prompt text only**: no credentials, no tool permissions, no trust state. And\n**`augur destroy` deletes it**, so the clean-guest button stays a clean-guest button. Container mode\nonly — the macOS clone already survives `down`.\n\n`augur down` **stops** the container and keeps it (like `augur down --macos` keeps its VM\nclone) so the next `augur up` restarts it fast, preserving the writable layer's caches and tool\nstate that live outside the mounted workspace. **`augur destroy`** removes *this project's*\ncontainer and its egress network when you're done with it (or to force a clean, from-scratch\ncontainer). `augur update`/` augur install-cert` rebuild the image, self-prune the previous\ngeneration (`container image prune`), and remove this project's container so the new image takes\neffect on the next `up` — other projects on the same image tag need their own `augur destroy && augur up`. For anything beyond that — stopped containers from projects you're finished with, or\nreclaiming the shared builder's own resources — use `augur destroy` per project, or Apple\nContainer's own commands directly. `augur list` shows every augur container across projects\n(filtered to the `augur-` prefix, unlike raw `container list`), so you can spot finished ones by\ntheir project slug first:\n\n```\ncontainer prune              # remove stopped containers\ncontainer image prune --all  # remove dangling AND unused tagged base images\ncontainer builder stop       # stop the shared BuildKit builder (see note above), or:\ncontainer builder delete     # delete it outright — also clears its own build cache\n                              # (~/Library/Application Support/com.apple.container/build)\n```\n\n`augur` deliberately doesn't wrap these in a command of its own — they're already one-liners\nin Apple's CLI, and disk cleanup beyond the automatic self-prune is rare enough not to carry\nas a maintained wrapper. `container builder delete` is machine-wide, not scoped to this\nproject: it aborts any build in flight anywhere else on the Mac. See\n`docs/decisions/0005-no-prune-command.md`.\n\n- **Apple Container** (`container` )**1.0.0 or newer** , on macOS 26+. augur refuses`up` /`claude` /`shell` /`setup-token` on an older CLI (`down` ,`destroy` and`list` still work, so\nyou can clean up first).\n- bash\n\nRestrict the container/VM to a set of domains — everything else is blocked. Enforcement runs in a small proxy on the **host, as your user — it never needs `sudo`**. Useful for sandboxing an agent so it can only reach the services it should.\n\n**On by default.** Filtering is always active using a **managed baseline** (`~/.augur/augur.conf.default`, shipped with sensible defaults and refreshed on every install). Add your own always-on domains to `~/.augur/augur.conf`, or per-project ones to a `./.augur/allowlist.conf` in the project root. To disable for one run, pass `--no-egress`.\n\n```\ncd ~/projects/my-app\n\n# Optional: extend the baseline with project-specific domains\naugur init-conf   # scaffolds ./.augur/allowlist.conf\ncat >> .augur/allowlist.conf <<'EOF'\nregistry.example.com\napi.myservice.com\nEOF\n\naugur up            # container, egress on (baseline + augur.conf + allowlist.conf if present)\naugur up --macos    # macOS VM, same\naugur up --no-egress  # disable egress filtering for this run\naugur up --egress     # force egress filtering on (re-enables if AUGUR_EGRESS=0)\naugur status        # shows: Egress on/off + the active allowlist\n```\n\nFiltering is on by default; set `AUGUR_EGRESS=0` to disable it persistently, or `AUGUR_EGRESS=1` to force it on. The `--no-egress` / `--egress` flags override that environment variable for a single run.\n\nOne pattern per line, `#` for comments:\n\n| Pattern | Matches | \n|---|---|\n| `example.com` | that exact host (the apex) only | \n| `*.example.com` | subdomains only ( `api.example.com` , not`example.com` ) | \n| `.example.com` | the apex **and** all subdomains | \n\nThe effective list is three layers merged (union — a layer can only widen, never narrow):\n\n1. **Managed baseline** (`~/.augur/augur.conf.default` ) — shipped defaults for Claude Code / GitHub / Homebrew. augur owns this file and**refreshes it on every install** , so shipped domain updates reach you automatically. Don't edit it; your changes are overwritten.\n2. **Your global additions** (`~/.augur/augur.conf` ) — always-on domains you add.**Never overwritten** by install.\n3. **Project** (`./.augur/allowlist.conf` ) — per-project domains.\n\nThe merge happens on the host, so the guest can't widen its own policy by editing the mounted file. Edits take effect on the next `augur up`.\n\n**Project domains require approval (trust-on-first-use).** Because `./.augur/allowlist.conf` ships inside a repository you may not fully trust, augur shows the domains it adds and asks you to approve them on first sight and again whenever the file changes — the same model as SSH host keys. The approval fingerprint is stored host-side under `~/.augur/project-hashes/` (never in the project tree or a mounted share), so a compromised guest that rewrites `./.augur/allowlist.conf` cannot get the change honored without a fresh host-side approval. `augur status` shows the project's domains and whether they're approved. For non-interactive / disposable runs (CI), set `AUGUR_ACCEPT_PROJECT_CONF=1` to accept automatically; without a TTY and without that variable, an unapproved/changed conf fails closed.\n\n| Mode | Enforcement | \n|---|---|\n| **Container** | The agent runs on a **host-only**`--internal` network (internet severed; the host reachable) with`NET_ADMIN` dropped and`--no-dns` (external DNS fails closed). The**host-side**`augur-proxy` is its only egress, reached via the host-only gateway. A boot self-test fails closed if direct egress is ever reachable.**Trade-off:** host-only also lets the agent reach other host services bound to`0.0.0.0` ; closing it would need host`pf` rules (sudo), which augur avoids. | \n| **macOS VM** | The guest's only NIC is a host-owned socket ( `VZFileHandleNetworkDeviceAttachment` ); a bundled`gvproxy` runs the guest's network on the host and funnels every connection to the proxy. Needs no special entitlement. | \n\nIn every mode the proxy decides by domain (the CONNECT host, or the TLS SNI / HTTP Host) and connects out by name.\n\n`install` builds the proxy (`augur-proxy`, Swift) automatically — both container and macOS VM modes run the native host `augur-proxy`. **macOS VM egress also needs Go** (for `augur-gvproxy`) — `brew install go`, then re-run `bash install`. Without it, `augur up --macos` fails closed while egress filtering is on (the default); pass `--no-egress` to run the VM unfiltered. Container mode does not need Go.\n\nThe host ports the proxy uses are derived per-project so two egress-enabled projects can run at once; override with `AUGUR_PROXY_HTTP_PORT` / `AUGUR_PROXY_SOCKS_PORT` / `AUGUR_SSH_FWD_PORT` if needed.\n\n**Scope.** This guarantees *\"the guest can only reach allowlisted domains.\"* DNS is gated on the same allowlist (a name resolves only if it's connectable), so the guest can't tunnel data out via DNS queries either. It is still **not** exfiltration-proof: an allowlisted, writable host (e.g. `github.com` with your `GH_TOKEN`) and the shared workspace are intentional channels. This is a deliberate, accepted boundary, not a gap augur intends to close — see [`docs/decisions/0008-exfiltration-ceiling-accepted.md`](https://github.com/h1d3mun3/augur/blob/main/docs/decisions/0008-exfiltration-ceiling-accepted.md) for why. See also `augur-proxy/README.md` and `gvproxy/README.md`.\n\nBoth modes size their guest from the same file — `MEMORY=` for container mode, `MACOS_CPU=`/` MACOS_MEMORY_MB=` for macOS VM mode — so one `.augur/resources.conf`, committed to the project, covers whichever mode(s) you use.\n\nApple Container's per-container default memory (~1 GB) is too tight for running an agent, so augur passes `--memory 4g` by default.\n\nTo change the default for a project — and have it apply consistently everywhere you clone or open that project, unlike an environment variable that only exists on the machine where you set it — commit a `.augur/resources.conf` (`augur init-conf` scaffolds one alongside the allowlist):\n\n```\naugur init-conf                   # or by hand: mkdir -p .augur\necho \"MEMORY=8g\" > .augur/resources.conf\n```\n\nPrecedence: an `AUGUR_CONTAINER_MEMORY` environment variable (if set) overrides `.augur/resources.conf`, which overrides the built-in `4g` default. `augur status` shows the effective value.\n\nThe macOS VM defaults to 4 vCPU / 8192 MB regardless of host power. Set `MACOS_CPU=`/` MACOS_MEMORY_MB=` in the same `.augur/resources.conf`:\n\n```\naugur init-conf                   # or by hand: mkdir -p .augur\ncat >> .augur/resources.conf <<'EOF'\nMACOS_CPU=8\nMACOS_MEMORY_MB=16384\nEOF\n```\n\nPrecedence: `AUGUR_MACOS_VM_CPU`/` AUGUR_MACOS_VM_MEMORY_MB` environment variables (if set) override `.augur/resources.conf`, which overrides the built-in 4 vCPU / 8192 MB default. Applied on every `augur up --macos` — no destroy/re-clone needed. `augur status --macos` shows the effective values.\n\nLike `.augur/allowlist.conf`, this file is guest-writable (it lives inside the mounted workspace) — but unlike the allowlist, it carries **no approval gate**: a guest requesting more or less memory/CPU for itself isn't a containment breach the way widening egress is, so it just takes effect on the next `augur up`.\n\nSet the key you need. Add to `~/.zshrc`:\n\n```\nexport ANTHROPIC_API_KEY=\"sk-ant-...\"   # for Claude Code\n```\n\nAlternatively, place the key in a file (`~/.anthropic_api_key`).\n\n**Account-based auth** (subscription, no API key needed): augur never mounts the host's\nClaude credential store into the guest — auth is injected via the environment in both\nmodes. The easiest way to get a long-lived subscription token is **`augur setup-token`**,\nwhich runs `claude setup-token` inside the guest (no Claude install on the host) and saves\nthe token for you. Or generate one yourself if you already have Claude Code on the host:\n\n```\nclaude setup-token            # prints a token for CLAUDE_CODE_OAUTH_TOKEN\nexport CLAUDE_CODE_OAUTH_TOKEN=\"...\"   # or save it to ~/.claude_code_oauth_token\n```\n\naugur reads `CLAUDE_CODE_OAUTH_TOKEN` (env or `~/.claude_code_oauth_token`) and injects it\ninto the container/VM. `ANTHROPIC_API_KEY` takes priority if both are set.\n\nIn Container mode, credentials are injected per session: each `augur claude` / `augur shell`\nresolves them afresh, so a rotated key or token takes effect on the next session with no\ncontainer recreate. A session already running keeps the value it started with. `augur setup-token` runs with **no** credentials injected (it exists to mint one), and neither does a\n`container exec` you run by hand outside augur. In macOS VM mode they are written to\n`~/.augur-env` when the VM starts.\n\n**GitHub CLI:**\n\n```\nbrew install gh\ngh auth login\n```\n\n`gh` credentials are shared automatically in both modes: the host's `gh auth token` is injected\nas `GH_TOKEN` (per session in Container mode, like the Claude credentials above), and a guest-only\ngit credential helper makes HTTPS `git push` work off it. Neither mode shares the host's\n`~/.config/gh`: on a macOS host gh keeps the token in the Keychain, so a shared `hosts.yml` would\nonly make `gh auth status` show a second, failed login next to the working `GH_TOKEN` one, and after\n`gh auth login --insecure-storage` it would hand the guest a plaintext token. The trade-off is that\nyour gh config (aliases, `git_protocol`, editor, GHE host entries) does not apply inside the guest.\n\naugur's tests are split by what each layer can prove on a free runner, and by which bugs each layer actually catches.\n\n```\nmake unit           # Swift build/test + shellcheck + version smoke              (CI: macos-26)\nmake offline-tests  # seam + command-construction shell tiers (shimmed engine)   (CI: ubuntu)\nmake container-e2e  # LOCAL egress FAIL-CLOSED proof on Apple Container (macOS 26+)\nmake e2e            # LOCAL pre-release gate: macOS VM boot + xcodebuild test (never in CI)\n```\n\nThe shell test tiers live in `tests/` and run via `tests/run.sh` (see `tests/README.md`);\neach live tier self-skips when its prerequisites are absent, so the same command is safe in\nCI, the Linux dev container, and on a Mac.\n\nCI runs on **free GitHub-hosted runners only**, and **no CI job boots a VZ guest**:\n\n| Job | Runner | What it proves | \n|---|---|---|\n| `build-unit` | `macos-26` | `swift build` /`swift test` the CLIs (`augur-vm` builds,`augur-proxy` builds + tests),`shellcheck` , and a side-effect-free`augur version` smoke. No engine, no VM. | \n| `offline-tests` | `ubuntu-latest` | The seam + command-construction tiers: drive the real `cmd_up` /`cmd_claude` against a`container` shim and assert the built argv is byte-identical to the seam's declaration. No engine/VM needed. | \n\nBoth jobs are **secrets-zero** — the coding agent is never authenticated in CI. So\n`pull_request` runs from forks are safe: there is nothing to exfiltrate.\n\n**Why the live E2Es are *not* in CI.** GitHub's arm64 macOS runners are themselves\nVirtualization.framework guests with **no nested virtualization** (confirmed by GitHub; the\nrequest to enable it was closed as not planned). So anything that boots a VM/microVM — the\nmacOS VM mode or Apple Container mode — **cannot run on any GitHub-hosted runner** (standard\n*or* larger). A bigger runner gives more cores/RAM, not nesting. Those heavy paths — the\nApple Container egress fail-closed proof (`make container-e2e`) and the macOS VM E2E (` make e2e`) — are gated locally instead.\n\nBefore tagging a release, run the macOS-VM E2E on a real Mac:\n\n```\n# from your project directory (boots the VM; verifies mount + testmanagerd + egress fail-closed)\nmake -C /path/to/augur e2e\n# add a real in-VM build:\nAUGUR_E2E_PROJECT=/path/to/app AUGUR_E2E_SCHEME=App make -C /path/to/augur e2e\n```\n\nThis boots the macOS VM, checks the **virtiofs** workspace mount and **testmanagerd**\nreachability, optionally runs **`xcodebuild test`** inside the VM, and re-proves the\n**egress fail-closed** guarantee for the VM datapath (the macOS-VM variant of the\n`container-e2e` assertions). It's local-only for the nested-virtualization reason above, and\nbecause it needs Apple-signed IPSW/XIP that can't live in CI.\n\nThe `make e2e` gate above can't run in CI, so instead of *trusting* a human to remember it,\nthe release is **structurally** blocked until it passes. The *rationale* for these choices\n(branch model, `VERSION`-not-tags, linear history off, accepted admin-bypass) lives in\n[ADR-0009](https://github.com/h1d3mun3/augur/blob/main/docs/decisions/0009-release-gate.md); the operator flow is below. The pieces:\n\n- **`VERSION`** (repo root) is the single source of truth for the version number.`augur version` reads it;**tags are the *output* of a release, never the input.** A checkout\nwhose HEAD is exactly`v<VERSION>` reports the bare number; any other checkout reports`<VERSION>-dev+<sha>` .\n- **Branches** (model B):`main` is the everyday branch (all existing CI runs here). The`release` branch is gate-passage-only and protected — a commit cannot reach it without a\ngreen`e2e/macos-vm` status (on it, or carried over from a merged-in parent — see the note\nbelow the steps).\n- **`scripts/release-gate.sh`** runs` make e2e` on your Mac and posts its result as the`e2e/macos-vm` commit status. The status is issued**only** on exit 0, so it can't be\nfaked or skipped.\n- **`.github/workflows/release.yml`** fires on push to` release` , reads`VERSION` ,**verifies the\n`e2e/macos-vm` status on the tag-target commit** (refusing to tag otherwise), and — if no tag`v<VERSION>` exists yet — creates the annotated tag and a GitHub Release. Bumping nothing, or a\nfollow-up commit, is a safe**no-op** (collision guard). It boots no VM.\n\n**One-time setup (human, admin):**\n\n```\n# 1. Create the gate-passage branch at the current released commit, then protect it.\ngit push origin main:refs/heads/release\ngh api -X PUT repos/h1d3mun3/augur/branches/release/protection --input - <<'JSON'\n{ \"required_status_checks\": { \"strict\": true, \"contexts\": [\"e2e/macos-vm\"] },\n  \"enforce_admins\": true, \"required_pull_request_reviews\": null,\n  \"restrictions\": null, \"required_linear_history\": false,\n  \"allow_force_pushes\": false, \"allow_deletions\": false }\nJSON\n\n# 2. Create a fine-grained PAT scoped to this repo with ONLY \"Commit statuses: write\",\n#    then store it in the login Keychain. Pass -w with NO value so the token is typed at a\n#    hidden prompt (never in shell history or `ps`); -U lets you re-run this to rotate it:\nsecurity add-generic-password -U -a \"$USER\" -s augur-release-gate -w\n```\n\n**Releasing:**\n\n``` php\n# 1. Bump VERSION on main via a normal PR (e.g. 0.10.0 -> 0.10.1), get it merged.\n# 2. On your Mac, check out the EXACT merged commit, then prove the E2E on it — so the status\n#    lands on the commit that will be tagged (not on the pre-merge bump commit):\ngit checkout main && git pull --ff-only\nscripts/release-gate.sh                 # runs `make e2e`; posts e2e/macos-vm=success on green\n# 3. Fast-forward release to that (now-green) commit:\ngit fetch origin && git push origin origin/main:release\n# release.yml then tags v0.10.1 and cuts the GitHub Release. Done.\n```\n\nBranch protection only lets a commit onto `release` if a green `e2e/macos-vm` status is satisfied\nfor it, so **you can't ship something the E2E never ran against.** One subtlety: GitHub carries\nthat satisfaction **through merge commits** — a merge commit whose merged-in parent has the status\nis accepted even though the merge commit itself has none. So if you gate the *pre-merge* bump\ncommit, the tagged merge commit is a *different* SHA — content-identical for a\nclean merge, but not literally the tested one. So **`release.yml` re-checks the `e2e/macos-vm`\nstatus on the tag-target commit itself and refuses to tag otherwise** — you must gate the\n**post-merge `main` tip** (step 2), or the release fails loudly. (`required_linear_history` is\ndeliberately **off**: `main` uses merge commits, which that rule would reject on the fast-forward.)\n\n`git tag vX.Y.Z && git push origin vX.Y.Z` bypasses the gate\ncompletely: `release.yml` only fires on push to `release` (not on tags), and branch\nprotection doesn't cover tag refs — so a manual tag ships **without** the `e2e/macos-vm`\nproof. Worse, it *shadows* the automated path — the next gated release carrying that\n`VERSION` hits the collision guard and no-ops, so that version can never be cut properly.\nA tag is the gate's **output**, never something you create by hand. Always release through\nthe `release` branch (the flow above). (An admin can of course still bypass any protection\ndeliberately; the gate's job is to stop an *accidental* skip, not a conscious override.)\n\n⚠️ Never hand-cut tags.", "url": "https://wpnews.pro/news/show-hn-augur-sandboxed-macos-vms-with-xcode-for-ai-coding-agents", "canonical_source": "https://github.com/h1d3mun3/augur", "published_at": "2026-09-27 12:52:07+00:00", "updated_at": "2026-09-27 13:01:24.710198+00:00", "lang": "en", "topics": ["ai-agents", "developer-tools", "ai-tools", "ai-safety"], "entities": ["Augur", "Claude Code", "Xcode", "Apple", "Virtualization.framework", "h1d3mun3", "xcodebuild", "iOS Simulator"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/show-hn-augur-sandboxed-macos-vms-with-xcode-for-ai-coding-agents", "markdown": "https://wpnews.pro/news/show-hn-augur-sandboxed-macos-vms-with-xcode-for-ai-coding-agents.md", "text": "https://wpnews.pro/news/show-hn-augur-sandboxed-macos-vms-with-xcode-for-ai-coding-agents.txt", "jsonld": "https://wpnews.pro/news/show-hn-augur-sandboxed-macos-vms-with-xcode-for-ai-coding-agents.jsonld"}}