Command sandbox: confine untrusted native command execution (Landlock + seccomp)#73
Command sandbox: confine untrusted native command execution (Landlock + seccomp)#73ericeil wants to merge 21 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>
| The tension: `cargo build` needs its dependency crates, but the sandbox has no network. Resolution | ||
| splits cleanly along the code-execution line: | ||
|
|
||
| - **`cargo fetch` / `cargo vendor` download but never run build scripts** — no untrusted code |
There was a problem hiding this comment.
in Solidity AP we already have a split between the 'dependency fetch' task and actual building, using a separate builder container that is not in this repo. does it make sense to move cargo fetch steps into the builder repo as well? let's sync over DM
There was a problem hiding this comment.
Oh, that's interesting. Yes, if we can build the user's code separately in a more locked-down container, that would be ideal. I think we will still need the sandbox (both in the "builder" container, and later when we build the LLM-generated code), but some more defense-in-depth would be great here. Will track this as a separate workitem for now.
Deny io_uring and non-AF_UNIX sockets in seccomp, install Landlock scopes (signals/abstract UDS) and TCP default-deny, grant only shared cargo bin/ (not credentials.toml), and extend the escape suite.
The seccomp deny-list keyed on exact x86_64 syscall numbers was bypassable via the x32 ABI: an x32 call runs under the same AUDIT_ARCH_X86_64 identity (so seccompiler's arch guard passes it), but its syscall number is OR'd with __X32_SYSCALL_BIT (0x4000_0000), so it misses every exact-number rule and hits default-allow — a full bypass of the socket/io_uring/ptrace/process_vm denies. This matters most on kernels < 6.7 (the AL2023 6.1 target), where Landlock does no network filtering and seccomp is the sole network control, so the bypass is unconditional egress to the network + IMDS. Fix: mirror every deny onto its x32-tagged syscall number in apply_seccomp (x86_64 only; aarch64 has no per-syscall compat bit). Deriving the mirror from the already-built rule map means any future deny is covered automatically. Regression test records the errno of an x32 socket() attempt and asserts EPERM (seccomp caught it) rather than ENOSYS (kernel rejected it after the filter let it through) — a real guard even on x32-disabled CI kernels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ericeil
left a comment
There was a problem hiding this comment.
Security audit summary
Focused review of PR #73 against the guarantees in docs/command-sandbox.md §2:
- No network (incl. DNS / IMDS)
- No secrets (env scrub + no out-of-band recovery)
- Minimal filesystem
- Resource caps
- Offline builds
I probed a live run-confined binary on this host (kernel 7.0, Landlock enforcing). Several intended vectors are correctly closed (env scrub, Landlock outside-workdir, /proc/<ppid>/environ, ptrace/process_vm_*, hardlink-out, direct socket(AF_INET)).
However, guarantee #1 is fully breakable today, and several same-uid control-plane gaps remain. Highest severity first.
Issue counts by severity
- bugs (security): 4
- suggestions: 2
- nits: 1
Verified with live probes
| Vector | Result under sandbox |
|---|---|
socket(AF_INET) |
DENIED (EPERM) — as designed |
io_uring IORING_OP_SOCKET + CONNECT to 1.1.1.1:80 |
SUCCESS — full network bypass |
| io_uring connect to 169.254.169.254:80 | SUCCESS (TCP connect) |
| Abstract Unix socket to same-uid listener outside sandbox | SUCCESS |
kill(SIGKILL) any same-uid process |
SUCCESS |
Read planted file under RO-granted cargo home (credentials.toml) |
SUCCESS |
AF_NETLINK / AF_VSOCK socket() |
SUCCESS |
| Landlock outside file via normal open / io_uring OPENAT | DENIED |
| Env canary / ptrace / process_vm_readv | DENIED |
Bottom line: untrusted code inside the sandbox can reach the network (including IMDS) today via io_uring, despite the seccomp deny-list on socket(). That is a direct violation of the stated threat model.
| The tension: `cargo build` needs its dependency crates, but the sandbox has no network. Resolution | ||
| splits cleanly along the code-execution line: | ||
|
|
||
| - **`cargo fetch` / `cargo vendor` download but never run build scripts** — no untrusted code |
There was a problem hiding this comment.
Oh, that's interesting. Yes, if we can build the user's code separately in a more locked-down container, that would be ideal. I think we will still need the sandbox (both in the "builder" container, and later when we build the LLM-generated code), but some more defense-in-depth would be great here. Will track this as a separate workitem for now.
Landlock + seccomp are kernel-mediated and a container shares the host kernel, so ubuntu-latest cannot validate run-confined on the 6.1 kernel we ship on. This job boots the real Amazon Linux 2023 kernel-6.1 KVM cloud image under QEMU/KVM (SHA256-verified, UEFI/OVMF, cloud-init SSH seed) and runs tests/test_sandbox_escape.py inside it. On 6.1 the suite exercises the 6.1 contract specifically: Landlock FS enforced, network seccomp-only (no Landlock net < 6.7), scopes absent (< 6.12, so the signal / abstract-UDS vectors self-skip), and the x32 deny-mirror asserted regardless. The guest is kept dependency-light: the suite is stdlib + pytest only, so we install pytest via uv on PYTHONPATH and pass --noconftest to skip tests/conftest.py's heavy imports (its fixtures are all in-module). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "copy repo into the VM" step tar'd the whole workspace, which by that point holds the running VM's al2023.qcow2 (QEMU actively writing it) plus seed.iso / serial.log / repo.tgz — tar aborts with "file changed as we read it". Use git archive HEAD instead: a clean snapshot of tracked source, which is all the escape suite needs and structurally cannot include the runtime artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
actions/checkout@v4 and upload-artifact@v4 run on the deprecated node20 runtime, which emits a deprecation warning now that the runner defaults to node24. Bump both to v5 (first major on node24) to clear it; our usage (plain checkout, path-list upload) is unaffected by the majors' breaking changes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ressions Negative control for the sandbox-escape (kernel 6.1) workflow. Disables the seccomp socket() network block so socket(AF_INET/AF_NETLINK/AF_VSOCK,…) succeeds, breaking the "No network" guarantee. The escape suite must go RED (inet_sock / netlink / vsock / net_ext / imds leak) — proving the CI fails when the sandbox is broken, not just when it works. REVERT THIS COMMIT before merging. Verified locally: run-confined builds and tests/test_sandbox_escape.py fails on the network probes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ches regressions" This reverts commit 6f965c1.
The base image baked the run-confined command-sandbox launcher onto PATH unconditionally (Dockerfile stage 1b + a COPY into /usr/local/bin). Not every AutoProver container needs the sandbox, so pull it out of the base image and provide it as a runtime-mounted, opt-in building block instead. - scripts/Dockerfile: drop stage 1b and the run-confined COPY; leave a pointer to the overlay. - scripts/Dockerfile.sandbox: builds run-confined in a throwaway Rust stage and publishes the binary into a mounted volume via a minimal bookworm-slim image. - scripts/docker-compose.sandbox.yml: overlay with a one-shot run-confined-build service that populates a run_confined named volume, plus the wiring onto the autoprove service (read-only mount + RUN_CONFINED_BIN, gated behind the builder via service_completed_successfully). Consumers that don't add this overlay get a sandbox-free image. - docs/command-sandbox.md: update the §9 note — the launcher is resolved via $RUN_CONFINED_BIN (overlay-mounted) rather than baked into the image. The launcher provider already resolves $RUN_CONFINED_BIN ahead of PATH (composer/sandbox/launcher.py), so no code change is needed. Verified end-to-end: the publisher lands a 0755 ELF in the volume and `run-confined --probe` reports `landlock FullyEnforced` from a read-only mount. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Did security reviews and testing with both Grok and Opus, and fixed the problems they found. Added a CI test to verify the sandbox against Amazon Linux 2023. |
chandrakananandi
left a comment
There was a problem hiding this comment.
Had a couple of small questions.
|
|
||
| while i < argv.len() { | ||
| match argv[i].as_str() { | ||
| "--rw" => cfg.rw_paths.push(PathBuf::from(take(&mut i, "--rw")?)), |
There was a problem hiding this comment.
why not use Clap: https://rust-cli.github.io/book/tutorial/cli-args.html#parsing-cli-arguments-with-clap? That's usually the way to do CLI arg parsing in rust. Maybe ask claude about it?
There was a problem hiding this comment.
Yep, that's probably a better choice
| Err("missing `--` and command to run".to_string()) | ||
| } | ||
|
|
||
| fn set_rlimits(cfg: &Config) { |
There was a problem hiding this comment.
do we know what these limits are needed for?
There was a problem hiding this comment.
Their meanings are described in comments on the new Clap-based CLI options. These are here so we can e.g. limit the memory usage of the Crucible process so it doesn't interfere with the main service. I suspect we'll want more control over this some day, but these particular limits are easy to implement. :)
| def as_observation(self) -> dict: | ||
| """The ``Observation::CommandResult`` payload the IoC loop feeds back to Rust.""" | ||
| return { | ||
| "exit_code": self.exit_code, | ||
| "stdout": self.stdout, | ||
| "stderr": self.stderr, | ||
| } |
| # Belt-and-suspenders: the resolved path must still live under the workdir. | ||
| try: | ||
| target.resolve().relative_to(workdir.resolve()) | ||
| except ValueError as e: | ||
| raise UnsafePath(f"file path {rel!r} resolves outside the workdir") from e | ||
| return target |
There was a problem hiding this comment.
it seems like this check is sufficient andyou can skip the p.is_absolute / .. in p.parts check above?
|
|
||
| ``workdir`` persists across calls (a session materializes its crate once and | ||
| runs several commands against it). Concurrency is bounded by ``sem`` when | ||
| given — important because fuzzers are resource-hungry. |
There was a problem hiding this comment.
claude loves to narrate these pointless "why" and "what we didn't do" details. No one needs this justification.
| ``CARGO_HOME``); the sandboxed path's env is fully governed by the provider/policy. | ||
| """ | ||
| prov: SandboxProvider = provider if provider is not None else NoneProvider() | ||
| ensure_available(prov) # fail-closed: raises before running if it can't confine |
There was a problem hiding this comment.
claude loves to keep mentioning this fail-closed term, I have never heard of it.
There was a problem hiding this comment.
Heh, yeah, I wondered about that. Says Claude:
"Fail-closed" is a security design principle: when the safety mechanism can't do its job, the system refuses to run rather than running unprotected.
Here at command.py:122, ensure_available(prov) checks whether the selected sandbox provider can actually confine the child process (e.g. for the launcher provider: is run-confined present, does the kernel support Landlock + seccomp, etc.). If it can't, ensure_available raises SandboxUnavailable before the command is ever spawned. So the command never runs unsandboxed.
The contrast is fail-open: if confinement were unavailable, quietly fall back to running the command with no sandbox. That's the dangerous default — you'd get a working command and silently lose the security boundary, which is exactly what you don't want for something running potentially-untrusted toolchains.
| if sem is not None: | ||
| async with sem: | ||
| return await _run() | ||
| return await _run() |
There was a problem hiding this comment.
more compact
async with (sem if sem is not None else contextlib.nullcontext)):
return await _run()
or, if you prefer to abuse or syntax:
async with (sem or contextlib.nullcontext):
...
but I personally hate abusing or like this
| ok: bool | ||
| reason: str = "" |
There was a problem hiding this comment.
this lets you expression nonsense like Availability(ok=True, reason="everything is broken"). Python typing does allow sum types, so you could go type Availability = Literal["ok"] | Reason where:
@dataclass
class Reason
reason: str
or sth
| root = Path(cargo_home) | ||
| out: list[Path] = [] | ||
| bin_dir = root / "bin" | ||
| if bin_dir.is_dir(): | ||
| out.append(bin_dir) | ||
| return tuple(out) |
| home / ".cache" / "solana", | ||
| home / ".local" / "share" / "solana", | ||
| ] | ||
| ro_candidates += list(extra_ro) |
… codes Replace the hand-rolled argv loop with a clap derive `Cli` that lowers into the existing `Config`. `--probe` is still handled before clap (it short-circuits into a self-restricting kernel check and must not trip the required-command rule); `--allow-env`'s NAME / NAME=VALUE semantics are preserved. Exit codes now follow the `env`/`timeout` convention so the launcher's own failures stay out of the wrapped command's status range: 125 launcher failed (bad argv OR sandbox unavailable) — fail-closed 126 command found but not executable (exec != ENOENT) 127 command not found (exec == ENOENT) else the child's status passes through the execve. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add CommandResultObservation TypedDict for the as_observation() payload. - Drop the lexical absolute/`..` guard in _confined_target; the resolve().relative_to() check already subsumes it (and catches symlink escapes too). - Collapse the sem branch via contextlib.nullcontext(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a SandboxArgs TypedDict (NotRequired fields mirroring the overridable SandboxConfig fields) and type from_env(**overrides) with Unpack[SandboxArgs], so unknown/mistyped keyword overrides are caught by the type checker instead of at runtime. Return Self rather than a quoted forward reference. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…istry Declare the command-sandbox providers as composer.sandbox_providers entry points (pyproject.toml) and resolve them by name in SandboxConfig.resolve_provider, importing the selected mechanism's module lazily. This drops the in-module registry (_PROVIDERS dict, register_provider, get_provider) and the launcher's import-time self-registration, so the policy seam no longer references any concrete mechanism at all. Net: provider resolution lives in one place; the launcher module is imported only when the "launcher" provider is actually constructed (verified). Note: entry points are baked into install metadata, so environments must be (re)installed (uv pip install -e .) for the providers to resolve. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Rust-backend handoff (`SandboxConfig.backend_spec`) used to serialize the policy field-by-field (`ro`/`rw`/`allow_env`/`network`/`rlimits`) plus the launcher `binary` path, so the backend had to reassemble a run-confined argv itself — duplicating the flag-mapping that `LauncherProvider.wrap` already owns and pinning the wire shape to one mechanism. Add `SandboxProvider.argv_prefix(policy)` — the mechanism-agnostic confinement wrapper up to the `--` separator — and express `wrap` as `[*argv_prefix, program, *args]`. `backend_spec` now ships just that opaque prefix (empty for the `none` passthrough) plus `timeout_s`, typed as the new `BackendSpec` TypedDict. The backend prepends it with no knowledge of the sandbox tool, so adding a provider is a pure-Python change on both launch paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`LauncherProvider.available` shelled out to `run-confined --probe` via a blocking `subprocess.run(timeout=10)` — stalling the event loop for up to 10s on the async command path (`run_local_command` → `ensure_available`). Make the probe async end-to-end: `SandboxProvider.available` (and `NoneProvider`, `ensure_available`) are now coroutines, and the launcher uses `asyncio.create_subprocess_exec` + `asyncio.wait_for`. Behavior is unchanged — OSError fails closed, a probe timeout kills the child and propagates. `SandboxConfig.backend_spec` becomes async to await the check (no production callers yet). Tests await the seam; the two collection-time `skipif` guards drive the coroutine with `asyncio.run`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Availability(ok=bool, reason=str)` let you build the nonsensical `Availability(ok=True, reason="...")` — an available result carrying a reason. Replace it with `Availability = Literal["ok"] | Reason`, where a `Reason` holds the explanation and the `"ok"` arm has nowhere to put one, so the illegal state is unrepresentable. `ensure_available` discriminates on `avail != "ok"`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`name` is only ever read, so declare it as a read-only `@property` in the protocol (implementers still back it with a plain class attribute). Drop `@runtime_checkable`: the only isinstance check was one conformance test, and a runtime_checkable protocol only verifies member *presence*, not signatures. Rewrite that test as a static `provider: SandboxProvider = NoneProvider()` assignment so pyright is the conformance gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Collapse shared_cargo_ro_paths to a conditional expression (the list accumulator only ever held zero-or-one element), and use ro_candidates.extend instead of `+= list(...)`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
chandrakananandi
left a comment
There was a problem hiding this comment.
Thanks for getting this started!
jtoman
left a comment
There was a problem hiding this comment.
All the other python changes look quite cromulent. One glaring hole, which could be attributable to me failing to read...
| autosetup = "certora_autosetup.autosetup.cli:main" | ||
| fixconf = "certora_autosetup.fixconf:main" | ||
|
|
||
| # Command-sandbox providers, discovered by composer.sandbox.policy.get_provider. |
There was a problem hiding this comment.
at the risk of asking a stupid question: ... where is this function? I don't see get_provider in composer.sandbox.policy and I scanned all the files in composer.module and didn't see any entry point magic. Did you forget to patch that in perhaps?

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.