Command sandbox: confine untrusted native command execution (Landlock + seccomp)#73
Open
ericeil wants to merge 3 commits into
Open
Command sandbox: confine untrusted native command execution (Landlock + seccomp)#73ericeil wants to merge 3 commits into
ericeil wants to merge 3 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(seedocs/pr-split-plan.md).It has no
composerdependencies outsidecomposer.sandboxand 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 ownbuild.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 to169.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
RunCommandruns 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 (stockpython:3.12-slim, uid 1000):bwrapbwrap,seccomp=unconfinedmount --make-rslaveblocked by AppArmordocker-defaultbwrap,seccomp=unconfined+apparmor=unconfinedbwrapcapsetblocked (Docker dropsCAP_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>/environsecret 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).execve) and rlimits (RLIMIT_AS/CPU/NPROC/FSIZE).Both confinements are preserved across
execveand inherited acrossfork, so every descendant (cargo,rustc, eachbuild.rs, the linker) runs confined. It wins for now on kernel floor (5.13), license clarity, and minimal surface — and theSandboxProviderseam keepslandrun/sandlockswappable 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 underruncor gVisor alike.What's in the PR (20 files, +2.4k)
composer/sandbox/(stdlib-only):policy.py(tool-agnostic seam:SandboxPolicy/SandboxProvider/LaunchSpec,nonepassthrough, provider registry, fail-closed helpers),command.py(run_local_command— theRunCommandprimitive),launcher.py(thelauncherprovider),recipes.py(rust_build_policy+DEFAULT_ENV_PASSTHROUGH),config.py.rust/run-confined/: the trusted launcher; workspace root trimmed tomembers = ["run-confined"](the pyo3 framework crates rejoin in later PRs),Cargo.lockregenerated for that subset.scripts/Dockerfilebuildsrun-confinedontoPATH;.dockerignore/.gitignoreexclude the Rust build output;docs/command-sandbox.md(full design + threat model).Testing
tests/test_sandbox{,_command,_config,_launcher,_run_confined,_escape}.py→ 42 passed, 0 skipped. The escape gate exercises every vector (write outside workdir, read host files, read/proc/<parent>/environ,ptracethe parent, open an inet socket) — all denied — plus an unconfined control. Therun-confinedbinary was built and genuinely exercised (Landlock ABI present on the CI kernel), not skipped.Notes for reviewers
master; verified zero file overlap with the commitsmasterhas since gained, so it merges cleanly and the diff shows only the 20 sandbox files.