Context
We want a lightweight, cross-platform sandbox for the moltnet eval runner (apps/moltnet-cli + libs/dspy-adapters/{claudecode,codex}) so the eval runner can exec Claude Code / Codex CLIs against generated code that may be adversarial, while:
- only seeing a scoped worktree (no host FS leakage),
- only reaching LLM provider APIs (no arbitrary egress),
- ideally not seeing the raw OAuth token (header-placeholder injection instead of file mount).
We prototyped Gondolin (experimental Linux micro-VMs with JS-programmable network stack + VFS) as an alternative to Docker, sandbox-exec, or bwrap + iptables.
Full writeup: .exploration/gondolin-prototype/FINDINGS.md.
TL;DR
- Core primitives validated. Placeholder auth, VFS scoping, per-host egress allowlist all work end-to-end against live Anthropic API using real Claude Code OAuth. Warm boot on M-series Mac: 28ms.
- Claude Code CLI runs sandboxed using only
CLAUDE_CODE_OAUTH_TOKEN as a Gondolin-injected placeholder. Guest never sees real token. claude --print "2+2=" → 4.
- Custom image build is broken. Gondolin's zig-based guest-binary compile step fails non-deterministically on our target (aarch64-linux-musl, alpine 3.23). Upstream earendil-works/gondolin#68.
- Verdict: promising fit for moltnet evals, not production-ready today, but our eval runner isn't either. Worth continuing if we accept early-stage upstream dependency or vendor/fork the build pipeline.
Smoke test results
All against default alpine-base image, claude installed at runtime via npm.
| # |
Check |
Result |
| 1 |
Guest sees placeholder, not real token |
✅ GONDOLIN_SECRET_fe23... only |
| 2 |
/workspace mounted RW from host (RealFSProvider) |
✅ Bidirectional |
| 3 |
Egress to example.com denied |
✅ HTTP 403 from policy |
| 4 |
Egress to api.anthropic.com allowed |
✅ HTTP 405 (wrong verb, but reached) |
| 5 |
claude --print via placeholder auth |
✅ → 4 |
| 6 |
Warm-cache boot time |
✅ 28ms (cold with 89MB asset download: ~14s) |
What didn't work
Alpine's npm package is broken (upstream, not Gondolin-specific)
apk add nodejs npm + npm i -g <anything> fails with TypeError: Class extends value undefined is not a constructor or null in minipass-sized — CJS/ESM interop bug in Alpine 3.23's npm 11.11.0. Reproducible in plain docker run alpine:3.23. Workaround: avoid Node entirely, ship native binaries.
gondolin build custom-image pipeline fails
Attempted to bake claude 2.1.110 + codex rust-v0.121.0 + moltnet cli-v1.25.0 native binaries into a custom image. Build runs a nested Docker container that compiles Gondolin's own zig guest binaries (sandboxd, sandboxfs, sandboxingress, sandboxssh) before our postBuild.commands run. That zig step fails with:
```
error: ld.lld: cannot open /work/zig-cache/global/o//libcompiler_rt.a: No such file or directory
error: sub-compilation of musl libc.a failed
note: failed to check cache: invalid manifest file format
```
- Non-deterministic: different sub-targets fail across runs.
- Known zig 0.15.2 parallel-build cache issue.
- Maintainer's workaround from #68 (
GONDOLIN_GUEST_SRC=/path/to/clone/guest): same failure class.
- Cleaning stale tmpdirs: no help.
This blocks the "fast warm-boot with all tools preinstalled" end state.
Threat-model asymmetry: Claude vs Codex
| Dimension |
Claude Code CLI |
Codex CLI |
| Auth surface |
Env var CLAUDE_CODE_OAUTH_TOKEN → Authorization header |
File: $CODEX_HOME/auth.json / ~/.codex/auth.json, fallback OPENAI_API_KEY |
| Gondolin integration |
Header placeholder (strong): guest never sees real token |
File bind-mount (weaker): guest reads real token |
| Exfiltration risk |
Near-zero |
Guest can exfil via /workspace writes unless mitigated |
| Mitigation |
None needed |
ShadowProvider over /workspace or the codex adapter's existing IsolateHome tmp-copy |
We did not test the Codex path end-to-end — the custom-image block stopped us before baking codex into an image.
Key Gondolin facts learned
- Not truly cross-platform: macOS + Linux only. No Windows. ARM64 best-tested.
- QEMU default (~15s cold with asset download, 28ms warm). Optional
libkrun requires local zig build.
- HTTP/1.x + TLS-intercepted HTTPS only. No HTTP/2, HTTP/3, QUIC, WebRTC, UDP.
- Host process sees plaintext guest traffic (necessary for egress hooks) — host is in TCB. Fine for untrusted-guest/trusted-host, broken for mutual distrust.
- Secret placeholder substitution: request headers only. Not URL path, request body, or response content. Silently inert if a CLI puts the token anywhere else.
- OAuth rotation is not handled. Response-body token refresh would leak a real token to the guest. Not observed with
claude --print but latent.
- VFS is the killer feature.
RealFSProvider, ReadonlyProvider, ShadowProvider stacking gives fine-grained policy that bwrap --ro-bind can't match.
- Guest images are Alpine-only.
Decision matrix
| Option |
Effort |
Security |
Maturity |
Cross-platform |
Startup |
| Gondolin (VM) |
medium-high; upstream flaky |
strong (VM + policy) |
experimental |
macOS + Linux |
28ms warm, ~15s cold |
| sandbox-exec + bwrap |
medium; two backends |
good (kernel syscall filter) |
mature primitives, no unified API |
macOS + Linux |
near-zero |
| Docker sandboxes (current) |
low |
good (ns + cgroup) |
mature |
cross w/ Docker Desktop |
1-3s |
| gVisor / Firecracker |
high |
very strong |
mature |
Linux-only |
100s ms |
Gondolin's unique value vs sandbox-exec+bwrap: programmable egress policy with secret-placeholder injection. Single feature hard to replicate natively.
What we didn't get to
- Full eval-runner integration (actual
moltnet eval run loop with multi-turn, file edits, git ops, heartbeat callbacks).
- Codex path validation end-to-end.
- moltnet CLI path validation.
ShadowProvider real-worktree test (hiding .git/config, .env, .claude/, ~/.ssh).
- Boot-time with a working custom image (~500MB of binaries).
- Parallel concurrent eval runs (N VMs on one M-series Mac, RAM profile).
- HTTP/2 SSE streaming for long agent conversations.
Proposed next steps
Short term (hours)
Medium term (days)
Long term (weeks)
Artifacts
```
.exploration/gondolin-prototype/
├── package.json # pins @earendil-works/gondolin@0.7.0
├── run.ts # smoke-test harness
├── fake-worktree/prompt.txt # "What is 2+2?"
├── image/
│ ├── build-config.json # pinned claude 2.1.110 / codex rust-v0.121.0 / moltnet cli-v1.25.0
│ └── build.log # last failed build output
├── results.json # smoke-test results
├── README.md # quickstart
└── FINDINGS.md # full experiment writeup
```
Related diary entries
- procedural: Gondolin sandbox prototype: Claude Code CLI validated with header-placeholder auth (
88771147-5c90-4e66-a507-843f6eb6a990)
- semantic: Gondolin facts: HTTP/1.x only, header-only secret substitution, Alpine-only guest, host in TCB (
206bef35-c3d8-4c61-bfed-16e15d584294)
- reflection: Gondolin custom-image build pipeline blocked — implications for adoption path (
ad2851b6-ee69-4e86-a9c2-f39e39036c55)
Context
We want a lightweight, cross-platform sandbox for the moltnet eval runner (
apps/moltnet-cli+libs/dspy-adapters/{claudecode,codex}) so the eval runner can exec Claude Code / Codex CLIs against generated code that may be adversarial, while:We prototyped Gondolin (experimental Linux micro-VMs with JS-programmable network stack + VFS) as an alternative to Docker,
sandbox-exec, orbwrap+iptables.Full writeup:
.exploration/gondolin-prototype/FINDINGS.md.TL;DR
CLAUDE_CODE_OAUTH_TOKENas a Gondolin-injected placeholder. Guest never sees real token.claude --print "2+2="→4.Smoke test results
All against default
alpine-baseimage,claudeinstalled at runtime via npm.GONDOLIN_SECRET_fe23...only/workspacemounted RW from host (RealFSProvider)example.comdeniedapi.anthropic.comallowedclaude --printvia placeholder auth4What didn't work
Alpine's npm package is broken (upstream, not Gondolin-specific)
apk add nodejs npm+npm i -g <anything>fails withTypeError: Class extends value undefined is not a constructor or nullinminipass-sized— CJS/ESM interop bug in Alpine 3.23's npm 11.11.0. Reproducible in plaindocker run alpine:3.23. Workaround: avoid Node entirely, ship native binaries.gondolin buildcustom-image pipeline failsAttempted to bake
claude 2.1.110+codex rust-v0.121.0+moltnet cli-v1.25.0native binaries into a custom image. Build runs a nested Docker container that compiles Gondolin's own zig guest binaries (sandboxd,sandboxfs,sandboxingress,sandboxssh) before ourpostBuild.commandsrun. That zig step fails with:```
error: ld.lld: cannot open /work/zig-cache/global/o//libcompiler_rt.a: No such file or directory
error: sub-compilation of musl libc.a failed
note: failed to check cache: invalid manifest file format
```
GONDOLIN_GUEST_SRC=/path/to/clone/guest): same failure class.This blocks the "fast warm-boot with all tools preinstalled" end state.
Threat-model asymmetry: Claude vs Codex
CLAUDE_CODE_OAUTH_TOKEN→Authorizationheader$CODEX_HOME/auth.json/~/.codex/auth.json, fallbackOPENAI_API_KEY/workspacewrites unless mitigatedShadowProviderover/workspaceor the codex adapter's existingIsolateHometmp-copyWe did not test the Codex path end-to-end — the custom-image block stopped us before baking codex into an image.
Key Gondolin facts learned
libkrunrequires local zig build.claude --printbut latent.RealFSProvider,ReadonlyProvider,ShadowProviderstacking gives fine-grained policy thatbwrap --ro-bindcan't match.Decision matrix
Gondolin's unique value vs
sandbox-exec+bwrap: programmable egress policy with secret-placeholder injection. Single feature hard to replicate natively.What we didn't get to
moltnet eval runloop with multi-turn, file edits, git ops, heartbeat callbacks).ShadowProviderreal-worktree test (hiding.git/config,.env,.claude/,~/.ssh).Proposed next steps
Short term (hours)
gondolin buildwith concrete repro, linked to earendil-works/gondolin#68.alpine-base, install tools at runtime via direct binary fetch (skip broken Alpine npm),gondolin snapshot, resume from snapshot. Unblocks prototyping while upstream settles.VM.createaccepting{ worktree, prompt, tool: "claude" | "codex", model }.Medium term (days)
evals/legreffier-commit-fix/inside Gondolin. Compare to unsandboxed baseline.ShadowProviderpolicy that hides secrets from worktrees and test against a worktree containing.env,.git/config,~/.ssh.Long term (weeks)
sandbox-exec+bwrap+iptables egress and accept losing placeholder-injection.libs/eval-sandbox/with both Go and TS interfaces, docs, examples, CI coverage.Artifacts
```
.exploration/gondolin-prototype/
├── package.json # pins @earendil-works/gondolin@0.7.0
├── run.ts # smoke-test harness
├── fake-worktree/prompt.txt # "What is 2+2?"
├── image/
│ ├── build-config.json # pinned claude 2.1.110 / codex rust-v0.121.0 / moltnet cli-v1.25.0
│ └── build.log # last failed build output
├── results.json # smoke-test results
├── README.md # quickstart
└── FINDINGS.md # full experiment writeup
```
Related diary entries
88771147-5c90-4e66-a507-843f6eb6a990)206bef35-c3d8-4c61-bfed-16e15d584294)ad2851b6-ee69-4e86-a9c2-f39e39036c55)