Skip to content

Allow runner to execute without a sandbox - #720

Merged
epompeii merged 28 commits into
develfrom
u/ep/local-runner
Mar 23, 2026
Merged

Allow runner to execute without a sandbox#720
epompeii merged 28 commits into
develfrom
u/ep/local-runner

Conversation

@epompeii

@epompeii epompeii commented Mar 21, 2026

Copy link
Copy Markdown
Member

This changeset adds the concept of an operating system (os) and sandbox to the spec.

This allows the bare metal runner to be used without Firecracker and non-Linux operating systems.

@github-actions

github-actions Bot commented Mar 21, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

PR: #720
Base: devel
Head: u/ep/local-runner
Commit: acc6801a6b99c6e77b8dfe84822c582a4f837c7e


Now I have a thorough understanding of the PR. Here's my review:


PR Review: Add OS and Sandbox fields to spec, enable non-sandboxed runner execution

Overview

This PR adds os (operating system) and sandbox fields to the spec model, introduces a non-sandboxed execution mode for the runner (executing benchmarks directly on the host), and includes the --danger-allow-no-sandbox CLI flag to gate this mode. ~2900 lines added, ~860 removed across 65 files.


Strengths

Security design is solid:

  • canonicalize_within_rootfs() (local.rs:375-461) correctly re-roots absolute symlinks and clamps .. traversal — this is the right approach for rootfs containment
  • pidfd_open usage (local.rs:228-234) prevents PID reuse races when killing child processes
  • Environment is cleared before execution (local.rs:77), only OCI-derived vars are set
  • --danger-allow-no-sandbox naming makes the security implications unmistakable
  • check_sandbox_allowed() (up/job.rs:16-27) enforces the flag at job execution time with a clear rejection message

Code quality is high and follows project conventions:

  • thiserror used throughout, no anyhow in library/service code
  • #[expect(...)] used instead of #[allow(...)] with reason strings
  • camino::Utf8PathBuf used for all paths
  • Strong types (OperatingSystem, Sandbox, Cpu, Memory) — no stringly-typed values
  • CLI parser in separate parser module, handler logic in runner module
  • No num_args on the --danger-allow-no-sandbox flag

Types and schema well-implemented:

  • OperatingSystem and Sandbox enums follow the established typed_string! macro pattern
  • Migration safely backfills existing rows with 'linux' / 'firecracker' defaults
  • Option<Sandbox> correctly maps to Nullable<Text> in the schema
  • Good test coverage including serde roundtrips and invalid input rejection

Issues

1. Polling loop in wait_with_timeout — busy-wait with 100ms sleep (local.rs:147-172)

The timeout/cancel polling loop sleeps 100ms per iteration. This is functional but means up to 100ms latency on process completion. Consider using child.try_wait() in a loop or wait_with_output on a thread with a Receiver::recv_timeout pattern. Minor issue — acceptable for benchmark workloads.

2. format!("{e}") instead of .to_string() (local.rs:363)

hint: format!("{e}"),

Should be e.to_string()format! with a single Display arg is a clippy lint (to_string_in_format_args). This appears in a few places.

3. dyn Any downcast in panic handler (local.rs:175-179)

let msg = panic
    .downcast_ref::<&str>()
    .copied()
    .or_else(|| panic.downcast_ref::<String>().map(String::as_str))
    .unwrap_or("unknown panic");

CLAUDE.md says "Do NOT use dyn std::any::Any without explicit justification." This is a JoinHandle::join result which inherently returns Box<dyn Any>, so the usage is justified — but it would be good to add a comment noting this is the standard pattern for extracting panic messages from thread::JoinHandle.

4. Migration hardcodes 'firecracker' for all existing specs (up.sql)

All existing specs get sandbox = 'firecracker' — this is correct given that all prior specs were Firecracker-based, but if any spec was created outside the normal flow, the migration will silently assign the wrong sandbox. Consider adding a comment in the migration noting this assumption.

5. collect_output_files silently skips failures (local.rs:481-484, 490-492)

Warnings are printed to stderr but callers have no way to distinguish "no output files requested" from "all output files failed to read." This is a minor observability gap — the current Option<HashMap> return type loses this information.

6. ChildHandle doesn't implement Send/Sync explicitly (local.rs:211-216)

The ChildHandle holds a raw pidfd (i32). Since it's only used from the main thread and kill() is called before handle.join(), this is safe in practice. But if the usage pattern ever changes, consider adding a SAFETY comment or explicit Send impl.


Minor Nits

  • operating_system.rs:86-88 and sandbox.rs:78: Tests use assert_eq!(true, ...) — prefer assert!(is_valid_operating_system("linux")) for readability
  • The WaitOutput struct (local.rs:120-124) could potentially reuse RunOutput to reduce duplication, though the types differ slightly (no output_files)

Verdict

Approve with minor fixes. The security model is well-thought-out, the code follows project conventions closely, and the type system is used effectively to prevent misuse. The issues identified are minor and don't block merging. The most important ones to address are #2 (clippy lint) and adding comments for #3 and #4.


Model: claude-opus-4-6

@github-actions

github-actions Bot commented Mar 21, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchu/ep/local-runner
Testbedubuntu-22.04
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
microseconds (µs)
(Result Δ%)
Upper Boundary
microseconds (µs)
(Limit %)
Adapter::Json📈 view plot
🚷 view threshold
3.92 µs
(+10.49%)Baseline: 3.55 µs
4.68 µs
(83.73%)
Adapter::Magic (JSON)📈 view plot
🚷 view threshold
3.90 µs
(+10.73%)Baseline: 3.52 µs
4.60 µs
(84.73%)
Adapter::Magic (Rust)📈 view plot
🚷 view threshold
24.96 µs
(-2.15%)Baseline: 25.50 µs
31.53 µs
(79.15%)
Adapter::Rust📈 view plot
🚷 view threshold
2.89 µs
(+2.76%)Baseline: 2.81 µs
3.26 µs
(88.49%)
Adapter::RustBench📈 view plot
🚷 view threshold
2.87 µs
(+2.15%)Baseline: 2.81 µs
3.26 µs
(87.93%)
head_version_insert/batch/10📈 view plot
🚷 view threshold
104.83 µs
(+4.11%)Baseline: 100.69 µs
118.74 µs
(88.29%)
head_version_insert/batch/100📈 view plot
🚷 view threshold
246.78 µs
(+3.58%)Baseline: 238.25 µs
265.55 µs
(92.93%)
head_version_insert/batch/255📈 view plot
🚷 view threshold
469.83 µs
(+1.41%)Baseline: 463.32 µs
499.30 µs
(94.10%)
head_version_insert/batch/50📈 view plot
🚷 view threshold
164.95 µs
(+2.15%)Baseline: 161.47 µs
183.04 µs
(90.12%)
threshold_query/join/10📈 view plot
🚷 view threshold
149.98 µs
(+3.18%)Baseline: 145.35 µs
169.62 µs
(88.42%)
threshold_query/join/20📈 view plot
🚷 view threshold
167.65 µs
(+4.84%)Baseline: 159.92 µs
186.54 µs
(89.87%)
threshold_query/join/5📈 view plot
🚷 view threshold
144.48 µs
(+5.10%)Baseline: 137.47 µs
160.38 µs
(90.09%)
threshold_query/join/50📈 view plot
🚷 view threshold
206.85 µs
(+2.80%)Baseline: 201.22 µs
231.48 µs
(89.36%)
🐰 View full continuous benchmarking report in Bencher

claude added 11 commits March 21, 2026 20:34
…nner

Add OperatingSystem and Sandbox validated types to bencher_valid, add both
fields to the spec JSON types and database model, create a table recreation
migration that backfills existing specs with os=linux/sandbox=firecracker,
and replace compile-time three-way cfg dispatch in the runner with runtime
dispatch based on config.sandbox. Non-sandboxed execution is now available
on any platform in release builds.

https://claude.ai/code/session_01Dse1aG6RxsmUg6s2sGdYQy
Reject jobs with sandbox: None at runtime unless the operator explicitly
passes --danger-allow-no-sandbox when starting the runner. This prevents
accidentally running untrusted benchmark code directly on the host.

https://claude.ai/code/session_01Dse1aG6RxsmUg6s2sGdYQy
Address review feedback for the non-sandboxed runner:

- Clear host environment before spawning benchmark (env_clear())
- Set working directory to temp dir for process isolation
- Validate output file paths to prevent host filesystem traversal
- Use dedicated Runtime error variant instead of OutOfRange for
  timeout and cancellation errors

https://claude.ai/code/session_01Dse1aG6RxsmUg6s2sGdYQy
… config

CI fixes:
- Regenerate OpenAPI spec with os/sandbox fields
- Add os/sandbox to CLI spec create/update types
- Pass --sandbox firecracker to existing runner test scenarios

Non-sandboxed execution now unpacks OCI image layers to a temp directory
and runs the command from the unpacked rootfs. No isolation is applied —
this mode is for trusted workloads only.

Extract duplicated OCI config resolution (entrypoint/cmd/env/working_dir)
into shared resolve_oci_config() used by both local.rs and vm.rs.

Add non-sandboxed test scenarios (nosandbox_basic, nosandbox_env,
nosandbox_exit_code) and document sandbox modes in DESIGN.md.

https://claude.ai/code/session_01Dse1aG6RxsmUg6s2sGdYQy
Rename MacOs variant to Macos so snake_case rename produces "macos"
matching the runtime TryFrom/Into impls. Previously the OpenAPI schema
had "mac_os" while serde used "macos".

https://claude.ai/code/session_01Dse1aG6RxsmUg6s2sGdYQy
- Add env_clear() before spawning benchmark in non-sandboxed mode so
  host secrets don't leak into benchmark processes
- Match explicitly on Sandbox::Firecracker in job sandbox check so
  future sandbox types are handled correctly

https://claude.ai/code/session_01Dse1aG6RxsmUg6s2sGdYQy
Update sandbox modes documentation to reflect actual behavior: host
environment is cleared and only OCI-derived variables are set.

https://claude.ai/code/session_01Dse1aG6RxsmUg6s2sGdYQy
- Regenerate TypeScript types with OperatingSystem and Sandbox enums
  (fixes "Check Typescript Types for changes" CI failure)
- Add nosandbox_scratch_image test scenario: FROM scratch with a
  statically-linked binary, matching the common production use case of
  shipping only the benchmark binary in the OCI image

https://claude.ai/code/session_01Dse1aG6RxsmUg6s2sGdYQy
The action bundles the OpenAPI spec; rebuild to include the new
OperatingSystem and Sandbox types.

https://claude.ai/code/session_01Dse1aG6RxsmUg6s2sGdYQy
- Fix OCI storage test failures: update hardcoded "oci" directory references
  to "registry" in blobs, manifests, uploads, and jobs tests
- Fix timeout kill race condition: use PID-based libc::kill instead of
  trying to kill a child already consumed by wait_with_output
- Fix program path traversal: canonicalize and validate program path stays
  within unpacked rootfs
- Fix output file path traversal: resolve OCI file paths relative to
  unpack_dir and validate they don't escape the rootfs
- Expose --os and --sandbox CLI flags in spec create and update commands
- Add From<OperatingSystem> and From<Sandbox> impls for bencher_client types

https://claude.ai/code/session_011ZiX8t1cpGMGQH93zrrVWf
…dir validation

- Close PID reuse window: add `exited` AtomicBool flag set by wait thread
  before returning, checked before signaling to prevent killing recycled PIDs
- Add PATH-based program resolution: bare command names (e.g. `echo`) are
  searched in PATH directories within the unpacked rootfs, matching container
  runtime behavior
- Canonicalize unpack_dir after unpacking for stable containment checks
- Validate and canonicalize working directory stays within rootfs
- Gate `libc::kill` with `#[cfg(unix)]`, add Windows `taskkill` fallback
- Fix nosandbox_exit_code test to match runner process exit behavior

https://claude.ai/code/session_011ZiX8t1cpGMGQH93zrrVWf
@epompeii epompeii changed the title Allow runner to execute without the sandbox for development Allow runner to execute without a sandbox Mar 22, 2026
epompeii and others added 8 commits March 22, 2026 03:56
The hard-coded MAX_SYMLINKS constant (40) in canonicalize_within_rootfs
is now a configurable field on Config, threaded through all call sites
in the local runner and exposed as --max-symlinks on both `runner run`
and `runner up` CLI commands. The default (40) matches the Linux
kernel's MAXSYMLINKS (since 3.18). The CLI arg conflicts with --sandbox
since it only applies to non-sandboxed mode.

https://claude.ai/code/session_01Y2aGgg46htY4AjeXpqGao9
@epompeii
epompeii merged commit 5931c9b into devel Mar 23, 2026
67 checks passed
@epompeii
epompeii deleted the u/ep/local-runner branch March 23, 2026 02:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants