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.
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.
| 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. |
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).
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 β 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.
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.
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.
| 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 |
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
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.
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 nondeterminismRequirements: 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.
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.
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.pyruns the agent loop on multi-file repo instances (alist_filestool was added for exploration) and reports pass/turns/tokens per condition; a synthetic instance ships, andbench/swe/README.mdshows 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 assbx 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. Theruntool returns the Verdict as MCPstructuredContent(typed{status, crash{kind,first_user_frame,offset}, diagnostics[], tests{}, delta{}, rendered}) behind anoutputSchemaβ 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), andrichPython tracebacks, on top of pytest/unittest/cargo/ctest/gtest. Each is a self-contained parser infeedback/parse.rswith a fixture test (pytest-xdist /uvemit 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) sosbxbehaves 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-coreandfeedbackpasscargo publish --dry-run(sbx-cli verifies after they're on the index β standard publish ordering). Socargo install sbx-cli,docker run ghcr.io/rars-oss/sbx, and acurl-able tarball all light up on first release β see Install anddocs/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 nothingandloop: heap-use-after-free @ sum() has survived 4 runs β likely a red herring, not the fix site.sbx historyprints 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 MCPruntool. - 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 needsCAP_BPFand runs outside the sandbox tracing the target (the seccomp denylist blocksbpfinside 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.