Gondolin sandboxing for AI coding agents: two tiers from tool-only to full clean room
A two-command setup that runs the pi coding agent with either tool-only filesystem sandboxing or full clean-room isolation, each tier matching a different threat model.
I’ve been running pi and all other clankers on my local machine without sandboxing because I want to live dangerously. I don’t have many secrets in this host, I’m fairly “plugged in” while the clanker runs, and having survived claude code with 3.6/3.7 slopping around my machine without major incidents made me learn to stop worrying and love the clanker. Since then I’ve been monitoring the situation of the cambrian explosion of sandboxing tools. My favourites are the ones that pack a full vm for letting the agent loose doing its non deterministic shenanigans but intercep network calls outside the vm with a good old deterministic approach. I think this is the minimum viable lethal trifecta mitigation setup for local running clankers. It’s not perfect since you can get second order prompt injected if you run malicious code safely in the vm that gets committed outside (along many others gotchas I’m sure exists but I’m not aware of), but at least you can say: “Hey, It runs safe in my virtual machine”.
Gondolin is a local Linux micro-VM with mediated filesystem and network. This post covers the setup I shipped: two tiers of sandboxing that share a config file but apply different containment.
The goal
Three commands, one agent, escalating containment:
| Command | Threat it handles | Where pi runs |
|---|---|---|
pi | none | host, no VM |
pi-secure | untrusted repo, filesystem confinement | host; tools in VM |
pi-secure-vm | untrusted agent code, prod secrets, network exfiltration | inside VM |
The choice is mine per repo. A trusted local project gets pi. A repo with third-party code the agent will execute gets pi-secure-vm. The shell decides per invocation; nothing is permanently reconfigured.
Both sandbox tiers are pi’s official patterns, adapted to micro-VMs1.
Tier 1: pi-secure (tool sandboxing)
pi runs on the host exactly as in the fast path. The same pass-cli secret pipeline sources the LLM key into pi’s process. The VM runs only pi’s tools: bash, read, write, edit, grep, find, ls.
The mechanism is a pi extension that boots a Gondolin VM and overrides each tool’s operations to run via the SDK’s vm.fs and vm.exec. The repo is mounted at /workspace through a RealFSProvider, so file edits write through to the host.
The improvement over the plain official extension is a ShadowProvider layer. The official example mounts the repo raw; a .env in the repo is readable by the sandboxed tools. Mine hides it at the syscall level:
// ~/.config/pi/extensions/gondolin-secure/index.ts
const base = new RealFSProvider(localCwd);
const noSecrets = new ShadowProvider(base, {
shouldShadow: createShadowPathPredicate([
"/.env",
"/.npmrc",
"/.venv",
"/.envrc",
]),
writeMode: "deny",
});
const workspace = new ShadowProvider(noSecrets, {
shouldShadow: createShadowPathPredicate(["/node_modules"]),
writeMode: "tmpfs",
});
The first shadow denies reads and writes to secret files (ENOENT/EACCES). The second hides the host’s node_modules and lets the VM install its own in an in-memory overlay, so architecture-mismatched binaries never leak in.
# ~/.commonrc
pi-secure() {
pass-run-tui "$HOME/.env.pi.template" command pi \
--no-extensions --extension "$GONDOLIN_PI_EXTENSION" "$@";
}
--no-extensions loads only the sandbox extension. Plain pi stays fast because the extension lives in ~/.config/pi/, outside pi’s auto-discovery directory.
Tier 2: pi-secure-vm (full clean room)
pi itself runs inside the VM. Its tools, its LLM calls, its network egress, and any code it executes are all confined. The real LLM key never enters the VM: the host sources it via pass-cli, hands the VM a random placeholder, and Gondolin’s host-side HTTP bridge swaps the placeholder back to the real value only for allowlisted hosts.
host: pass-cli → real ZAI key → createHttpHooks({ secrets: { ZAI_API_KEY: { hosts: ["api.z.ai"], value } } })
↓ placeholder substitution
VM: pi sees ZAI_API_KEY=GONDOLIN_SECRET_<random>
pi calls api.z.ai with Authorization: Bearer GONDOLIN_SECRET_<random>
↓ host bridge intercepts, swaps header
upstream api.z.ai receives: Authorization: Bearer <real key>
upstream example.com receives: nothing (egress blocked, not in allowHosts)
The launcher is a TypeScript file run by Node 26 (native TS stripping, no build step):
// ~/.config/gondolin/launcher/gondolin-run.ts
const { httpHooks, env } = createHttpHooks({
allowedHosts: config.allowHosts, // deny-all default
secrets: Object.fromEntries(
secretNames.map((n) => [
n,
{ hosts: config.secrets[n].hosts, value: realSecrets[n] },
]),
),
});
process.env.GONDOLIN_GUEST_DIR = config.imageDir; // image selector (see Tradeoffs)
const vm = await VM.create({
env,
httpHooks,
vfs: { mounts: { "/workspace": provider } },
});
await vm.shell({ command: config.run, cwd: "/workspace", env });
The image is baked from an OCI base so pi boots ready to run:
# Dockerfile for the pi-vm image
FROM docker.io/library/node:24-alpine
RUN apk add --no-cache curl ripgrep fd git jq ca-certificates bash
RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent
gondolin build exports that OCI rootfs and layers the Alpine kernel and initramfs on top, producing the micro-VM image. pi, ripgrep, fd, git, and curl are pre-installed. There is no runtime npm install, no rootfs-size pressure, no first-run bootstrap fetches.
The shared config file
Both tiers read the same per-repo file. Walk-up resolution like mise.toml or .envrc: nearest gondolin.config.json wins, falling back to ~/.config/gondolin/default.config.json.
// gondolin.config.json (committed to the repo; no real secrets)
{
"shadow": [".env", ".npmrc", ".venv"],
"shadowTmpfs": ["node_modules"],
"allowHosts": ["api.z.ai"],
"secrets": { "ZAI_API_KEY": { "hosts": ["api.z.ai"] } },
"run": "pi --model zai/glm-5.2",
}
shadow and shadowTmpfs apply to both tiers. allowHosts, secrets, and run are Tier 2 only (Tier 1 does not confine network or hold secrets in the VM). Both tiers build the same ShadowProvider stack from the same shadow arrays.
Persistence and disk usage
Gondolin stores guest images, OCI layer cache, sessions, and snapshots on disk. Knowing what lives where helps when cleaning up or moving to an external drive.
Environment variables
The SDK reads these env vars at runtime (set in ~/.commonrc):
| Variable | Default | What it controls |
|---|---|---|
GONDOLIN_IMAGE_STORE | ~/.cache/gondolin/images | OCI image layer cache and refs |
GONDOLIN_SESSIONS_DIR | ~/.cache/gondolin/sessions | Session registry (list/attach) |
GONDOLIN_CHECKPOINT_DIR | ~/.cache/gondolin/checkpoints | VM snapshot storage |
GONDOLIN_GUEST_DIR | auto-resolved from image store | Guest assets for boot |
GONDOLIN_VMM | qemu | Default VM backend |
Image sizes
The default alpine-base guest image is ~200MB. Baked images with pi and tools pre-installed are larger:
| Image | Size | Purpose |
|---|---|---|
pi-vm | 1.1G | Production clean-room VM |
pi-vm-dev | 4.1G | Dev image with extra tooling and debugging utilities |
The launcher selects the image via the imageDir config or the GONDOLIN_GUEST_DIR fallback:
// gondolin.config.json
{
"imageDir": "/Users/fran/.local/share/gondolin-images/pi-vm-dev",
// ...
}
Cache management
The OCI layer cache at ~/.cache/gondolin/images/objects stores unpacked container image layers and can grow past 14G after several rebuilds. Once images are baked, this cache is only needed for rebuilding. You can inspect, pull, and clean up images with the gondolin image CLI:
# list local image refs
gondolin image ls
# pull the default Alpine image
gondolin image pull alpine-base:latest
# free OCI layer cache (safe after baking)
rm -rf ~/.cache/gondolin/images/objects
External drive setup
On a disk-constrained machine, move the Gondolin directories to an external volume with symlinks. The hardcoded paths in gondolin-run.ts resolve through the symlinks transparently:
mv ~/.cache/gondolin /Volumes/Apps/gondolin/cache
mv ~/.local/share/gondolin-images /Volumes/Apps/gondolin-images
ln -s /Volumes/Apps/gondolin/cache ~/.cache/gondolin
ln -s /Volumes/Apps/gondolin-images ~/.local/share/gondolin-images
Set the env vars in .commonrc so the SDK targets the right paths even if the symlinks break.
Tradeoffs
Tier 1 vs Tier 2: what each tier leaves exposed
Tier 1 confines the filesystem. The network and the LLM key are still on the host. pi’s LLM calls go out from the host directly, and the bash tool can curl anything (the allowHosts field exists in the config but the extension does not wire it up yet). The real ZAI key sits in pi’s process memory on the host.
Tier 2 closes both gaps. The bash tool can only reach allowHosts. The ZAI key is a placeholder inside the VM. The cost is complexity: a baked image to maintain, a launcher to run, and roughly three seconds of VM boot per session.
Warning
Gondolin substitutes placeholders in request headers, with an optional extension to URL query strings. It never substitutes inside request bodies2. A provider that expects the API key inside a JSON body defeats Tier 2: the real key would have to live in the VM, or the call would fail. Signed-header auth is also broken: AWS SigV4 signs the request over the placeholder value, so the bridge substitutes the real key too late and AWS returns SignatureDoesNotMatch. Static header values do work (Bearer tokens, HTTP Basic, plain API-key headers like X-API-Key); any auth where the header value is a signature derived from the secret breaks, and those providers need a host-credential mount. That mount is a deliberate exception to Gondolin’s own “do not mount host secret files” guidance, gated on short-lived credentials. The detailed SigV4 investigation remains a draft, so this post does not link to it yet. Verify the auth shape before adopting Tier 2 for a given provider. pi uses Authorization: Bearer <key>, so substitution works.
Write-through is the contract
Both tiers mount the repo read-write. If the agent deletes a file in the repo, the file is actually deleted from the host. Only the explicitly shadowed paths (.env, node_modules, and so on) are protected. Git is the safety net for agent mistakes inside the repo; the sandbox is the safety net for everything outside it. A copy-on-write mount would be safer but would break the core workflow, since the agent edits code and those edits must land on disk.
VM.create({ image }) does not select custom images in SDK 0.12.0
The SDK’s image option silently falls back to the default alpine-base for custom images3. Name, build ID, and asset-directory paths all hit the same fallback. Only the GONDOLIN_GUEST_DIR environment variable selects a custom image reliably. The launcher sets it explicitly.
pi must run under Node, not Bun
pi installed via mise’s default backend is a Bun binary, and Bun’s node:net polyfill crashes on the socket-close path the Gondolin SDK uses. Installing pi via npm under mise-managed Node fixes it4.
What I use day to day
Most of the time, pi. For repos with third-party code the agent will execute, pi-secure-vm. I rarely reach for the middle tier; the marginal complexity over the fast path is not worth it for trusted-local work, and the marginal safety over the full clean room is not worth it for untrusted code. The middle tier exists because it is the official extension pattern, and the filesystem-only sandbox is a real threat model for some workflows. Keeping all three as one-command wrappers means the choice is cheap.
References
- pi. The coding agent this setup wraps.
- Gondolin. The local micro-VM providing filesystem and network mediation.
- Gondolin CLI. CLI reference covering
gondolin image, env vars, and common options. - Gondolin VFS Providers.
ShadowProviderandRealFSProvider, used to hide secret files. - Gondolin Secrets Handling. The placeholder substitution model and the header-only constraint.
- pi.dev Containerization. Documents both patterns this setup adapts.
- pi.dev Extensions. The mechanism Tier 1 uses to override tool operations.
- pi crashed inside the Gondolin sandbox. Companion post on the Bun vs Node runtime mismatch.
- Proton Pass CLI: process-isolated secret injection. The host-side secret pipeline both tiers build on.
Footnotes
-
Both patterns appear in pi.dev Containerization: “Gondolin extension” runs pi on the host with tools routed into a VM; “Plain Docker” runs the whole pi process in a container. ↩
-
Gondolin Secrets Handling substitutes placeholders in request headers by default, including plain header values and
Authorization: Basic/Proxy-Authorization: Basic(base64-decoded, replaced, and re-encoded), with an optionalreplaceSecretsInQueryfor URL query strings. Request bodies are never substituted. ↩ -
Verified in
@earendil-works/gondolin@0.12.0. Passingname:tag, build ID, or an asset-directory path toVM.create({ image })boots the defaultalpine-baseimage instead. SettingGONDOLIN_GUEST_DIRselects the custom image. Re-check this in newer releases. ↩ -
The crash and its fix are in pi crashed inside the Gondolin sandbox. ↩
This post was written with AI assistance.