Skip to content

Latest commit

Β 

History

34 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

sbx β€” a hermetic executor that hands coding agents typed, delta-aware feedback

Rust tests lint startup 4.6 ms no root license

A coding agent reads 30 KB of stdout to find one faulting line. This hands it the line.
A no-root Linux sandbox that owns the facts of a run β€” and emits them, plus what changed since last time.

πŸ“˜ Technical report (renders on GitHub) Β Β·Β  πŸ“Š Styled version (live once the repo is public) Β Β·Β  πŸ”¬ Research & prior art


An agent edits code, runs the tests, reads the result, edits again. The reads the result step is where the tokens go β€” 45–84 % of a coding agent's context is tool output (measured across SWE-agent, Terminal-Bench and Claude Code). Every deployed harness shapes that output the same way: truncate the bytes. The obvious fix β€” regex-compress stdout (RTK and ~15 clones, 2026) β€” was independently shown not to reduce billed cost, because an agent that loses information spends extra turns re-discovering it.

sbx takes a different position: the executor already knows whether the process crashed, what the sanitizer caught, which frame faulted, which tests failed and why. So it should emit facts, not bytes β€” and, because it sees consecutive runs, it should emit what changed. It runs your command in a ~4 ms throw-away sandbox (no root, no daemon, no Docker) and returns a typed Verdict + a Delta against the previous run.

See it in 30 seconds

sbx run --work /tmp/demo -- sh -c 'gcc -g -fsanitize=address overflow.c -o o && ./o'
[sbx] CRASH exit=1 wall=174ms raw=2820B
crash: AddressSanitizer heap-buffer-overflow (WRITE of size 4)
  hint: read/write past the end of a heap allocation β€” check the index/length at the frame below
  at /work/overflow.c:9 in fill
  allocated at /work/overflow.c:14 in main
(full log available on request: sbx log)

# ... the agent edits and re-runs:
[sbx] CRASH exit=1 wall=55ms raw=3327B
delta: crash moved heap-buffer-overflow @ overflow.c:9 -> heap-use-after-free @ uaf.c:15

The 2.8 KB ASan report (shadow-byte dump, legend, runtime frames) becomes ~5 lines; the delta: line is the most actionable sentence an agent can read after a re-run. Both come from deterministic parsers β€” no LLM in the loop, no lossy summarisation.

What it is

Spec β†’ hermit-core β†’ Outcome β†’ feedback β†’ Verdict, policy/mechanism split across three crates

Crate Role
hermit-core The mechanism. clone(2) into fresh user / pid / mount / net / uts / ipc / cgroup namespaces, tmpfs root with read-only host binds, pivot_root, seccomp-bpf denylist, loopback up via raw netlink, rlimits, best-effort cgroup v2, no_new_privs, ADDR_NO_RANDOMIZE, fixed environment. No root, no daemon. Every Outcome carries an Applied record saying exactly which isolation held.
feedback The policy. distill(stdout, stderr, facts) -> Verdict (sanitizers β†’ Crash, gcc/clang/rustc β†’ Diagnostics, pytest/cargo-test/ctest/gtest/go-test/jest/vitest β†’ Tests, tracebacks, signals), diff(prev, cur) -> Delta with line-shift-tolerant matching, and a stable text render. Pure functions, fixture-tested.
sbx-cli sbx run | verdict | selftest | bench. run persists the last verdict under <work>/.sbx/ so the next run prints a delta automatically; --json gives the full machine-readable outcome.

The main result: same fix, far less context

Per task, raw stdout vs the typed verdict on a log scale β€” 7 to 20x fewer characters

On a run-before-edit control (Claude Sonnet 4.6, driven through the Claude Code CLI β€” no API key), the typed verdict shows the model 7–20Γ— fewer characters at equal success and equal turns β€” the "same outcome, far less context" that byte-compression is provably unable to deliver. Tool output is most of an agent's context; compressing it 7–20Γ— frees a large share of the window. On a weak local model (Qwen2.5-Coder 3B) the typed verdict also lifts success where raw fails (0 β†’ 3/12).

The experiment that refuted our own hypothesis

We set out to show typed feedback cuts an agent's turns. It doesn't β€” not on a frontier model β€” and finding out why is the most interesting result in the study. We built a causal test: hold the sandbox execution byte-identical and corrupt only the verdict the model is shown β€” surgically move the reported fault line to a plausible, causally-inert decoy, rewritten consistently across the crash frame, stack, tail and diagnostics so the lie has no tell. Then read out which line the agent's first edit actually changes.

Across five tasks the agent's first edit lands on the true fix; the decoy the verdict pointed at is never touched

Across five tasks β€” including a 24-guard "haystack" built specifically to force reliance on the verdict β€” Claude Sonnet 4.6 is 0/7 steered. It never follows the fabricated line (or a fabricated crash class); it repairs the real bug from source, succeeding even with no feedback at all. The executor's localization is not load-bearing here, so corrupting it is inert β€” the frontier model verifies, it does not trust.

That is the boundary, stated as a law: a corrupted or nondeterministic verdict can mislead an agent only where it cannot independently verify from source β€” which is exactly the regime typed feedback targets (large repos, memory bugs whose crash site is far from the cause), and exactly where a correct, deterministic verdict matters most. The full causal design, statistics and threats to validity are in docs/REPORT.md Β§6.7.

Determinism is the precondition, not decoration

A delta is meaningless if addresses move with ASLR, test order with scheduling, and messages with locale. So the executor is hermetic β€” and it is measurable: under the hermetic profile the failing output of four sanitizer tasks is byte-identical across 40/40 runs; with ASLR on, 0/40 (and crash identity itself drifted until it was made address-free). This is why the feedback lives inside the executor, not in a post-hoc filter that never sees two runs.

Security & threat model

sbx runs untrusted code, so its boundary is stated plainly. The default profile β€” user/pid/mount/net namespaces + a single-uid map + pivot_root with the old root detached + a seccomp denylist installed last under no_new_privs β€” contains the mis-configuration classes that matter, and 29/29 escape probes pass on the default flags. The seccomp filter is a layer (kernel-attack-surface reduction + a lock on the mount/namespace calls, x32 ABI variants included), not the boundary itself: --no-seccomp drops the read-only-bind guarantee, and --net (shared host net namespace) / --inherit-env (host env into the child) are opt-in and change the threat model. Without a delegated cgroup v2 subtree there is no memory ceiling (RLIMIT_AS is off so AddressSanitizer can map its 20 TB shadow), so a delegated cgroup is required to bound memory against untrusted code. This is a research sandbox with an honest boundary β€” not a hardened hostile-multi-tenant jail; the full threat-model note is in docs/REPORT.md Β§6.6.

What's inside β€” the concepts on display

Concept Where it lives
Unprivileged Linux sandboxing hermit-core β€” namespaces + tmpfs root + ro host binds + pivot_root, all as a normal user
seccomp-bpf syscall filtering crates/hermit-core/src/seccomp.rs β€” EPERM denylist: io_uring, bpf, ptrace, mount, AF_ALG, ~40 families
Netlink by hand netlink.rs β€” brings loopback UP inside the net namespace with no ip binary
Deterministic execution fixed env, no ASLR, frozen clock/seed β€” the precondition for run-to-run deltas
Typed feedback parsing feedback/src/parse.rs β€” ASan/UBSan/LSan/TSan, gcc/clang/rustc, pytest/cargo/ctest/gtest, go test, Jest/Vitest, JUnit, tracebacks (incl. rich), signals + AST scopes (C/C++/Rust/Python/Go)
Differential feedback feedback/src/delta.rs β€” diff of two runs (crash moved / SAME, tests fixed / broken), matched by AST scope (feedback/src/scope.rs, tree-sitter) so a diagnostic survives a refactor, else line-shift-tolerant
Causal experiment design bench/ β€” byte-identical execution, single-field verdict corruption, first-edit-locus readout, matched controls
Three-tier agent harness bench/agent/agent.py β€” Anthropic SDK Β· Ollama Β· Claude Code CLI backends, list_files/read_file/write_file/run tools, cache-aware cost
Property / fuzz testing 300 mutated inputs β†’ 0 failures; 17 unit/integration tests; clippy -D warnings clean

How it's built

sandbox-executor/
β”œβ”€β”€ crates/
β”‚   β”œβ”€β”€ hermit-core/   # lib.rs (Spec/Outcome/run), child.rs (ns + mounts + exec), cgroup.rs, seccomp.rs, netlink.rs
β”‚   β”œβ”€β”€ feedback/      # lib.rs (Verdict types), parse.rs, delta.rs, render.rs, tests/fixtures/
β”‚   └── sbx-cli/       # the sbx binary
β”œβ”€β”€ adapters/          # claude-code/ (hook) Β· mcp/ (MCP server) Β· lima/ (macOS via a Linux micro-VM)
β”œβ”€β”€ bench/             # experiment harness: agent/, tasks/ (14 validated), swe/ (SWE-bench/ARVO runner), scripts/
β”œβ”€β”€ docs/              # REPORT.md + report.html (the write-up), verdict-schema.md, hermetic-profile.md, figures
└── research/          # prior-art survey, round-2 synthesis, the literature behind every claim

Install

Three ways to get sbx, no repo clone required. Linux only β€” the sandbox is built from user namespaces + seccomp + pivot_root; on macOS / Windows run it inside a Linux VM / WSL2 (see adapters/lima/).

# 1. Pre-built binary from a tagged GitHub Release
curl -L https://github.com/RARS-oss/sbx/releases/latest/download/sbx-v0.1.0-x86_64-unknown-linux-gnu.tar.gz \
  | tar xz && ./sbx selftest

# 2. From crates.io (installs the `sbx` binary)
cargo install sbx-cli

# 3. Docker / OCI β€” the image ships with the toolchains sbx sandboxes (gcc, clang, python3)
docker run --security-opt seccomp=unconfined ghcr.io/rars-oss/sbx \
  run -- sh -c 'gcc -fsanitize=address a.c && ./a.out'

--security-opt seccomp=unconfined lets sbx create its own namespaces (Docker's default profile blocks the clone/unshare flags); sbx then installs its own seccomp filter inside. The Release binary and the GHCR image are both produced by .github/workflows/release.yml on a v* tag, and the three crates carry crates.io metadata (hermit-core and feedback pass cargo publish --dry-run). These three paths go live the moment the first v0.1.0 tag is pushed and the crates are published β€” the plumbing is wired and verified; see docs/RELEASING.md.

Build & test

cargo test --workspace                    # 27 tests
cargo run -p sbx-cli -- selftest          # prints what isolation held on this kernel
cargo run -p sbx-cli -- bench --n 30      # startup latency
cargo run -p sbx-cli -- run --work /tmp/x -- sh -c 'gcc -fsanitize=address a.c && ./a.out'
cargo run -p sbx-cli -- run --work /tmp/x --json -- pytest -q     # full outcome + verdict + delta
cargo run -p sbx-cli -- run --work /tmp/w --overlay /path/to/repo -- go test ./...  # big repo, not copied
cargo run -p sbx-cli -- run --work /tmp/x --repeat 10 -- pytest -q     # flaky? 10 runs, flag nondeterminism

Requirements: Linux with unprivileged user namespaces (default on Ubuntu / Fedora / WSL2). On WSL2, cgroup limits need a delegated cgroup (systemd user session); without it sbx says so in applied.cgroup and falls back to rlimits. Building on Windows: develop in WSL2 and point CARGO_TARGET_DIR at a native path for speed.

Status & what's next

Working today (verified on WSL2 6.6 and stock Ubuntu, unprivileged): the sandbox, the hermetic profile, seccomp + loopback, the parsers and delta, the Claude Code hook, the three-tier harness, 14 validated C/C++/Rust/Python repair tasks, and the causal study above. The open frontier is real-world-scale repair (SEC-bench / ARVO / multi-file), where a strong model must iterate β€” the regime in which the turn- and cost-reductions this design predicts become measurable on a frontier model.

Roadmap

Ordered by value Γ— tractability. Items marked † were independently recommended by the round-2 research in research/.

Prove it at scale β€” the decisive experiment.

  • Real benchmarks: SWE-bench Β· ARVO Β· SEC-bench β€” runner shipped (bench/swe/). † swe_run.py runs the agent loop on multi-file repo instances (a list_files tool was added for exploration) and reports pass/turns/tokens per condition; a synthetic instance ships, and bench/swe/README.md shows how to point it at the real SWE-bench (git checkout + test_patch) or ARVO (sanitizer PoC as the oracle). Running the full datasets (per-instance envs) is the remaining infra step. The #1 measurement.
  • overlayfs β€” shipped as sbx run --overlay <repo>. † The repo is the read-only lower (mounted, not copied); the agent's edits land in <work>/upper, the repo untouched. Enables the large-repo benchmarks above, and is the seam for COW snapshot/fork of the work dir.

Make it adoptable.

  • MCP server β€” shipped, with structured output (adapters/mcp/). † Claude Desktop / Cursor / Windsurf / Roo Code attach sbx with one config block. The run tool returns the Verdict as MCP structuredContent (typed {status, crash{kind,first_user_frame,offset}, diagnostics[], tests{}, delta{}, rendered}) behind an outputSchema β€” the client reads the error as an object, not a text blob.

  • More language parsers β€” shipped. Go (go test), Node/TS (Jest, Vitest), JVM (JUnit β€” Maven Surefire + Gradle), and rich Python tracebacks, on top of pytest/unittest/cargo/ctest/gtest. Each is a self-contained parser in feedback/parse.rs with a fixture test (pytest-xdist / uv emit standard pytest output, already handled).

  • Cross-platform via Lima / WSL β€” config shipped (adapters/lima/). A persistent Linux micro-VM behind the CLI (macOS Virtualization.framework via Lima; Windows already runs through WSL) so sbx behaves the same everywhere. Native namespaces don't exist off Linux, so this is "ship a fast persistent VM"; the Lima config

    • wrapper are authored to mirror the tested WSL path (not yet exercised on macOS in CI).
  • Install without compiling β€” shipped. A v* tag builds a stripped Linux binary (GitHub Releases) and a slim multi-stage OCI image to GHCR, both via .github/workflows/release.yml. All three crates carry crates.io metadata; hermit-core and feedback pass cargo publish --dry-run (sbx-cli verifies after they're on the index β€” standard publish ordering). So cargo install sbx-cli, docker run ghcr.io/rars-oss/sbx, and a curl-able tarball all light up on first release β€” see Install and docs/RELEASING.md.

Sharper feedback.

  • Run-memory & anti-loop signals β€” shipped (feedback/src/history.rs, sbx history). Beyond the one-step delta, sbx keeps a per-work-dir history of runs (.sbx/history.jsonl) keyed by scope-stable crash/diagnostic identities, and derives anti-loop signals β€” loop: 3 runs in a row with the SAME verdict β€” recent edits changed nothing and loop: heap-use-after-free @ sum() has survived 4 runs β€” likely a red herring, not the fix site. sbx history prints the timeline + per-problem evolution ("appeared run 2, fixed since run 6"). This is the executor as external execution memory β€” something a strong model can't reconstruct from a single stdout (it never sees its own past runs), and a red-herring guard raw output can't give. Surfaced in the CLI, sbx run --json (loop_signals), and the MCP run tool.
  • Tree-sitter semantic deltas β€” shipped for C, C++, Rust, Python, Go (feedback/src/scope.rs). † Each diagnostic is AST-enriched with its enclosing function; the delta matches by scope so a diagnostic (or a whole stack frame) survives a refactor that moves its line far β€” a Β±3-line window would call it "1 fixed + 1 new".
  • Print the offending value β€” shipped (deterministic path). † The crash verdict now carries the sanitizer's access geometry as offset: N bytes after an M-byte region β€” the offending value for a bounds bug (how far out, how big the buffer), address-stripped so it stays stable, no debugger or eBPF. Dynamic tracing of arbitrary call arguments via an eBPF uprobe layer is the ambitious version β€” it needs CAP_BPF and runs outside the sandbox tracing the target (the seccomp denylist blocks bpf inside it).

Research-grade / later.

  • Race / nondeterminism detection β€” shipped as sbx run --repeat N: runs N times and compares the semantic verdict (status, crash identity, failing tests, diagnostics β€” not wall time), flagging a flaky test or a race as "K distinct verdicts across N runs". It detects presence, not absence β€” the honest, determinism-consistent form; full record/replay stays rr/Hermit territory.

MIT. Built as a systems + ML-research portfolio piece; the reasoning and every cited number live in docs/REPORT.md.

About

Hermetic sandbox executor that returns typed, delta-aware feedback to coding agents (Rust). Parsed compiler/sanitizer/test verdicts + run-to-run deltas instead of raw stdout.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages