Skip to content

Eval-runner sandbox: Gondolin prototype findings + next steps #826

Description

@getlarge

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_TOKENAuthorization 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

  1. Full eval-runner integration (actual moltnet eval run loop with multi-turn, file edits, git ops, heartbeat callbacks).
  2. Codex path validation end-to-end.
  3. moltnet CLI path validation.
  4. ShadowProvider real-worktree test (hiding .git/config, .env, .claude/, ~/.ssh).
  5. Boot-time with a working custom image (~500MB of binaries).
  6. Parallel concurrent eval runs (N VMs on one M-series Mac, RAM profile).
  7. HTTP/2 SSE streaming for long agent conversations.

Proposed next steps

Short term (hours)

  • File the upstream zig-compile bug against gondolin build with concrete repro, linked to earendil-works/gondolin#68.
  • Pivot to snapshot-based image: boot alpine-base, install tools at runtime via direct binary fetch (skip broken Alpine npm), gondolin snapshot, resume from snapshot. Unblocks prototyping while upstream settles.
  • Wire a TS wrapper around VM.create accepting { worktree, prompt, tool: "claude" | "codex", model }.

Medium term (days)

  • End-to-end eval run: one real scenario from evals/legreffier-commit-fix/ inside Gondolin. Compare to unsandboxed baseline.
  • Benchmark parallel boot: 1, 4, 8, 16 concurrent VMs. RAM, CPU, per-VM boot.
  • Write a ShadowProvider policy that hides secrets from worktrees and test against a worktree containing .env, .git/config, ~/.ssh.

Long term (weeks)

  • Decide: bet on Gondolin (vendor/fork the build pipeline), or switch to native sandbox-exec+bwrap+iptables egress and accept losing placeholder-injection.
  • If continuing: productize as 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

  • 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)

Metadata

Metadata

Assignees

Labels

evalsAgent context evaluationperfPerformance improvementsecuritySecurity vulnerabilities and fixes

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions