kumoba
Local microVM sandboxes for AI agents and untrusted code. One small daemon, a CLI, an HTTP API, and SDKs for TypeScript and Rust.
- Real VMs, not containers. Every sandbox is a libkrun microVM with its own kernel. KVM on Linux, Hypervisor.framework on Apple silicon.
- Fast. Create, boot, run a command and delete in about 0.4 s on an M-series Mac.
- Self-hosted. Runs on your laptop or your server. No account, no cloud, no telemetry.
- Batteries included. Commands with streaming output, files, time-limited port previews, egress allowlists, secrets that never enter the guest, and an xfce desktop you can watch live in the browser or drive from a computer-use agent.
import { Kumoba } from "kumoba";
await using sandbox = await new Kumoba().create();
const result = await sandbox.run("python3 -c 'print(21 * 2)'");
console.log(result.stdout); // "42\n"
Getting started
1. Install. On macOS, get libkrun from Homebrew first: brew tap slp/krun && brew install slp/krun/libkrun slp/krun/libkrunfw zstd. Linux needs /dev/kvm and zstd; the release already carries the patched libkrun.
|
# add this to your shell profile
This puts the kumobad daemon, the kumoba CLI, kumoba-worker and the default image (Debian with Python, Node, Chromium and an xfce desktop) into ~/.kumoba, checking every download against the release's SHA256SUMS. KUMOBA_IMAGE=test fetches a 5 MB Alpine image instead, and KUMOBA_VERSION=0.2.0 pins a release.
2. Start the daemon. No configuration needed:
It boots every image in ~/.kumoba/images, writes a bearer token to ~/.kumoba/token, and listens on ~/.kumoba/kumobad.sock and on 127.0.0.1:7788 for browser links. kumobad --help lists the environment variables that override each default.
3. Run something. In another terminal:
id= # a sandbox that stays around
|
kumoba with no arguments lists every command, and kumoba <command> --help shows its arguments. The command list comes from the daemon itself, so the CLI always matches the daemon it talks to. docs/cli.md covers the argument syntax, output and exit codes.
4. Use it from code.
import { Kumoba } from "kumoba";
const kumoba = new Kumoba();
const once = await kumoba.run("python3 --version"); // fresh VM, deleted afterwards
console.log(once.stdout);
const sandbox = await kumoba.create({ user: "kumoba" });
await sandbox.files.write("/workspace/a.txt", "hello");
console.log((await sandbox.run("cat /workspace/a.txt")).stdout);
await sandbox.delete();
new Kumoba() finds the socket and token in ~/.kumoba by itself. Pass { url, token } or set KUMOBA_URL and KUMOBA_TOKEN to reach a daemon elsewhere. Agents with a shell can use the CLI directly: kumoba commands --json prints every command with its JSON Schema.
Install
| What | How |
|---|---|
| Daemon, CLI, worker and images | scripts/install.sh as above, or a tarball and image from the latest release. Linux tarballs include the patched libkrun and libkrunfw; the macOS worker is signed for Hypervisor.framework. |
| Daemon and CLI from source | cargo install kumobad installs kumobad, kumoba and kumoba-worker side by side. On macOS sign the worker as below. |
| CLI only, through npm | npm install -g kumoba, or npx kumoba <command>; the same commands as the native CLI |
| TypeScript SDK | npm install kumoba |
| Rust library | cargo add kumoba |
| Guest agent for your own images | bin/kumoba-guest in the release tarball, or cargo install kumoba-guest --target x86_64-unknown-linux-musl with RUSTFLAGS='-C linker=rust-lld' |
On macOS a cargo installed worker cannot use Hypervisor.framework until it carries the entitlement. The release worker already does:
scripts/check-host.sh tells you whether a machine qualifies and how many sandboxes fit in its memory. Everything is released together under one version; see CHANGELOG.md.
How it works
your app, the kumoba CLI ──HTTP──▶ kumobad ──spawns──▶ kumoba-worker (one per VM, libkrun)
│ │ vsock
│ ▼
│ kumoba-guest (PID 1 helper inside the VM)
└── egress proxy, secrets, previews, auto-stop
kumobad keeps sandbox metadata and disks in a state directory, boots VMs through a worker process per sandbox, and talks to a small agent inside each guest over vsock. Guests have no network device; outbound HTTP(S) goes through a proxy in the worker that enforces the sandbox's allowlist and injects secrets only for the hosts they are scoped to. See docs/architecture.md.
Building from source
| Host | Needs |
|---|---|
| macOS 14+ on Apple silicon | brew tap slp/krun && brew install slp/krun/libkrun slp/krun/libkrunfw e2fsprogs rustup |
| Linux x86_64 or arm64 | /dev/kvm (bare metal, or a VM with nested virtualization), Rust 1.99, scripts/prepare-linux-host.sh builds the patched libkrun and fetches libkrunfw |
serve.sh writes the client settings to .local/serve/env; set -a; source .local/serve/env; set +a points new Kumoba() at it. Without Docker, scripts/build-test-image.sh produces the Alpine test image. CONTRIBUTING.md covers the tests.
CLI
kumoba is a thin client for the daemon's command catalog (GET /v1/commands): every command, argument, type and help line comes from the daemon it talks to. The native build ships with the daemon; npm install -g kumoba or npx kumoba gives the same CLI on Node.
;
exec and run exit with the guest command's own status. docs/cli.md has the argument syntax, output kinds and exit codes.
TypeScript SDK
The package lives in sdk/typescript. It depends only on zod and talks to kumobad over a Unix socket or loopback TCP.
const kumoba = new Kumoba({
url: "unix:///path/to/kumobad.sock", // default: KUMOBA_URL, then ~/.kumoba/kumobad.sock
token: "...", // default: KUMOBA_TOKEN, KUMOBA_TOKEN_FILE, then ~/.kumoba/token
publicUrl: "https://sandboxes.example.com", // optional, makes expose() return full URLs
});
const sandbox = await kumoba.create({
image: "default",
cpu: 2,
memoryMib: 4096,
user: "kumoba", // run commands as this guest user
network: { allow: ["api.github.com"] }, // "open" | "blocked" | { allow }
autoStopMinutes: 15,
labels: { project: "demo" },
});
// Commands: string → /bin/sh -c, array → argv. Never throws on non-zero exit.
const r = await sandbox.run(["python3", "script.py"], {
cwd: "/workspace",
env: { PYTHONUNBUFFERED: "1" },
timeoutMs: 60_000,
onStdout: (text) => process.stdout.write(text),
});
r.ok; r.exitCode; r.stdout; r.stderr; r.timedOut;
// Files
await sandbox.files.write("/workspace/a.txt", "hi");
await sandbox.files.upload("./local.csv", "/workspace/data.csv");
const text = await sandbox.files.readText("/workspace/a.txt");
const stream = await sandbox.files.stream("/workspace/big.bin");
// Secrets: the guest sees a placeholder in $GITHUB_TOKEN; the real value is
// injected only into HTTPS requests to api.github.com.
await sandbox.setSecrets([
{ name: "gh", value: token, hosts: ["api.github.com"], envVars: ["GITHUB_TOKEN"] },
]);
// Ports: a signed, expiring link through kumobad to a port inside the VM.
const link = await sandbox.expose(3000, { ttlSeconds: 600 });
// Desktop (images built from images/default)
await sandbox.desktop.start();
const png = await sandbox.desktop.screenshot();
await sandbox.desktop.click(200, 300);
await sandbox.desktop.type("hello");
await sandbox.desktop.hotkey("ctrl+s");
const view = await sandbox.desktop.liveView(); // noVNC websocket link
// One-shot: a fresh VM for one command, deleted afterwards
const once = await kumoba.run(["python3", "-c", "print(42)"], { image: "default" });
// Lifecycle
await sandbox.stop(); await sandbox.start(); await sandbox.delete();
await kumoba.list({ labels: { project: "demo" } });
await kumoba.get(id); await kumoba.find(id);
// Network activity: every proxied connection, allowed or denied, with bytes
for await (const entry of kumoba.accessLog({ since: new Date(Date.now() - 3_600_000) })) {
console.log(entry.sandboxId, entry.host, entry.decision, entry.bytesIn);
}
await using deletes the sandbox when the scope ends (TypeScript 5.2+, or Node 24+ for plain JavaScript; otherwise call sandbox.delete() in a finally). Errors are KumobaError with status and code.
Rust
The kumoba crate drives VMs directly, without the daemon; it needs a kumoba-worker binary from the release or cargo install kumobad. crates/kumoba/examples/hello.rs boots an image, runs a command and exits:
API documentation is on docs.rs/kumoba.
HTTP API
Everything the SDK does is a plain HTTP call with a bearer token; see docs/http-api.md.
Running it as a service
On a server, give each setting an explicit path rather than relying on ~/.kumoba. kumobad is configured through environment variables (kumobad --help lists them). A minimal unit:
[Service]
User=kumoba
Environment=KUMOBAD_STATE_DIR=/var/lib/kumoba/state
Environment=KUMOBAD_WORKER=/opt/kumoba/bin/kumoba-worker
Environment=KUMOBAD_IMAGES=default=/opt/kumoba/images/default.raw
Environment=KUMOBAD_TOKEN_FILE=/etc/kumoba/token
Environment=KUMOBAD_SOCKET=/run/kumoba/kumobad.sock
Environment=KUMOBAD_PREVIEW_LISTEN=127.0.0.1:7789
Environment=KUMOBAD_PREVIEW_URL=https://previews.example.com
Environment=KUMOBAD_MAX_RUNNING=4
Environment=KUMOBAD_ALLOW_PRIVATE_HOSTS=gitlab.corp.example,10.20.0.0/16
Environment=KUMOBAD_UPSTREAM_PROXY=http://proxy.corp.example:3128
Environment=KUMOBAD_NO_PROXY=.corp.example
Environment=KUMOBAD_UPSTREAM_PROXY_CONNECT=address
Environment=KUMOBAD_LIBKRUN=/opt/kumoba/lib/libkrun.so.1
Environment=KUMOBAD_LIBKRUNFW=/opt/kumoba/lib/libkrunfw.so.5
ExecStart=/opt/kumoba/bin/kumobad
The release tarballs (kumoba-<version>-linux-<arch>.tar.zst) have exactly this layout: bin/ with kumobad, kumoba-worker and kumoba-guest, lib/ with the libraries, licenses/ and a manifest.json of checksums. KUMOBA_IMAGE=path.raw scripts/package.sh <version> builds the same tarball with an image under images/. Put a reverse proxy in front of KUMOBAD_PREVIEW_LISTEN if browsers on other machines need expose() links or the desktop live view, and set KUMOBAD_PREVIEW_URL to its public address; the preview routes are self-authenticating and need no token.
Security model
- Guests run in a hardware-isolated VM with their own kernel; the only channel to the host is the vsock agent and the egress proxy.
- Private, link-local and metadata addresses are never reachable from a guest, whatever the sandbox's allowlist says. An operator can open specific intranet hosts or CIDRs with
KUMOBAD_ALLOW_PRIVATE_HOSTS; loopback, link-local and the host's own addresses stay blocked even then. - Behind a corporate proxy, set
KUMOBAD_UPSTREAM_PROXYand the daemon tunnels through it withCONNECTto the address it resolved and checked itself, or to the host name withKUMOBAD_UPSTREAM_PROXY_CONNECT=host(hosts inKUMOBAD_NO_PROXYand allowed intranet hosts go direct). The guest's TLS session rides inside the tunnel, so secrets and certificate checks behave the same. - Secret values are stored on the host, scrubbed from proxied responses and injected only into HTTPS request headers for the hosts they are scoped to. The guest only ever holds a placeholder.
- Preview links are HMAC-signed, bound to one sandbox and port, and expire. Browsers load them from a separate preview listener (
127.0.0.1:7789by default) that serves nothing else, so a page from a sandbox runs on an origin without access to the API, and redirects it sends stay inside its own preview path. On the API listener the same links are download-only. All sandboxes' previews share that one origin: a page can't load another sandbox's preview without its token, but browser storage such aslocalStorageis shared between them, so don't open untrusted previews in the same browser profile as previews whose storage matters. - The Unix socket is created
0600; the TCP listener accepts loopback addresses only.
Status
Experimental. The runtime and SDK are exercised on every push by real VMs in CI (Linux x86_64 with KVM), and every release is installed with scripts/install.sh and booted before it is published. Expect the API to change before 1.0; docs/releasing.md says what a version bump promises.
Layout
| Path | What |
|---|---|
crates/kumoba |
Host runtime library (crates.io) |
crates/kumoba-guest |
Agent that runs inside every VM (crates.io) |
crates/kumoba-protocol |
Host-guest wire protocol (crates.io) |
crates/kumobad |
The daemon, its HTTP API and the kumoba-worker VMM process (crates.io) |
sdk/typescript |
kumoba npm package (npm) |
images/ |
Guest image recipes (default, plus the mkfs helper) |
patches/ |
libkrun patches this project needs |
scripts/ |
Build, test, image, package, install, release and CI entry points |
License
Apache-2.0. libkrun is Apache-2.0; libkrunfw ships a GPL-2.0 Linux kernel. See NOTICE.