Find a file
Evgeny Boger 939367f0c5 feat(phase6): allow-listed = my access; else = third-party access
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>
2026-05-24 16:15:15 +03:00
bin fix: address remaining code-review findings (sweep) 2026-05-24 13:40:20 +03:00
crates/agent-vm feat(phase6): allow-listed = my access; else = third-party access 2026-05-24 16:15:15 +03:00
images feat(phase7): --mount, clipboard bridge, ccusage wrapper, Chrome MCP 2026-05-24 06:04:08 +03:00
vendor feat(phase6): allow-listed = my access; else = third-party access 2026-05-24 16:15:15 +03:00
.gitignore Phase 0: scaffold microsandbox-based rewrite 2026-05-17 17:05:07 +03:00
.gitmodules Phase 0: scaffold microsandbox-based rewrite 2026-05-17 17:05:07 +03:00
ARCHITECTURE.md docs(plan): refine v1 scope; add Phase 5/6/7; renumber deferred phases 2026-05-24 04:59:46 +03:00
Cargo.lock feat(phase5): OpenCode auth + host-cred security snapshot 2026-05-24 05:23:28 +03:00
Cargo.toml Phase 0: scaffold microsandbox-based rewrite 2026-05-17 17:05:07 +03:00
LICENSE first commit 2026-01-28 01:15:09 +01:00
PLAN.md docs(plan): record Phase 6 follow-up commits (gh scope, sweep, github.com streaming) 2026-05-24 14:21:20 +03:00
README.md docs(readme): rewrite — tighten, drop status/why, add subcommand table 2026-05-24 14:52:10 +03:00

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 push etc. 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-permissions set 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 --mount or pass --no-git.
  • handshake read id_offset: timed outfree -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/NAME or 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.