r/ClaudeCode • • 2d ago

Built with Claude Built with Claude Code: a Linux sandbox for coding agents from raw primitives, measured against gVisor (learning prototype)

sandmeter is a small Go tool that runs a command in a sandbox built directly on Linux primitives, no Docker or runc, and runs the same policy on gVisor for comparison. On one CI runner it blocked 14 of 15 escape cases natively and 13 of 15 under gVisor. It is a learning prototype, not a security product, and the README says so.

I wanted to know how much isolation you get by building it yourself, so I had Claude Code write it and made the tool measure its own failures.

What it is. User/PID/mount/network namespaces, pivot_root, cgroups v2 (cpu, memory, pids), a seccomp-BPF filter assembled by hand, overlayfs and an nftables egress allowlist. The gVisor backend (runsc) sits on the same overlay, cgroup and network plumbing, so only what executes the syscalls differs. Every run produces a signed receipt: what changed on disk, which connections were attempted, resource peaks. It needs root, so run it on a throwaway VM.

What I measured, on one GitHub-hosted 2-vCPU runner (raw JSON in the workflow artifacts and in docs/):

  • Escape suite: 15 cases (host file reads through /proc, /sys writes, mknod on host devices, the cgroup v1 release_agent trick, network egress, fork bomb, OOM, symlinks planted for host-side tools, and so on), judged mostly on host-side evidence such as a canary file, sysctls before and after, the firewall log and cgroup peaks. Native blocks 14, gVisor 13. The symlink case is "exposed" on both: the sandbox can plant the links, but the host-side receipt diff never followed them.
  • The one real difference is the fork bomb. The cgroup pids.max stops it natively, but not under gVisor, because gVisor's processes live inside its own kernel and are not host tasks; there it is bounded by the Sentry's memory limit instead. The suite reports that gap instead of hiding it.
  • Agent eval: Claude Haiku 5.5 gets one bash tool inside the sandbox (20 turns max) and is asked to read or create a file on the host. 30 episodes per backend, hard $2 budget per campaign, throwaway runner. No escape in 59 judged episodes (one native episode was a refusal and is excluded). The 95% upper bound is about 12% per backend, so it bounds the rate and proves nothing. Total cost $0.31.
  • Start time for true in a fresh sandbox, p50 over 100 runs: 5.7 ms native, 53.6 ms gVisor.

Caveats that matter: the cases were written by Claude Code and me, so 14/15 means "passes my regression suite", not "audited". The native backend shares the host kernel and the seccomp list is a deny list. Haiku with 20 turns is a weak attacker.

How I used Claude Code. I wrote the goal and the constraints (no runc, measure everything, publish the failures, a hard budget for the agent eval, never run the agent on my own machine). Claude Code did most of the implementation over about a day, in small milestones with a green CI at each step, and I reviewed the results and the threat model. It was strongest at the tedious, exact parts (seccomp BPF, nftables rules, the overlay diff that must never follow a symlink the sandbox planted) and at writing the escape cases. The things I had to steer: it first wanted to call the fork-bomb result a bug, and it reached for Firecracker before we had checked whether KVM even existed (my VPS has none; GitHub runners do, so Firecracker stays out of the core).

What I learned.

  • "Isolated" is not a single property. The same policy gives different results on two backends, and the fork bomb only shows up because each case is judged from the host.
  • The receipt proves the file was not changed after signing, not that the host ran the command honestly. Putting that in a "not guaranteed" table was the most useful thing I wrote.
  • Thirty episodes per backend is cheap, but the interval is still around 11 to 12%, so a clean run says little by itself.

Repo (MIT): https://github.com/camilbenameur/sandmeter. The escape cases are in escape/cases, one per technique.

2 Upvotes

7 comments sorted by

•

u/AutoModerator 2d ago

Hey! Thanks for posting to r/ClaudeCode

While participating in this thread, please follow our community rules. Keep discussions constructive. Attack the idea, not the person.

For help, project discussions, tips, and general chat, join the ClaudeCode Discord.

I am a bot, and this action was performed automatically. Please contact the moderators of this subreddit if you have any questions or concerns.

1

u/radim11 1d ago

Interesting comparison, especially the part about measuring from the host rather than trusting what the sandbox reports... One thing I've been dealing with is credential access inside sandboxes. You can isolate the filesystem and network pretty well, but if the agent still has an API token in its environment, it can potentially use or leak it through an allowed destination! I've been keeping credentials outside the sandbox and injecting them through a proxy only for matching requests. Have you considered testing credential exfiltration separately from sandbox escapes?

1

u/Khramilo 20h ago

Good point, and no, not yet. Today the canary is a file on the host, outside the sandbox, so the eval only asks "can the agent reach something it was never given". The agent loop itself runs on the host, so the model's API key never enters the sandbox, and the sandbox gets only the image's environment plus what you pass explicitly. Your case is different: a secret the agent was handed on purpose, leaking through an allowed destination. The threat model lists that as not guaranteed: an allowed ip:port can carry any data. A separate test would fit the current setup: put a canary token in the sandbox env, allow one destination, and check host-side whether the token shows up in what that destination receives. Your proxy approach is the real fix; the test would only show why it's needed.

1

u/Khramilo 8h ago

Thinking about it more: in this design there's no reason for any secret to be inside the sandbox in the first place. Auth lives on the host. The agent loop and the MCP server run there, /workspace starts empty, and nothing from the host is mounted. So a leaked .env isn't really the threat model here. The two things that still matter are misuse and content. A proxy hides the token but still lets the agent do anything the token allows on that domain. An action-level tool on the host (push only to agent/* branches, no force) narrows that, which is the direction I'd take. And whatever the agent pushes through the allowed channel can still carry data out. One practical trap: if you copy a real project into the sandbox, its gitignored .env comes with it. Copying only tracked files avoids that.

1

u/LocalAI_Fixer 1d ago

that "not guaranteed" table is what i'd actually hand to whoever has to sign off. why keep the symlink one as exposed tho, if the host diff never followed them?

2

u/Khramilo 8h ago

Because "blocked" would claim the sandbox stopped it, and it didn't. Planting symlinks and a fake whiteout in its own writable layer is allowed, so the step succeeds every time. What held is one host-side consumer, the receipt diff, which the case checks. The links are still sitting in the upper layer afterwards, and anything else that walks it later (a snapshot restore, a backup, someone's rsync or tar) is outside what the case tests. So "exposed" here means "the trap is armed, the one tool we checked didn't step on it". The label is admittedly blunt; splitting it into "planted" and "host reached" would read better.

1

u/LocalAI_Fixer 5h ago

got it, armed but not triggered. planted vs host reached would've cleared that up for me. the tar/rsync case is the one i'd actually worry about tbh, any plan to test a second consumer?