gondolin: a local micro-VM sandbox with policy written in JavaScript
Experimental Linux microvm setup with a TypeScript Control Plane as Agent Sandbox
At a glance
- What is it?
- Gondolin runs agent-generated code in a local Linux micro-VM and keeps network and filesystem control on the host, with secret injection that never exposes a value to the guest. It is a thoughtful answer to exfiltration risk, still pre-1.0, limited to macOS and Linux, and its own README marks the faster backend experimental.
- Who is it for?
- Adopt gondolin when your agent runs unreviewed code that needs real credentials, and start from the host-side egress policy rather than the VM, since createHttpHooks is where the security property comes from and the guest shell is incidental. Do not adopt it on Windows, on Linux x86_64 where the krun runner is only smoke-built, or for short turns where the roughly 200 MB first-use asset fetch and micro-VM boot dominate.
- Can I use it commercially?
- Yes. Apache-2.0 is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 86 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 28, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The exfiltration risk that shapes the design
The README states the problem in one paragraph, and it is worth quoting the shape of it. AI agents increasingly run generated code without human review. That code often needs network access and credentials. Together those two facts create exfiltration risk, and no amount of prompt discipline fixes it.
Gondolin's answer is to give the generated code its own Linux micro-VM on the host, with network and filesystem access kept under host-side policy control. The policy layer is written in JavaScript rather than declared as configuration, which is the single most consequential design decision in the project: a proxy in your own language can be adjusted to whatever your agent actually tries to do.
The boundary is a real kernel boundary, not a container filter and not a userspace jail. QEMU is the default backend, with an optional experimental `krun` backend behind it. The repository also ships `builtin-trufflehog-registry.json` alongside the image and sandbox-helper registries, which tells you the maintainers think about secret scanning as part of the stack rather than an afterthought.
createHttpHooks and why the guest never holds a secret
The quick example is the mechanism. You describe which hosts are reachable and which secret goes to which host, and the guest receives a placeholder instead of the value.
import { VM, createHttpHooks } from "@earendil-works/gondolin";
const { httpHooks, env } = createHttpHooks({
allowedHosts: ["api.github.com"],
secrets: {
GITHUB_TOKEN: {
hosts: ["api.github.com"],
value: process.env.GITHUB_TOKEN,
},
},
});
const vm = await VM.create({ httpHooks, env });The secret lives in host memory, in `process.env`, and never crosses into the guest. When the code inside runs a request to an allowed destination, the host substitutes the real value on the way out.
const result = await vm.exec(`
curl -sS -f \
-H "Authorization: Bearer $GITHUB_TOKEN" \
https://api.github.com/user
`);
console.log("exitCode:", result.exitCode);The README is specific that the real secret is injected by the host only for allowed destinations, and that this includes `Authorization: Basic` flows, not just bearer tokens. A string passed to `vm.exec` runs under `/bin/sh -lc`, which is why the guest code above is ordinary shell and curl rather than a structured command. `result` carries `exitCode`, `stdout` and `stderr`, and `vm.close()` tears the machine down.
Two backends, and a 200 MB first run
QEMU is the default and needs no build step beyond installing it. The `krun` backend is described as optional and experimental, and it is the faster option when it works, since it uses libkrun rather than a full machine emulator.
Guest assets are kernel, initramfs and rootfs, plus optional krun boot artifacts, and the README puts their size at roughly 200 MB or more. They are resolved automatically on first use through `builtin-image-registry.json` and then cached locally. With no image specified, Gondolin uses `GONDOLIN_DEFAULT_IMAGE`, which defaults to `alpine-base:latest`.
The prerequisites differ by platform.
brew install qemu nodeon macOS, and on Debian or Ubuntu:
sudo apt install qemu-system-arm nodejs npmNote what the Linux package list contains. `qemu-system-arm` is the system emulator package regardless of your host architecture, which is consistent with the project treating ARM64 as its primary target. The README states that Linux and macOS are supported, that ARM64 is the most tested runtime path today, and that Linux x86_64 `make krun-runner` is covered by CI smoke builds. That is an honest description of a project with no Windows support at all.
Session lifecycle from the CLI
The quickest way to see what the tool does is to open a shell in a fresh machine.
npx @earendil-works/gondolin bashThe session model is the interesting part, and it exists because agent turns are long. Three commands manage machines that already exist.
npx @earendil-works/gondolin list
npx @earendil-works/gondolin attach <session-id>
npx @earendil-works/gondolin snapshot <session-id>
npx @earendil-works/gondolin bash --resume <snapshot-id-or-path>`list` reports running sessions, `attach` puts a shell into one that is already running, and `snapshot` is described as stopping a running session while keeping its disk state. `bash --resume` takes either a snapshot id or a path, which means a snapshot survives process exit and can be handed to a different run.
The filesystem policy is part of that story. Rootfs modes are `readonly`, `memory` and `cow`, and there is runtime rootfs sizing, so you can decide whether a turn gets a scratch disk, an ephemeral one, or a copy-on-write overlay seeded from a snapshot. For an agent that runs code repeatedly against the same starting state, that overlay is the difference between a cold boot per turn and a resumed machine.
Policy beyond HTTP: VFS, DNS, ingress and SSH
HTTP egress is the headline, but the same programmable-surface idea shows up in four other places.
Programmable VFS mounts let you write custom filesystem behaviour in JavaScript, so a mount can deny by path pattern rather than only by mount point. DNS behaviour is configurable across three named modes, `synthetic`, `trusted` and `open`, which lets you pin resolution to a controlled resolver or leave it alone. Ingress is the reverse direction, exposing a guest HTTP service on the host through `--listen` or `vm.enableIngress()`. SSH has two halves: host to guest via `vm.enableSsh()`, and an optional guest to upstream allowlisted SSH egress that is proxied and described as exec-oriented.
The pattern is consistent. Each of these is a JavaScript hook the host controls rather than a declarative setting the guest could work around. The cost is the mirror image: a policy bug is now a bug in your code, and it sits between the agent and the network rather than in a config file someone can read.
Custom image builds are the other end of the filesystem story, an Alpine-based build pipeline with an optional OCI rootfs source. When `vmm=krun` is selected, Gondolin additionally requires krun boot assets from the image manifest, `assets.krunKernel` and an optional `assets.krunInitrd`, and custom kernels or initrds have to be supplied as an explicit `sandbox.imagePath` asset object.
Building the krun runner on macOS
The krun backend needs a helper binary that does not ship prebuilt for every host, so there is a target for it.
make krun-runnerThe Makefile pins `LIBKRUN_VERSION` to v1.17.4, clones `containers/libkrun` into `.cache/`, and builds it with `BLK=1 NET=1`. On macOS it cross-compiles the Linux half using Homebrew's LLVM clang and lld with a sysroot it stages itself.
The output lands at `host/krun-runner/zig-out/bin/gondolin-krun-runner`, and on macOS the build ad-hoc signs it with the `com.apple.security.hypervisor` entitlement so Hypervisor.framework access is allowed. Without that signature the binary cannot open the virtualization handle, and the failure would otherwise look like a permission bug. When the runner is present, Gondolin auto-detects it for `--vmm krun`.
The Linux prerequisites are a longer list, including build-essential, clang, lld, llvm-dev, libcap-ng-dev, a Rust toolchain on edition 2024, and Zig 0.16.0 for the architecture in question. That is the honest cost of the experimental backend, and the README's own framing of krun as optional keeps it out of the path of anyone who just wants the sandbox.
What the Makefile and package.json reveal about the build
The root `package.json` is a pnpm workspace at `[email protected]`, private, with `build` and `test` fanning out across every package.
{
"scripts": {
"build": "pnpm -r build",
"test": "pnpm -r test"
},
"packageManager": "[email protected]"
}The Makefile targets are more telling than the scripts. Alongside `lint`, `typecheck`, `build`, `test` and `docs`, there is a long family of fuzz targets: `fuzz-host`, `fuzz-cbor`, `fuzz-protocol`, `fuzz-sandbox`, each with `-last` and `-repro` variants, plus `fuzz-clean`. Fuzzing the protocol and the serialization layer is the right instinct for something parsing host-controlled input on behalf of generated code.
Formatting is Prettier at `^3.8.1` and TypeScript at `^5.7.3`, with Husky hooks wired through the `.husky/` directory. The guest side is a separate Zig build under `guest/`, and the docs site is configured by `zensical.toml`.
The licence is Apache-2.0, which is the permissive end and adds an explicit patent grant, though that is a matter for the LICENSE file rather than for this article.
Where Gondolin is the wrong choice
Five limitations are visible without reading past the feature list.
It is macOS and Linux only. No Windows, no other host, and no mention of one. It is pre-1.0, currently at 0.12.0, with releases v0.11.0 and v0.12.0 both on 2026-05-18 and 2026-05-19 and the last push on 2026-07-06. That is an active project, not a stable API. The fast backend is experimental, and on Linux x86_64 even building it is only covered by CI smoke builds, so if you are on that combination you are the person discovering the breakage.
The cost side is the first run. Roughly 200 MB of guest assets downloaded and cached on first use, plus micro-VM boot time on every session. For short agent turns the overhead can exceed the work being done. And the policy layer being JavaScript cuts both ways: it is the most flexible part of the design and also the part where a mistake hands an agent a credential. If you cannot review the hooks you write, this is the wrong abstraction even though it is the right idea.
Editorial conclusion
Adopt gondolin when your agent runs unreviewed code that needs real credentials, and start from the host-side egress policy rather than the VM, since createHttpHooks is where the security property comes from and the guest shell is incidental. Do not adopt it on Windows, on Linux x86_64 where the krun runner is only smoke-built, or for short turns where the roughly 200 MB first-use asset fetch and micro-VM boot dominate. Verify first that your egress hooks deny by default on a path you did not explicitly allow, and pin the package version, because 0.12.x carries no API stability promise.
Frequently asked questions
How do I get a shell in a Gondolin sandbox?
Run npx @earendil-works/gondolin bash after installing qemu and node. Guest assets of roughly 200 MB are resolved and cached on first use, and the default image comes from GONDOLIN_DEFAULT_IMAGE, which defaults to alpine-base:latest.
How does Gondolin keep secrets out of the sandbox?
You register a secret with createHttpHooks against specific hosts, and the guest only ever sees a placeholder. The host injects the real value on the way out, and the README notes this covers Authorization: Basic flows as well as bearer tokens.
What is the difference between the QEMU and krun backends?
QEMU is the default and needs only qemu installed. krun is an optional experimental backend built on libkrun, which requires make krun-runner to produce a helper binary, a Rust toolchain on edition 2024 and Zig 0.16.0, plus krun boot assets in the image manifest.
Which platforms does Gondolin support?
Linux and macOS, with ARM64 described as the most tested runtime path today and Linux x86_64 make krun-runner covered by CI smoke builds. Windows is not listed as supported.
Can I snapshot and resume a sandbox?
Yes. npx @earendil-works/gondolin snapshot <session-id> snapshots a running session and stops it, and bash --resume takes a snapshot id or path to start again from that state.
What licence is Gondolin under and is it stable?
Apache-2.0, which includes an explicit patent grant; check the LICENSE file for the terms. The project is not archived and the last push was on 2026-07-06, but the current release line is 0.12.x, so the API is not yet stable.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/earendil-works-gondolin)