Revises the GitHub policy per the user's intent: rather than a list of
allowed methods/paths, defer to GitHub itself. For allow-listed repos
the request goes out with the real bearer (the user's normal access);
for any other repo or surface, the Authorization header is stripped
and the request reaches GitHub as a third-party / anonymous caller —
GitHub then enforces public-vs-private as it does for any unauth
visitor.
Concretely:
- allow-listed repo READ → my access (private content visible)
- allow-listed repo WRITE → my access (PR creation, push, etc.)
- public non-listed READ → succeeds (third party sees public)
- private non-listed READ → 404 (GitHub hides it from third party)
- any non-listed WRITE → 401 (third party can't write)
**`intercept_hook::github_access(method, path, allowed)`** for
api.github.com:
- `/repos/<o>/<r>/...` → Authenticated if allow-listed, else
Anonymous.
- `/user`, `/user/orgs[/...]` → Authenticated (gh auth status,
gh repo view org/x).
- other `/user/*` (repos, keys, emails, gpg_keys, …) → Anonymous
(third party can't see; GitHub returns 401).
- `/graphql`, `/search/*`, `/rate_limit`, `/meta`, `/markdown`,
`/notifications` → Authenticated (utility, gh-tooling-friendly).
- everything else (/users/<x>, /orgs/<x>, /licenses, …) →
Anonymous (third-party-visible).
- `..` traversal → Deny.
The launcher's `forward_github_api` (reqwest-based) now consults
this and conditionally injects/strips Authorization before
forwarding.
**`intercept_hook::github_smart_decision`** for github.com /
codeload / raw / objects:
- allow-listed repo → Authenticated (empty stdout → proxy
passthroughs verbatim, secret layer substitutes).
- any other repo → Anonymous. Hook now returns the buffered
request with the `Authorization` header line removed, via the
new upstream "passthrough-with-modified-bytes" verdict (see
`vendor/microsandbox` commit). The placeholder isn't in the
forwarded bytes, so the secret-substitution layer is a no-op
and GitHub sees a third-party request.
- `..` traversal → Deny.
- Malformed → 400.
**`strip_authorization_from_request(bytes)`** byte-precise helper
that removes the Authorization header line (case-insensitive)
while preserving the request line, every other header, and the
body verbatim. Correctly handles the LAST header (whose trailing
CRLF lives in the `\r\n\r\n` separator, not inside the header
block).
Test coverage rewritten around the new enums (62 tests pass before
e2e):
- `gh_access_*` (10 tests): allow-listed all-methods, third-party
all-methods, case-insensitivity, user/identity vs PII paths,
utility allow list, public lookup endpoints, traversal, query
string, malformed.
- `smart_*` (7 tests): allow-listed authenticated for clone+push,
any-other-repo anonymous, single-`.git` strip, traversal,
case-insensitivity, malformed, malformed-owner.
- `strip_auth_*` (5 tests): basic auth strip + body preserve,
case-insensitive header name, no-op when no auth present,
malformed input pass-through, request line + Cookie URL with
`:` not confused with header separator.
E2E verified on a real host:
$ gh api repos/octocat/Hello-World # public, not allow-listed
→ "octocat/Hello-World / public" (third-party read works)
$ git ls-remote https://github.com/octocat/Hello-World
→ 7fd1a60b... HEAD (anon clone works)
$ git ls-remote …/octocat/Hello-World.git/info/refs?service=git-receive-pack
→ remote: Not Found (anon push correctly denied
by GitHub)
$ gh api 'repos/wirenboard/.../../octocat/Hello-World'
→ agent-vm 403 traversal (our hook synthesises)
63/63 tests green.
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
Run Claude Code / Codex / OpenCode inside a per-project libkrun microVM, booting in ~2 seconds, with:
- Host OAuth tokens never enter the VM. The TLS-intercept proxy in microsandbox substitutes the real bearer for a placeholder on the way out. OAuth refresh is MITM'd so multi-hour sessions survive token rotation.
- Per-launch GitHub repo allow-list. Auto-detected from
git remote -v; extend with--repo OWNER/NAME.gh pr create,git pushetc. are filtered at the proxy — off-list calls get a 403 before they reach GitHub. - Sandbox is the boundary. Root inside the VM, project bind-mounted
at its host path,
--dangerously-skip-permissionsset by default (the microVM is the only thing actually keeping the agent on rails).
This is the Rust rewrite of the original Bash
wirenboard/agent-vm on
top of microsandbox. Living on rewrite-microsandbox until v1.
Requirements
- Linux with
/dev/kvm(rw) - Docker, for building the base image + running the local registry
- Rust toolchain (rustup stable)
libcap-ng-dev,libdbus-1-dev,pkg-config
~/.microsandbox/{bin/msb, lib/libkrunfw.so.5.x} auto-install on
first launch.
Quick start
git clone -b rewrite-microsandbox https://github.com/wirenboard/agent-vm
cd agent-vm
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
"$BIN" setup # build + push image, build patched msb
cd ~/your-project
"$BIN" claude # or codex / opencode / shell
Subcommands
claude | codex | opencode | shell launch an agent in a per-project sandbox
pull refresh the cached image
setup build base image + patched msb
clipboard {get,put} [--sys] exchange a string with the project sandbox
Each launcher accepts:
| flag | what |
|---|---|
--memory N |
VM memory 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 |
skip gh/git auth injection (still respects --repo) |
--repo OWNER/NAME |
add to the GitHub allow-list (repeatable) |
--mount HOST[:GUEST] |
extra bind mount (subject to libkrun IRQ cap) |
Trailing args go to the agent: agent-vm claude -p "say hi",
agent-vm shell -- -c 'cargo test'.
Credentials
Reads from the host:
~/.claude/.credentials.json(Claude)~/.codex/auth.json(Codex, OpenCode)gh auth token(git/gh)
The guest gets placeholder strings; the proxy substitutes on the wire.
Real tokens live in ${XDG_STATE_HOME}/agent-vm/<hash>.secrets/ (0700)
on the host, outside the bind mount the guest sees. A SHA-256
snapshot of the three credential files is taken at launch and
re-checked on exit; unexpected mutations print a warning.
For Claude/Codex, when the in-VM agent's bearer expires the
hook MITMs the OAuth refresh, runs claude -p/codex exec on the
host to rotate, and feeds the new placeholder back to the guest — no
re-attach required.
Project hook
If the project root contains an executable .agent-vm.runtime.sh,
the launcher sources it inside the guest before exec'ing the agent.
Use for npm install, env exports, dev-server startup. Non-zero
exit aborts the launch.
Troubleshooting
RegisterNetDevice(IrqsExhausted)at boot — libkrun's virtio IRQ pool is saturated by project + state + net + secrets. Drop a--mountor pass--no-git.handshake read id_offset: timed out—free -h; the VM needs more memory than is available. Try--memory 1.- GitHub 403 from the proxy — repo isn't in the allow-list.
Pass
--repo OWNER/NAMEor run from a project with the right remote.
See also
- PLAN.md — phased roadmap, what's done, what's deferred.
- ARCHITECTURE.md — design notes; why things look the way they do.