Agent Sandbox

September 1, 2026 · View on GitHub

Agent Sandbox

Sandbox as a service for coding agents — hosted with a free trial, or self-host for free.

Delegate the task. Keep the control.

Hand a coding task to an autonomous agent running inside a throwaway microsandbox microVM. Watch it work live, answer the one question it stops to ask, and get a pull request back.

Start a run from the dashboard, from your coding agent, or from a script — same sandbox, same fleet, same live view. Sign up on a hosted controller, or self-host in five minutes; either way, your machines, GitHub accounts and MCP servers are yours alone.

CI License: Apache 2.0 Node TypeScript MCP Isolation Self-hosted

Sign up / live console · Self-host · Status · Security model · Architecture · Lifecycle

Works with Cursor · Claude Code · Codex · VS Code · Windsurf · Zed · Cline · Claude web · CI
— anything that speaks MCP over stdio or Streamable HTTP. And nothing at all, if you use the dashboard.


Why this exists

Handing a task to an autonomous agent usually means one of two bad trades: run it on your own machine and hope it doesn't rm -rf something, or run it in a container and pretend a shared kernel is a boundary. Agent Sandbox takes the third option — one hardware-isolated microVM per run, on hardware you own — and then solves the parts that make running agents unattended actually usable:

ProblemWhat Agent Sandbox does
"Is this safe to run unattended?"Every run is its own KVM guest kernel. A fork bomb or rm -rf / ends at the VM boundary.
"It went quiet for 10 minutes."NDJSON tool events stream to a live log — watch, the fleet monitor, and SSE in the console.
"It guessed instead of asking."The agent writes a question and a PreToolUse hook denies every further tool call until you answer. It cannot barrel on.
"What is it doing right now?"ask runs a second, read-only agent in the same box that reads the diff and log — the driver is never paused.
"I don't want to paste tokens everywhere."GitHub access is resolved per repo from a login-keyed store; on-demand secrets are injected for one step and never stored.
"A prompt injection could exfiltrate my keys."A deterministic guard hook denies control-plane edits, credential exfiltration and runtime reconfiguration — no model in the loop.
"I'm away from my desk and it's blocked."The dashboard is a full control plane, not a log viewer: start runs, answer questions, queue follow-ups, tear boxes down — from a phone.

Three ways to start a run

The sandbox is the product; how you reach it is your choice. All three paths hit the same orchestrator, produce the same box, and show up in the same fleet view.

Start itBest for
🖥️ DashboardOpen the console, pick repos, type the task, hit send. Nothing installed.Kicking off work from anywhere — including a phone — and answering a box that's blocked.
🧩 Your coding agentdelegate this: <task> in Cursor, Claude Code, Codex, VS Code, Windsurf, Zed…Handing off what you're already looking at — the local entry ships your uncommitted working tree.
⚙️ A script / CIPOST /delegate.json (or /mcp) with a bearer token.Automation: nightly chores, an issue-triage bot, a pipeline step.

Once a run exists, every surface can drive it: watch the live log, ask a read-only co-pilot what it's doing, queue a follow-up, answer its question, pin the box, or tear it down.

Quickstart

1. Dashboard — no client, no config

Hosted: open the controller's landing page → Get started → name, username, password → you are on the dashboard. Add a GitHub account under Integrations (yours alone), type a task, send.

Self-hosted, single operator: deploy (below), open https://<ASB_DOMAIN>/dashboard, paste your MCP_HTTP_TOKEN once. To let several people in, see Multi-user mode.

The composer picks up the repos you name — "fix the flaky retry test in packages/queue" attaches the repo it refers to automatically, so the agent starts with the checkout it was clearly asked about instead of hunting for it. You can also pick repos explicitly, or run with no repo at all.

2. Your coding agent — delegate what you're looking at
git clone https://github.com/HatriGt/agent-sandbox.git
cd agent-sandbox
npm ci && npm run build
cp .env.example .env       # set VPS_SSH to a host that has microsandbox installed

Hosted: in the dashboard go to Account → Connect an IDE. It mints a personal API key and shows the exact config for Claude Code, Cursor, VS Code and Windsurf with the key filled in, plus a Test connection button. That is all you need — skip the rest of this step.

Local stdio entry (self-hosters who want to ship an uncommitted working tree): register it with your IDE's MCP config (~/.cursor/mcp.json, .vscode/mcp.json, Windsurf's mcp_config.json, claude mcp add — see Connect your IDE):

{ "mcpServers": { "agent-sandbox": {
  "type": "stdio",
  "command": "node",
  "args": ["/absolute/path/agent-sandbox/dist/index.js"],
  "env": { "WORKSPACE_DIR": "${workspaceFolder}" }
} } }

Now say "delegate this: add tests for the parser" in your agent chat. The local entry rsyncs your working tree — uncommitted changes included — so you can hand off work that isn't pushed anywhere.

3. A script — one authenticated POST
curl -X POST https://<ASB_DOMAIN>/delegate.json \
  -H "Authorization: Bearer $MCP_HTTP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task":"bump deps and open a PR","repos":[{"repo":"owner/name"}]}'

Every console action has a route behind it (/fleet.json, /watch.sse, /ask.json, /resume.json, /teardown.json), so anything the dashboard can do, a script can do.

Important

The HTTP entry refuses to start without MCP_HTTP_TOKEN; that token is the deployment's root identity. In multi-user mode people sign in with their own accounts and personal API keys, and the operator token stays in .env as break-glass. Read docs/security.md before exposing a controller to the internet.

Contents


Runtime: microsandbox (msb, libkrun/KVM microVMs). Orchestration: a small TypeScript MCP server with two entry points that share the same tools:

EntryTransportUse it forClient
dist/index.jsstdio (your IDE spawns it)"delegate THIS" — your local working tree incl. uncommitted changesAny MCP client on your machine — Cursor, VS Code, Windsurf, Zed, Claude Code, Cline…
dist/http.jsStreamable HTTP + bearer token"delegate a git repo/branch" from anywhere — and it serves the dashboardThe web console, any remote MCP client, a phone, CI

Why microsandbox

We benchmarked microsandbox, OpenSandbox, SmolVM, and CubeSandbox on the target VPS (see docs/eval-summary.md). microsandbox won for this use case: real microVM isolation (hardware boundary), sub-second boot, right-sized RAM, and — critically for v0.6.9 — a self-contained CLI with the whole delegate wishlist built in:

Needmicrosandbox v0.6.9 feature
Ship repo + local changes--copy-dir SRC:DST, -v/--volume, msb cp
Inject git/npm creds-e KEY=val, --secret-conf (keyring/env secrets)
Egress control (creds-proxy-grade)--net public/private/host, --no-net, --net-rule allow@<target>, DNS controls
Run Claude Code + MCPany OCI image; Claude Code CLI + MCP servers run in-box
Use ccproxy for the model-e ANTHROPIC_BASE_URL=https://your-ccproxy.example.com (verified)
Auto-teardown--idle-timeout, --max-duration
Resume / continuemsb exec, msb ssh into a running box
Checkpoints / fast restoremsb snapshot, msb run --from-snapshot
Status / preview-p HOST:GUEST port forward

The egress/secret features mean we get the credential-injection-proxy pattern (that Cleanroom and iron-proxy sell as separate products) natively.

Architecture

   Dashboard        Your IDE         Any MCP          Script / CI
  (HTTP+token)    (stdio, MCP)    client (HTTP)      (HTTP+token)
        │                │                │                │
        │        local working tree       │ git clone       │
        │        (rsync, uncommitted)     │                 │
        └────────────────┴────────┬───────┴─────────────────┘

                    handlers (shared)  →  msb on the VPS host

                   microsandbox microVM (libkrun/KVM)
                     • repo copied in (local tree OR fresh clone)
                     • git/npm creds injected (short-lived)
                     • Claude Code runs the task → commit/PR
                     • model calls → ccproxy

          live log · fleet view · ask · resume · follow-ups · teardown

Both entries register identical tools (src/handlers.ts) backed by the same side-effecting deps (src/deps.ts), and the dashboard's JSON routes call those same handlers — so a run started in the browser and a run started from your IDE are the same object, visible and drivable from either.

Tools

delegate · status · resume · teardown · pool_status · monitor · watch · ask · gh_token_add. delegate takes source (local ships your working tree; git clones owner/repo@ref on the VPS), task, optional ref, optional allowDomains, and repos:[{repo,ref?}] for a cross-repo task (each lands in /workspace/<name> in one box). Missing required info is asked back, not failed.

Fleet monitor. monitor (no args; or npm run monitor) shows the whole fleet in one call — how many sandboxes are up and what each is doing: role (session / claimed-pool / free-pool), run state (running / waiting-for-answer / done / idle), the task text, uptime, and CPU/MEM. Only running boxes count as "up"; auto-stopped boxes are noted separately. status is one session; monitor is all of them at a glance.

Watch one box live. watch(session) returns a rich snapshot of a single box — run state, task, resources, and a tail of the agent's log (what it's doing right now). For a terminal live-stream that redraws every ~2s, run npm run watch -- <session> on the VPS.

Ask a running box — without stopping it. ask(session, question) runs a second, read-only co-pilot agent inside the same box and answers you from what it reads: the workspace, git diff, and the driver's live log. The driver agent keeps working, is never paused, and never sees the exchange — so you can ask "what has it changed so far?", "why is it stuck?", "is it about to do something dumb?" mid-run. watch gives you the raw log; ask gives you the log interpreted.

Three things keep the lanes apart (see src/ask.ts):

  • Separate session bucket. Claude Code keys resumable sessions by cwd, so the ask lane is rooted at /ask. If it shared the repo workdir, an ask turn would become the most recent session there and the next resume would continue the co-pilot's conversation instead of the driver's.
  • No shared state. Ask artifacts live under /ask, outside /workspace — the repo tree stays clean, monitor/watch never show ask chatter, and nothing lands in a PR.
  • Read-only, enforced. A second PreToolUse hook (ask-ro.js) denies the edit tools and mutating shell in this lane. Both in-box hooks self-select on the ASK_LANE env flag: the driver's ask-gate skips when it's set (so a pending question doesn't freeze the co-pilot — exactly when you most want to ask what it's stuck on), and the read-only gate skips when it isn't. The gate is generated from the same predicate the tests exercise, and the shipped file is run under node in the suite.

ask can only look, never steer: to change what the driver does, answer its question with resume. Follow-ups continue the same co-pilot thread unless you pass newThread:true. One turn is capped by ASK_TIMEOUT_MS (default 45s, under the client's request timeout); ASK_MODEL points the co-pilot at a cheaper/faster alias than the driver's (the co-pilot mostly reads a log and summarizes — it does not need the driver's model).

Three surfaces, one lane: the ask MCP tool, an Ask panel in each dashboard row (under that box's terminal — POSTs to /ask.json), and a CLI. With no question the CLI reads them from stdin, one per line, so you can hold a conversation with the co-pilot while the driver works:

# in the deployed setup the built dist lives in the container, not on the host
ssh <vps> "docker exec agent-sandbox npm run ask -- <session> 'what has it changed so far?'"
ssh -t <vps> "docker exec -i agent-sandbox npm run ask -- <session>"   # interactive, stdin

Web dashboard — a full control plane, not a log viewer. The HTTP entry serves the console at /dashboard (and a public landing page at /). It is a first-class way to use the product, not just to observe it: start a run (repo picker, or let the composer infer the repo from the task text), stream each box live over SSE, ask the read-only co-pilot, queue follow-ups, answer a blocked agent, pin a box so it is never reaped, browse and download the files a run produced, and manage GitHub accounts and MCP servers under Integrations. It works on a phone, which is the point — a run you started this morning can be unblocked from wherever you are.

Open https://<ASB_DOMAIN>/dashboard, paste MCP_HTTP_TOKEN once into the token gate; the browser keeps it and sends it as a bearer header on every call (?token= is not accepted). See docs/security.md and web/DESIGN.md.

Reactive GitHub auth — no default account. There is no baked GitHub token or git identity. Access is resolved per repo from a login-keyed store on the VPS: on the first delegation to a repo no stored account can reach, delegate asks for a githubToken; it's probed (login + orgs + access), stored, and reused. Each repo's commit identity + push token + gh come from the account that has access to that repo (works for local too — owner read from the origin remote). status shows run:waiting when the agent asks a question; you answer with resume.

On-demand secrets (ask-then-resume). If a task needs a credential it doesn't have (a token for a private repo it can't reach, a DB URL, an API key), the agent is instructed to stop and name the exact env var instead of failing or faking. You then call resume with secrets, injected as env for that step only — never stored, gone on teardown:

resume({
  session: "box-abc",
  message: "Use the token I provided to clone and continue.",
  secrets: { "GITHUB_TOKEN": "ghp_…", "DB_URL": "postgres://…" }
})

How delegation flows (A2A)

delegate is interactive and blocking: one call ships the repo, launches the agent, and waits — it does not fire-and-forget. It returns only at a real boundary: the run finished (run:done), the box paused to ask a question (run:waiting), or the wait cap elapsed with the box still working (run:running — an explicit "reconnect via status"). The wait window is bounded (WAIT_TIMEOUT_MS, default 50s) so the call always returns under the MCP client's request timeout instead of hanging to a -32001.

delegate ──► launch, then wait to a boundary:
   ┌────────────────────────────────────────────────────────────────────┐
   │  waitForBoundary (≤ WAIT_TIMEOUT_MS)                                 │
   │    ├─ done              → return result ────────────────────────────┼──► report PR/result to user
   │    ├─ waiting(question) → return the QUESTION as text ──────────────┼──► calling agent answers it
   │    │                       (from context) OR asks the user via its   │     (from context) or asks
   │    │                       OWN native question UI, then resume(...)  │     the user, then resume()
   │    └─ still running     → return "reconnect via status" ────────────┼──► status() to keep waiting
   └────────────────────────────────────────────────────────────────────┘

Client-driven question loop (why not native elicitation). The spec-intended mechanism is MCP Elicitation (a server→client elicitation/create request sent before the final result). We implemented it, but Cursor auto-declines a server-initiated elicitation for a token-bearing delegate/status/resume call before the user ever sees a card — the server gets action=decline instantly and the call then hangs to -32001. So native elicitation is disabled (canElicit() returns false; one-line re-enable if a future Cursor build renders it). Instead the wait loop hands the pending question back as text, and the calling agent does the turn-taking with its own reliable UI: it answers the question itself when it can from repo/task context, otherwise prompts the user via its native question UI (e.g. AskQuestion), then calls resume({session, message}). Repeat until run:done. resume also auto-starts a box that idle-stopped while waiting; status reconnects and resumes the same loop. The tool descriptions carry this as an imperative protocol so the calling agent never ends its turn on a run:waiting.

Live output (stream-json terminal). The in-box agent runs with claude -p --output-format stream-json --verbose, whose NDJSON events are piped through a small formatter (~/.claude/stream-fmt.js, installed on every claim) that appends readable lines to the agent log as work happens — assistant text, → Tool: <arg> calls, and indented results. Plain claude -p buffers and flushes only at the end; this makes watch/monitor/the dashboard terminal genuinely live during a run.

Ask-and-stop is enforced. Writing a question to the channel doesn't by itself stop a headless agent, so a PreToolUse hook (ask-gate.sh, installed at bootstrap) denies every further tool call while /workspace/.agent.question exists. The agent writes its question as its last action and its turn ends — it can't self-answer or work around a pending question until you resume with the answer.

The in-box agent reaches back through one channel (/workspace/.agent.questionrun:waiting) whenever it hits something it shouldn't guess through:

  • a decision / missing fact (ambiguous requirement, which approach, destructive confirmation),
  • a missing credential (names the exact env var; you supply it via resume secrets),
  • an environment blocker — failed npm install / build / test / auth, a 401/403 from a package registry or API, a missing scope. It reports the exact failure and what unblocks it, and does not quietly skip the step or declare success. You (or the calling agent) answer, resume, and it picks up.

So a delegate/resume call returns only when it truly finishes (run:done), is genuinely blocked and waiting on you (run:waiting), or the wait cap elapsed (explicit "still working, reconnect via status") — never a silent "I'll report back later".

Connect your IDE

agent-sandbox registers standard MCP tools over two standard transports, so wiring it up is the same three lines everywhere — only the config file's location and key name differ per client.

Local entry — "delegate THIS", including uncommitted changes

{ "mcpServers": { "agent-sandbox": {
  "type": "stdio",
  "command": "node",
  "args": ["/absolute/path/agent-sandbox/dist/index.js"],
  "env": { "WORKSPACE_DIR": "${workspaceFolder}" }
} } }

Where that goes:

ClientConfig
Cursor~/.cursor/mcp.json (or .cursor/mcp.json in the project)
VS Code / Copilot agent mode.vscode/mcp.json — uses servers instead of mcpServers
Windsurf~/.codeium/windsurf/mcp_config.json
Claude Codeclaude mcp add agent-sandbox -- node /absolute/path/dist/index.js
Zedsettings.jsoncontext_servers
Cline / Roothe extension's MCP settings JSON

WORKSPACE_DIR is what lets you say "delegate this" with no repo argument — it tells the server which project is open. ${workspaceFolder} is a VS Code-family variable, so Cursor, VS Code and Windsurf expand it for you. In any client that doesn't, set an absolute path instead, or just name the repo — the server asks for it rather than guessing. (The remote entry always names a repo: the VPS cannot see your machine.)

Remote entry — delegate a git repo from anywhere

{ "mcpServers": { "agent-sandbox-remote": {
  "url": "https://<ASB_DOMAIN>/mcp",
  "headers": { "Authorization": "Bearer <MCP_HTTP_TOKEN>" }
} } }

Same URL and bearer header for every remote client — an IDE on another machine, Claude web, a phone, or a CI job that shells out to an MCP client. Nothing about the transport is editor-specific.

Note

Cursor is the most heavily exercised client today, which is why one Cursor-specific quirk is documented below (its Auto-review auto-declines server-initiated MCP elicitation). The workaround — handing the pending question back as plain text for the calling agent to answer — is client-agnostic and is the path all clients take, so no client depends on native elicitation.

Layout

src/          MCP orchestrator: handlers (shared) + stdio entry (index.ts) + HTTP entry (http.ts)
              + git-source, delegate-input, deps, http-auth, msb/pool/ssh/sync
              + ask.ts (read-only co-pilot lane: gate predicate + in-box hook + prompt)
test/         unit tests (node:test via tsx) — run `npm test`
docs/         status (start here), architecture, lifecycle, runbook, security — see docs/README.md
Dockerfile    HTTP controller image (Dokploy)
compose.yaml  Dokploy app: Traefik route ${ASB_DOMAIN} → :8787

Status

Actively developed and deployed. The orchestrator ships a ~300-case unit suite (npm test) covering the handlers, the guard predicates, the auth guard, the response security headers, the lifecycle/pool logic and the stream formatter — all pure, so they run in CI with no VPS. Both entries (stdio + HTTP) are live, and the web console runs against a real fleet.

Next up: real user authentication (replacing the single shared bearer token), rate limiting on the token check, and egress deny-by-default for RFC1918 ranges. See docs/remote-mcp-plan.md for the phase breakdown and Known gaps for what is deliberately not solved yet.

Deploy the remote (HTTP) entry

Prereq: a VPS with microsandbox (msb) installed and Docker + a Traefik reverse proxy (e.g. Dokploy). Then, from a machine that can SSH to the VPS:

VPS_SSH_ALIAS=<your-vps-ssh> ./setup.sh

That one script: verifies msb on the VPS, creates + authorizes a dedicated SSH key, pulls the private key into deploy/ (gitignored), scaffolds .env with a generated MCP_HTTP_TOKEN and the container→host SSH settings, and builds. Then:

  1. Edit .env: ASB_DOMAIN, ANTHROPIC_BASE_URL/ANTHROPIC_MODEL (your proxy), and optionally NPM_TOKEN. No GH_TOKEN/GIT_AUTHOR_* — GitHub access is reactive and resolved per repo from the login-keyed store (delegate asks for a token on first use).
  2. Point DNS ASB_DOMAIN → your VPS.
  3. Deploy: a Dokploy Compose app from this repo (path ./compose.yaml), or on the VPS docker compose up -d --build.
  4. Add the remote entry to your MCP client (see Connect your IDE above).

The container drives msb on the VPS host over SSH (msb needs KVM on the host), reaching it as host.docker.internal. Everything site-specific lives in .env; the repo ships no secrets.

Multi-user mode

AUTH_MODE=saas          # accounts, sessions, ownership, per-user integrations
SIGNUP=open             # public "Create an account"; omit for invite-only
ADMIN_GITHUB_LOGINS=…   # who becomes admin when signing in with GitHub (optional)
GITHUB_OAUTH_CLIENT_ID / GITHUB_OAUTH_CLIENT_SECRET   # optional "Continue with GitHub"

What you get:

  • Sign up / sign in — username + password (scrypt), personal access token, or GitHub. HttpOnly session cookies with CSRF protection; Signed-in devices on the Account page to revoke any of them.
  • Ownership — every machine has one owner. Others' machines are invisible and answer 404. Enforced in one place for the JSON routes and the MCP tools.
  • Per-user integrations — GitHub accounts and MCP servers are encrypted rows per user and are injected only into that user's machines. Background work (queued follow-ups, the credential broker) runs as the machine's owner.
  • Personal API keysAccount → Connect an IDE mints one and shows ready-to-paste configs; keys are hashed at rest and revocable.
  • Quotas and limitsUSER_MAX_BOXES concurrent machines per user, 60 mutating requests/minute per caller, sign-in throttling.
  • PlansTRIAL_DAYS=7 starts every new account on a free trial; when it lapses the account keeps its history but cannot start or resume machines until an admin marks it pro (or extends the trial). Unset for self-hosting: no clock. BILLING_URL is where Upgrade points.
  • AdminAccount → Manage users: create invite accounts, issue tokens, promote, set plans, remove. The operator token remains the break-glass identity.

Step-by-step: docs/self-hosting.md. Design, threat model and roadmap: docs/saas-design.md.

Security

Every run is a KVM microVM, so the host is protected by hardware regardless of what the agent does. The interesting threat is therefore not escape but a prompt-injected agent exfiltrating the credentials the box legitimately holds — so the defences that matter are deterministic, not model-mediated: a PreToolUse guard hook denylist, a read-only lane for ask, allow-listed tools, per-repo credential resolution, and a strict CSP around the console.

docs/security.md documents the full model, including an honest Known gaps section.

Found a vulnerability? Please report it privately — see SECURITY.md. Do not open a public issue.

Contributing

Contributions are welcome. CONTRIBUTING.md covers the dev loop (npm run build, npm test), the testing conventions (pure units, no VPS required) and what a good PR looks like.

License

Apache-2.0 © 2026 Ajeeth Kumar Ravichandran.