Two related fixes from real end-to-end testing of Phase 6: **Fix 1: gh CLI was effectively broken inside the VM.** The original github_path_allowed accepted only /user/* and /repos/<owner>/<repo>/..., which blocked the GraphQL endpoint gh CLI needs for the most common workflows: - `gh pr create` → POST /graphql (rejected with 403) - `gh issue create` → POST /graphql (rejected) - `gh repo view --json` → POST /graphql (rejected) - `gh issue list --search ...` → /search/issues (rejected) - `gh repo view org/x` → /orgs/<org> (rejected) - gh ambient probes → /rate_limit, /meta, /markdown (rejected) Extend the allow-list: - /graphql is allowed unconditionally. Per-repo scoping doesn't reach into GraphQL bodies in v1; this is a documented trade-off. - /search/*, /rate_limit, /meta, /markdown are allowed (read-only utility, no per-repo state exposed). - /orgs/<org> (bare) is allowed for `gh repo view org/...` resolution; /orgs by itself (listing) is denied to avoid leaking org membership. - /user surface narrowed: only /user (auth probe) and /user/orgs are allowed. /user/repos, /user/keys, /user/emails, /user/gpg_keys are now BLOCKED — the previous catch-all leaked the full inventory of private repos and SSH keys / verified emails (see code-review finding #4 from this session). - Reject any path segment equal to ".." anywhere to close the /repos/<allowed>/<repo>/../../<victim> path-traversal that GitHub would otherwise normalise upstream (code-review finding #2). **Fix 2: `fatal: detected dubious ownership in repository`.** The project is bind-mounted into the guest as the host user (UID 1000) but the in-guest agent runs as root (UID 0). git refuses operations on repos whose .git is owned by a different UID. Symptom: every `git status` / `git log` / `git show` etc. inside the guest aborts. write_guest_gh_config now ALWAYS writes a base /root/.gitconfig with `[safe] directory = *` regardless of whether gh auth was captured. The credential helper + url.insteadOf stanzas are only included when has_gh_token=true; the gh hosts.yml is still gated on having a token. Launcher always calls write_guest_gh_config (was gated on gh_token_file before) so safe.directory is in place even with --no-git or no host gh auth. Verified inside the guest: - `git status` returns "On branch master / nothing to commit" — no dubious-ownership abort. - `git log` reads the bind-mounted history. - `gh api graphql -f query="query { viewer { login } }"` reaches GitHub (response is Bad credentials, same nested-host limit as documented for the other providers — not our allow-list). - `gh api /rate_limit` reaches GitHub. - `gh api repos/octocat/Hello-World` still returns the clean 403 naming the allow-listed repos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| bin | ||
| crates/agent-vm | ||
| images | ||
| vendor | ||
| .gitignore | ||
| .gitmodules | ||
| ARCHITECTURE.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| LICENSE | ||
| PLAN.md | ||
| README.md | ||
agent-vm (microsandbox rewrite)
Run AI coding agents (Claude Code, Codex CLI, OpenCode) inside per-project
microVMs that boot in ~2 seconds, with the host's real OAuth tokens never
entering the VM and a per-launch GitHub-repo allow-list for git push / gh pr create.
This is the in-progress rewrite of
wirenboard/agent-vm on top of
microsandbox. The original Bash
implementation continues to live on main; the rewrite lives on
rewrite-microsandbox.
Why
The original agent-vm is mature but heavy: ~30 s cold start, 16 GB disk
template, host-side mitmproxy chain, balloon daemon, custom GitHub App. This
rewrite uses microsandbox's libkrun-backed microVMs and built-in TLS-intercept
- placeholder-substituted secrets. ~2 s launches, ~17 MB runtime, no host proxy, and the "real tokens never enter the VM" guarantee is enforced at the network layer instead of by a Python middlebox.
Status
Phases 0–7 + 9 are done. Each phase is one PR; see PLAN.md for the running roadmap.
- 0–2.x: scaffolding, image, launcher MVP, polish.
- 3–4: host-rooted Claude / Codex credentials with OAuth-refresh support.
- 5: OpenCode auth + host-cred mutation snapshot.
- 6: gh / git credential injection with per-launch repo allow-list at the proxy.
- 7:
--mount, clipboard bridge,ccusagewrapper, Chrome DevTools MCP. - 8: fast-launch via detached mode (deferred — product-shape decision).
- 9: distribution + polish + docs.
Verified end-to-end on Linux/KVM against real Claude (Anthropic) and Codex (ChatGPT OAuth) accounts.
Requirements
- Linux with KVM (
/dev/kvmreadable/writable) — primary target. macOS-VZ works at the microsandbox level but isn't exercised here. - Docker, for building the base OCI image and running the local registry.
- A Rust toolchain (rustup stable) to build agent-vm + the patched
msb. libcap-ng-devandlibdbus-1-devon the build host (transitive deps of the microsandbox crate).
The microsandbox runtime libs (~/.microsandbox/{bin/msb, lib/libkrunfw.so.5.x}) are auto-installed on first launch — you don't
need to download them by hand.
Build
git clone https://github.com/wirenboard/agent-vm
cd agent-vm
git checkout rewrite-microsandbox
git submodule update --init vendor/microsandbox
sudo apt-get install -y libcap-ng-dev libdbus-1-dev pkg-config
cargo build --release -p agent-vm
BIN=$(pwd)/target/release/agent-vm
agent-vm setup builds the base image (localhost:5000/agent-vm:latest),
the patched msb from vendor/microsandbox, and verifies the image by
booting a throwaway sandbox:
"$BIN" setup
Use
cd ~/your-project
"$BIN" claude # Claude Code TUI
"$BIN" codex # Codex CLI TUI
"$BIN" opencode # OpenCode TUI
"$BIN" shell # bash inside the sandbox
# One-shots (no TTY, output streamed live):
"$BIN" claude -p "fix the lint errors"
"$BIN" codex exec --skip-git-repo-check "Reply OK"
"$BIN" shell -- -c 'cargo test'
All four subcommands accept:
--memory N— VM memory in GiB (default 2)--cpus N— vCPUs (default 2)--image REF— override the OCI image--no-update-check— skip the registry HEAD on launch--no-git— opt out of gh / git injection--repo OWNER/NAME— add to the GitHub allow-list (repeatable)--mount HOST[:GUEST]— extra bind mount (subject to libkrun IRQ cap)
Plus:
"$BIN" pull— fetch the latest image into the microsandbox cache."$BIN" clipboard {get,put} [--sys]— exchange a string with the per-project sandbox via<state>/clipboard.txt(mounted at/agent-vm-state/clipboard.txtin the guest).--sysalso pulls from / pushes to the host system clipboard.bin/agent-vm-ccusage— wrapper that unions~/.claudeand every per-project session dir before runningnpx ccusage@latest.
Credentials
- Claude: reads
~/.claude/.credentials.json. Sends a placeholder bearer from the guest; the proxy swaps to the real token on outbound api.anthropic.com traffic. OAuth refresh is MITM'd into a host-sideclaude -pinvocation so long sessions survive token rotation. - Codex: reads
~/.codex/auth.json. Same model, with OpenAI'sid_tokenJWT replaced with an alg-none placeholder so no PII enters the guest. - OpenCode: synthesizes an OpenCode-shaped
auth.jsonfrom the host Codex credentials (same OpenAI account); a synthetic placeholder JWT goes intotokens.openai.accessand is substituted on the wire. - gh / git: if
gh auth statusshows you're logged in, the host token is injected into the guest's~/.gitconfigand~/.config/gh/hosts.ymlas a placeholder. The proxy substitutes for the real bearer on outbound traffic to GitHub.- Per-launch repo allow-list.
git remote -vof the cwd gives the initial set;--repo OWNER/NAMEwidens it; api.github.com requests outside the list get a synthesized 403 from the proxy hook.
- Per-launch repo allow-list.
Real tokens live in ${XDG_STATE_HOME}/agent-vm/<hash>.secrets/ (mode 0700)
on the host, outside the bind mount the guest sees. A SHA-256 snapshot
of ~/.claude/.credentials.json, ~/.codex/auth.json, and
~/.local/share/opencode/auth.json is taken at launch and re-checked on
exit; any mutation outside the Phase 4 refresh path prints a warning.
Project hook
If the project root contains an executable .agent-vm.runtime.sh, the
launcher sources it inside the guest at the project's bind path before
exec'ing the agent. Use it for npm install, env exports, dev-server
startup, etc.
# .agent-vm.runtime.sh
set -e
npm install --silent
export FOO=bar
Troubleshooting
RegisterNetDevice(IrqsExhausted)on boot: libkrun's IRQ pool is tight. Drop a--mountor pass--no-git.runtime error: handshake read id_offset: timed out: checkfree -h— boot needs more memory than the host has free. Try--memory 1.- Codex hangs at "Reading additional input from stdin...": kill,
re-run. The launcher forces stdin to
/dev/nullin non-TTY mode; if you piped real input, codex consumed it before printing the banner. gh apireturns "Bad credentials" but the same call works on host: you're in a doubly-nested setup where an outer agent-vm bridge replaced your host token with its own placeholder. The inner substitution is working; the outer bridge isn't re-substituting on the nested VM's egress. Run from a non-nested host.
Docs
- PLAN.md — phased roadmap with what landed when and what's still open.
- ARCHITECTURE.md — per-phase design notes; the source of truth for why something looks the way it does.