Skip to content

Command sandbox: confine untrusted native command execution (Landlock + seccomp)#73

Open
ericeil wants to merge 3 commits into
masterfrom
eric/sandbox
Open

Command sandbox: confine untrusted native command execution (Landlock + seccomp)#73
ericeil wants to merge 3 commits into
masterfrom
eric/sandbox

Conversation

@ericeil

@ericeil ericeil commented Jul 14, 2026

Copy link
Copy Markdown

What this is

A standalone command-sandbox mechanism — a Python seam + a small trusted Rust launcher — that confines a single command to an unprivileged, in-process kernel sandbox (Landlock + seccomp). It is the first of the stacked PRs splitting eric/crucible (see docs/pr-split-plan.md).

from composer.sandbox import run_local_command, rust_build_policy, SandboxPolicy

It has no composer dependencies outside composer.sandbox and is stdlib-only, so it can land and be consumed ahead of everything else on the branch.

Why this code exists

For the Solana ecosystem, AutoProver will need to run commands that compile and/or execute untrusted native code — LLM-authored harnesses (setup/action_*/build.rs), the analyzed program's own build.rs/proc-macros, and/or the Cruicible fuzzing harness, for example. Without a sandbox, those will run with the full ambient authority of the AutoProver process: ANTHROPIC_API_KEY, CERTORA*/AWS_* cloud tokens, PG* DB creds, the network route to 169.254.169.254 (IMDS → IAM role creds on EC2), and the entire bind-mounted host project.

The outer container protects the host from AutoProver. It does not protect AutoProver's own secrets, egress, and filesystem from code running inside it. A separate trust boundary already ensures the LLM authors only file contents, never the command line — but that says nothing about what a command, once running, can reach. This mechanism is that missing boundary: each RunCommand runs with no network, no inherited secrets, and only its declared inputs on disk, proven by an escape test.

Why we can't use an existing solution

Namespace sandboxes (bwrap / nsjail) — they fight the container. Their model (unprivileged user + mount + net namespaces, then pivot_root) is exactly what Docker's default seccomp + AppArmor block. Validated empirically (stock python:3.12-slim, uid 1000):

Approach under Docker defaults Outcome
unprivileged bwrap ✗ userns creation blocked by default seccomp
bwrap, seccomp=unconfined mount --make-rslave blocked by AppArmor docker-default
bwrap, seccomp=unconfined + apparmor=unconfined ✓ works — but requires weakening the whole container's LSMs (rejected)
setuid bwrap capset blocked (Docker drops CAP_SETPCAP)

Making bwrap work means either stripping the container's own seccomp/AppArmor (widening the host-kernel attack surface across all of AutoProver — the opposite of the goal) or adopting a heavier runtime.

gVisor / Kata — wrong cost, wrong layer. They work, but (a) they impose their heaviest overhead precisely on our syscall/I/O-bound compile+fuzz workload, and (b) their benefit — protecting the host kernel — is an infrastructure boundary already provided on EC2 by the Nitro hypervisor. Not worth coupling this boundary to a deployment/runtime decision.

Off-the-shelf Landlock tools — close, but each misses the hard part.

  • landrun (Go, MIT): great for Landlock FS + env, but blocks network via Landlock TCP-only rules (kernel ≥6.7) — it does not block UDP/DNS, fails open on older kernels, and has no rlimits. It would need a seccomp companion anyway, so it doesn't save the hard part.
  • sandlock (Python+Rust, Landlock+seccomp): the closest match, but requires kernel ≥6.12 (above Amazon Linux 2023's 6.1), ships an unstated license, and carries far more surface than we need (MITM proxy, COW, notification supervisor).

The chosen model: the process sandboxes itself

Instead of building a namespace around the command, the command restricts itself using two unprivileged kernel facilities — the model Chrome, OpenSSH, and systemd use. It needs no namespaces, no capabilities, no root, and no --security-opt, and runs in a stock container. The launcher composes two mature, permissively-licensed crates rather than hand-rolling primitives:

  • landlock — default-deny FS ruleset; grant rw to the workdir, r+x to toolchain paths; closes the /proc/<parent>/environ secret leak.
  • seccompiler (AWS Firecracker's seccomp-BPF compiler) — deny inet sockets (TCP and UDP/DNS and IMDS) and the same-uid secret vectors (ptrace, process_vm_readv).
  • plus an env allowlist (scrubbed execve) and rlimits (RLIMIT_AS/CPU/NPROC/FSIZE).

Both confinements are preserved across execve and inherited across fork, so every descendant (cargo, rustc, each build.rs, the linker) runs confined. It wins for now on kernel floor (5.13), license clarity, and minimal surface — and the SandboxProvider seam keeps landrun/sandlock swappable later with no change to the policy or the gate. Because it needs nothing from the container, the identical code path runs on a dev laptop, EC2, ECS, EKS, Fargate, and under runc or gVisor alike.

What's in the PR (20 files, +2.4k)

  • Python composer/sandbox/ (stdlib-only): policy.py (tool-agnostic seam: SandboxPolicy/SandboxProvider/LaunchSpec, none passthrough, provider registry, fail-closed helpers), command.py (run_local_command — the RunCommand primitive), launcher.py (the launcher provider), recipes.py (rust_build_policy + DEFAULT_ENV_PASSTHROUGH), config.py.
  • Rust rust/run-confined/: the trusted launcher; workspace root trimmed to members = ["run-confined"] (the pyo3 framework crates rejoin in later PRs), Cargo.lock regenerated for that subset.
  • Packaging/docs: scripts/Dockerfile builds run-confined onto PATH; .dockerignore/.gitignore exclude the Rust build output; docs/command-sandbox.md (full design + threat model).

Testing

tests/test_sandbox{,_command,_config,_launcher,_run_confined,_escape}.py42 passed, 0 skipped. The escape gate exercises every vector (write outside workdir, read host files, read /proc/<parent>/environ, ptrace the parent, open an inet socket) — all denied — plus an unconfined control. The run-confined binary was built and genuinely exercised (Landlock ABI present on the CI kernel), not skipped.

Notes for reviewers

  • Base: branches from a clean ancestor of master; verified zero file overlap with the commits master has since gained, so it merges cleanly and the diff shows only the 20 sandbox files.
  • Stacked-PR context: this is PR 1 of 4 (sandbox → ecosystem → rust framework → crucible app). The framework/Crucible consumers land in later PRs; this PR stands alone.

ericeil and others added 3 commits July 14, 2026 15:50
Self-contained command-sandbox mechanism, extracted from eric/crucible as the
first of the 4 stacked PRs (docs/pr-split-plan.md). Both sides ship together so
a parallel Python project can adopt it immediately:

  from composer.sandbox import run_local_command, rust_build_policy, ...

Python (composer/sandbox/, stdlib-only, no composer deps outside itself):
  - policy.py   tool-agnostic seam: SandboxPolicy / SandboxProvider / LaunchSpec,
                the `none` passthrough, the provider registry, fail-closed helpers
  - command.py  run_local_command — the RunCommand primitive (materialize input
                files into a workdir, run a command there); the LLM controls only
                file contents, never the command line
  - launcher.py the `launcher` provider: maps a policy to a run-confined invocation
                ($RUN_CONFINED_BIN -> PATH -> repo build dir)
  - recipes.py  rust_build_policy + DEFAULT_ENV_PASSTHROUGH (the shared "compile/run
                Rust" confinement recipe, usable by Rust and future Python backends)
  - config.py   SandboxConfig

Rust (rust/run-confined): the trusted launcher — Landlock filesystem + seccomp
network/ptrace + rlimits + scrubbed env, then execve. Workspace root trimmed to
members = ["run-confined"] (the pyo3 framework crates rejoin in later PRs);
Cargo.lock regenerated for that subset.

Packaging: scripts/Dockerfile builds run-confined into the image on PATH;
.dockerignore + .gitignore exclude the Rust build output.

Docs: docs/command-sandbox.md.

Gate: tests/test_sandbox{,_command,_config,_launcher,_run_confined,_escape}.py
— 42 passed (escape gate: all vectors denied + unconfined control; run-confined
confinement exercised, not skipped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the raw landlock_create_ruleset syscall in --probe with the same
BestEffort ruleset negotiation apply_landlock uses, inspecting RulesetStatus.
Drops the unsafe block and the hand-rolled LANDLOCK_CREATE_RULESET_VERSION
constant; the crate deliberately hides the numeric ABI, so probe now reports
the enforcement status instead. Python's available() only checks the exit
code and stderr, so the stdout change is safe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The launcher's --probe no longer reports the numeric Landlock ABI (the crate
hides it); it builds a best-effort ruleset and reports whether Landlock
actually enforces. Update §7 and §9 step 2 to match, following the switch of
probe() to the crate's public API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ericeil ericeil changed the title Command sandbox: confine untrusted native RunCommand execution (Landlock + seccomp) Command sandbox: confine untrusted native command execution (Landlock + seccomp) Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant