https://github.com/h1d3mun3/augur
Run Claude Code in an isolated Docker container or macOS VM
https://github.com/h1d3mun3/augur
claude-code cli codex docker gemini-cli macos-vm sandbox
Last synced: 1 day ago
JSON representation
Run Claude Code in an isolated Docker container or macOS VM
- Host: GitHub
- URL: https://github.com/h1d3mun3/augur
- Owner: h1d3mun3
- License: mit
- Created: 2026-03-24T10:15:05.000Z (4 months ago)
- Default Branch: main
- Last Pushed: 2026-07-08T23:52:51.000Z (3 days ago)
- Last Synced: 2026-07-09T01:18:22.134Z (3 days ago)
- Topics: claude-code, cli, codex, docker, gemini-cli, macos-vm, sandbox
- Language: Shell
- Size: 1.46 MB
- Stars: 2
- Watchers: 0
- Forks: 0
- Open Issues: 6
-
Metadata Files:
- Readme: README.md
- License: LICENSE
- Security: docs/security-reviews/2026-06-28-full-review.md
Awesome Lists containing this project
README
Run Claude Code in an isolated environment.
Works in any directory — only the current directory is exposed to the container or VM.
Two modes are available:
| | Container mode | macOS VM mode |
|---|---|---|
| **Isolation** | Linux container | macOS VM (Apple Virtualization Framework) |
| **Xcode / xcodebuild** | ✗ | ✓ |
| **iOS Simulator** | ✗ | ✓ |
| **Setup time** | ~5 min (image build) | ~75 min (VM build) |
| **Disk usage** | ~2 GB | ~70 GB+ |
| **Requires** | Apple Container (macOS 26+) | augur-vm (bundled), IPSW, Xcode XIP |
---
## Container mode (default)
Lightweight Linux container. Suitable for most projects that don't need Xcode.
The 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.
### Setup
```bash
# 1. Run the install script
bash install
# 2. Reload shell config
source ~/.zshrc # or source ~/.bashrc
# 3. Build the image
augur build
```
The install script copies `Dockerfile` and `augur` to `~/.augur/` and configures `PATH`. Safe to re-run.
> **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.
### Usage
```bash
cd ~/projects/my-app
augur up [--swift VERSION] # start the container
augur claude # launch Claude Code
augur shell # open a bash shell (for debugging)
augur setup-token # get a Claude subscription token (runs in the guest, saves on the host)
augur down # stop and remove the container
augur status # show status, toolchain, and auth info
augur build [--swift VERSION] # build the container image
augur update [--swift VERSION] # rebuild image with latest tool versions
augur init-conf # scaffold ./.augur/{allowlist,resources}.conf
augur version # show augur version
```
### File access
| Path | Description |
|------|-------------|
| Current directory | mounted at `/workspace-` (read/write), named after the directory |
| `~/.claude/projects/-workspace-` | **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 |
| `~/.config/gh/` | mounted **read-only** (the container can read but not rewrite it; the token is injected via `GH_TOKEN`) |
| `~/.gitconfig` | mounted read-only |
| Claude auth | injected via env (`CLAUDE_CODE_OAUTH_TOKEN` / `ANTHROPIC_API_KEY`) — the host's credential store is never mounted |
| Everything else | **not visible to the container** |
> Auth is env-based in both modes now. If you only ever logged in via the browser, run `augur setup-token` (or set `ANTHROPIC_API_KEY`); see [API keys and authentication](#api-keys-and-authentication). Upgrading from an older augur requires a one-time `augur build` (the image now pre-creates the scoped history dir).
> Claude Code's `--worktree` isn't specially supported (`augur claude --worktree ...` won't forward the flag). If you want it anyway: run `augur shell`, then type `claude --worktree ` yourself at the prompt — the worktree's files/git state persist fine, but its conversation history does not survive `augur down && up` (only the main checkout's project leaf is persisted). See `docs/claude-worktree-support-design.md` for the full trade-offs.
### Requirements
- **Apple Container** (`container`) on macOS 26+
- bash
---
## macOS VM mode (`--macos`)
Full 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.
### Setup
#### 1. Build the VM backend
augur ships its own VM backend (`augur-vm`), a small Swift CLI built directly on
Apple's Virtualization.framework — no third-party tools required.
```bash
# on the macOS host (needs the Xcode / Swift toolchain)
bash install # builds & installs augur-vm into ~/.augur
```
#### 2. Download Apple-signed assets
- **IPSW** — macOS restore image: System Preferences > Software Update
- **Xcode XIP** — Xcode installer: https://developer.apple.com/download/all/
#### 3. Build the base VM (one-time, ~75 min)
```bash
augur build --macos --ipsw ~/Downloads/macOS.ipsw --xcode-xip ~/Downloads/Xcode.xip
```
This will:
1. Create a macOS VM from your IPSW (`augur-vm create --from-ipsw`)
2. Open the VM window for manual Setup Assistant completion (credentials: `admin` / `admin`, Remote Login enabled)
3. Install Xcode, Homebrew, GitHub CLI, and Claude Code
4. Download the iOS Simulator runtime (Xcode installed from a XIP does not bundle it)
5. Save the result as a reusable base VM (`augur-macos-base`)
By default only the **iOS** Simulator runtime is baked in. Use `--platforms` to bake in others —
baking them into the base VM means every project clone gets them without re-downloading:
```bash
# iOS + watchOS
augur build --macos --ipsw ... --xcode-xip ... --platforms iOS,watchOS
# every platform Xcode offers (watchOS, tvOS, visionOS, …) — many extra GB
augur build --macos --ipsw ... --xcode-xip ... --platforms all
```
> **Supply chain note:** The base VM is built entirely from Apple-signed assets (IPSW + Xcode XIP).
> No third-party automation scripts are used.
### Usage
```bash
cd ~/projects/my-app
augur up --macos # clone base VM and start (first run clones automatically)
augur up --macos --gui # same, but also open a VM window (display + keyboard + pointer)
augur claude --macos # launch Claude Code (starts VM if not running)
augur shell --macos # open a bash shell (starts VM if not running)
augur setup-token --macos # get a Claude subscription token (runs in the VM, saves on the host)
augur down --macos # stop the VM (keeps the clone — next up is fast)
augur destroy --macos # stop and remove the project VM clone
augur status --macos # show VM status, toolchain, and auth info
augur list --macos # list all VMs and their state
augur update --macos # update Claude Code in the base VM
augur version --macos # show augur version (macOS mode)
```
### Authentication
On macOS, Claude Code stores its OAuth login in the Keychain, which is unreadable over SSH and
absent from a freshly cloned VM. So macOS mode injects a credential through the environment on
every `up` (the same way it does for the GitHub token):
- `CLAUDE_CODE_OAUTH_TOKEN` — subscription token; either set the env var / save it to
`~/.claude_code_oauth_token`, or run **`augur setup-token`**: it runs `claude setup-token`
inside the guest (so you don't install Claude Code on the host), then you paste the token
back once and augur saves it to `~/.claude_code_oauth_token`.
- `ANTHROPIC_API_KEY` — Console API key (env or `~/.anthropic_api_key`). Takes priority if both are set.
### File access
| Path | Description |
|------|-------------|
| Current directory | exposed at `~/workspace-` in the VM (read/write, virtiofs auto-mount) |
| `~/.gitconfig` | copied on VM start (HTTPS `git push` uses `GH_TOKEN`) |
| `~/.config/gh/` | shared **read-only** (token also injected via `GH_TOKEN`) |
| Claude history | **only this project's** history is shared, in a per-VM isolated dir (`~/.augur/claude-projects/`), so other projects' transcripts stay invisible. Cross-mode (container↔macOS) resume is no longer shared. |
| Claude auth | **not** shared — injected via env (the macOS Keychain is unreadable over SSH; see above) |
| Everything else | **not visible to the VM** |
> The macOS guest auto-mounts the shared directory under `/Volumes/My Shared Files/workspace-`; augur
> symlinks it to `~/workspace-`. The sealed system volume can't host a symlink at `/workspace`, so the
> per-project `~/workspace-` path is used in the VM (container mode uses `/workspace-`).
> Same caveat as container mode for Claude Code's `--worktree`: not specially supported, but `augur shell --macos` +
> manually running `claude --worktree ` works today. Unlike container mode, its conversation history *does*
> survive `augur down --macos`/`up --macos` (the VM's disk is stopped, not destroyed, until `augur destroy --macos`).
> See `docs/claude-worktree-support-design.md`.
### Running `xcodebuild test`
`xcodebuild test` needs an Aqua (GUI) login session to reach `testmanagerd`; a headless SSH login
has none. Two things make that session exist at boot: the base VM is built with **auto-login**
enabled, and the VM is always run with a **virtual display device** (macOS only starts an Aqua
session when a framebuffer exists — `--no-graphics` suppresses only the host-side window, not the
display device). With both in place, `xcodebuild test` works over SSH for all test types. Recommended
invocation (SwiftData's `@Model` macro needs `-skipMacroValidation` in a headless VM):
```bash
NSUnbufferedIO=YES xcodebuild test \
-scheme \
-destination 'platform=macOS,arch=arm64' \
-parallel-testing-enabled NO \
-skipMacroValidation \
-derivedDataPath ~/DerivedData \
CODE_SIGNING_ALLOWED=NO
```
If builds are flaky from the shared mount (virtiofs is not tuned for heavy I/O — occasional
"project is damaged" errors), copy the project to local disk first: `rsync -a ~/workspace-/ ~/Developer//`.
### Requirements
- macOS (Apple Silicon)
- Xcode / Swift toolchain (to build the bundled `augur-vm` backend via `bash install`)
- macOS IPSW (Apple-signed)
- Xcode XIP (Apple-signed, from developer.apple.com)
---
## Egress allowlist (`.augur/allowlist.conf`)
Restrict 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.
**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`.
```bash
cd ~/projects/my-app
# Optional: extend the baseline with project-specific domains
augur init-conf # scaffolds ./.augur/allowlist.conf
cat >> .augur/allowlist.conf <<'EOF'
registry.example.com
api.myservice.com
EOF
augur up # container, egress on (baseline + augur.conf + allowlist.conf if present)
augur up --macos # macOS VM, same
augur up --no-egress # disable egress filtering for this run
augur up --egress # force egress filtering on (re-enables if AUGUR_EGRESS=0)
augur status # shows: Egress on/off + the active allowlist
```
Filtering 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.
### `.augur/allowlist.conf` format
One pattern per line, `#` for comments:
| Pattern | Matches |
|---|---|
| `example.com` | that exact host (the apex) only |
| `*.example.com` | subdomains only (`api.example.com`, not `example.com`) |
| `.example.com` | the apex **and** all subdomains |
The effective list is three layers merged (union — a layer can only widen, never narrow):
1. **Managed baseline** (`~/.augur/augur.conf.default`) — shipped defaults for Claude Code / GitHub / npm / 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.
2. **Your global additions** (`~/.augur/augur.conf`) — always-on domains you add. **Never overwritten** by install.
3. **Project** (`./.augur/allowlist.conf`) — per-project domains.
The 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`.
**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.
### How it works
| Mode | Enforcement |
|------|-------------|
| **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. |
| **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. |
In every mode the proxy decides by domain (the CONNECT host, or the TLS SNI / HTTP Host) and connects out by name.
### Requirements
`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, macOS VM egress is unavailable but everything else works.
The 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.
> **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. See `augur-proxy/README.md` and `gvproxy/README.md`.
---
## Container memory (`.augur/resources.conf`)
Apple Container's per-container default memory (~1 GB) is too tight for running an agent, so augur passes `--memory 4g` by default.
To 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):
```bash
augur init-conf # or by hand: mkdir -p .augur
echo "MEMORY=8g" > .augur/resources.conf
```
Precedence: 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. Like `.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 for itself isn't a containment breach the way widening egress is, so it just takes effect on the next `augur up`.
---
## API keys and authentication
Set the key you need. Add to `~/.zshrc`:
```bash
export ANTHROPIC_API_KEY="sk-ant-..." # for Claude Code
```
Alternatively, place the key in a file (`~/.anthropic_api_key`).
**Account-based auth** (subscription, no API key needed): augur never mounts the host's
Claude credential store into the guest — auth is injected via the environment in both
modes. The easiest way to get a long-lived subscription token is **`augur setup-token`**,
which runs `claude setup-token` inside the guest (no Claude install on the host) and saves
the token for you. Or generate one yourself if you already have Claude Code on the host:
```bash
claude setup-token # prints a token for CLAUDE_CODE_OAUTH_TOKEN
export CLAUDE_CODE_OAUTH_TOKEN="..." # or save it to ~/.claude_code_oauth_token
```
augur reads `CLAUDE_CODE_OAUTH_TOKEN` (env or `~/.claude_code_oauth_token`) and injects it
into the container/VM. `ANTHROPIC_API_KEY` takes priority if both are set.
**GitHub CLI:**
```bash
brew install gh
gh auth login
```
`gh` credentials are shared automatically in both modes (token injected via `GH_TOKEN`;
the host's `~/.config/gh` is mounted read-only so the guest can't rewrite it).
---
## Testing & CI
augur's tests are split by what each layer can prove on a free runner, and by which bugs
each layer actually catches.
```bash
make unit # Swift build/test + shellcheck + version smoke (CI: macos-26)
make offline-tests # seam + command-construction shell tiers (shimmed engine) (CI: ubuntu)
make container-e2e # LOCAL egress FAIL-CLOSED proof on Apple Container (macOS 26+)
make e2e # LOCAL pre-release gate: macOS VM boot + xcodebuild test (never in CI)
```
The shell test tiers live in `tests/` and run via `tests/run.sh` (see `tests/README.md`);
each live tier self-skips when its prerequisites are absent, so the same command is safe in
CI, the Linux dev container, and on a Mac.
### Continuous integration (`.github/workflows/ci.yml`)
CI runs on **free GitHub-hosted runners only**, and **no CI job boots a VZ guest**:
| Job | Runner | What it proves |
|-----|--------|----------------|
| `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. |
| `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. |
Both jobs are **secrets-zero** — the coding agent is never authenticated in CI. So
`pull_request` runs from forks are safe: there is nothing to exfiltrate.
**Why the live E2Es are *not* in CI.** GitHub's arm64 macOS runners are themselves
Virtualization.framework guests with **no nested virtualization** (confirmed by GitHub; the
request to enable it was closed as not planned). So anything that boots a VM/microVM — the
macOS VM mode or Apple Container mode — **cannot run on any GitHub-hosted runner** (standard
*or* larger). A bigger runner gives more cores/RAM, not nesting. Those heavy paths — the
Apple Container egress fail-closed proof (`make container-e2e`) and the macOS VM E2E (`make
e2e`) — are gated locally instead.
### Pre-release gate (`make e2e`, local only)
Before tagging a release, run the macOS-VM E2E on a real Mac:
```bash
# from your project directory (boots the VM; verifies mount + testmanagerd + egress fail-closed)
make -C /path/to/augur e2e
# add a real in-VM build:
AUGUR_E2E_PROJECT=/path/to/app AUGUR_E2E_SCHEME=App make -C /path/to/augur e2e
```
This boots the macOS VM, checks the **virtiofs** workspace mount and **testmanagerd**
reachability, optionally runs **`xcodebuild test`** inside the VM, and re-proves the
**egress fail-closed** guarantee for the VM datapath (the macOS-VM variant of the
`container-e2e` assertions). It's local-only for the nested-virtualization reason above, and
because it needs Apple-signed IPSW/XIP that can't live in CI.