Skip to content

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

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

Command sandbox: confine untrusted native command execution (Landlock + seccomp)#73
ericeil wants to merge 21 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
Comment thread docs/command-sandbox.md Outdated
Comment thread docs/command-sandbox.md Outdated
Comment thread docs/command-sandbox.md
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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread scripts/Dockerfile Outdated
Comment thread scripts/Dockerfile Outdated
ericeil and others added 2 commits July 20, 2026 13:51
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 ericeil left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security audit summary

Focused review of PR #73 against the guarantees in docs/command-sandbox.md §2:

  1. No network (incl. DNS / IMDS)
  2. No secrets (env scrub + no out-of-band recovery)
  3. Minimal filesystem
  4. Resource caps
  5. 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.

Comment thread rust/run-confined/src/main.rs
Comment thread rust/run-confined/src/main.rs
Comment thread rust/run-confined/src/main.rs
Comment thread composer/sandbox/recipes.py
Comment thread tests/test_sandbox_escape.py
Comment thread docs/command-sandbox.md
Comment thread rust/run-confined/src/main.rs
Comment thread docs/command-sandbox.md
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

ericeil and others added 7 commits July 20, 2026 14:28
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>
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>
@ericeil

ericeil commented Jul 20, 2026

Copy link
Copy Markdown
Author

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 chandrakananandi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Had a couple of small questions.

Comment thread composer/sandbox/command.py
Comment thread rust/run-confined/src/main.rs Outdated

while i < argv.len() {
match argv[i].as_str() {
"--rw" => cfg.rw_paths.push(PathBuf::from(take(&mut i, "--rw")?)),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Chandra, would you say there you're...

...clapping back?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, that's probably a better choice

Err("missing `--` and command to run".to_string())
}

fn set_rlimits(cfg: &Config) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do we know what these limits are needed for?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. :)

Comment thread composer/sandbox/command.py Outdated
Comment on lines +56 to +62
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,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typed dict imo

Comment thread composer/sandbox/command.py Outdated
Comment on lines +73 to +78
# 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it seems like this check is sufficient andyou can skip the p.is_absolute / .. in p.parts check above?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep

Comment thread composer/sandbox/command.py Outdated

``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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude loves to narrate these pointless "why" and "what we didn't do" details. No one needs this justification.

Comment thread composer/sandbox/command.py Outdated
``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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

claude loves to keep mentioning this fail-closed term, I have never heard of it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread composer/sandbox/command.py Outdated
Comment on lines +153 to +156
if sem is not None:
async with sem:
return await _run()
return await _run()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool

Comment thread composer/sandbox/policy.py Outdated
Comment on lines +92 to +93
ok: bool
reason: str = ""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread composer/sandbox/recipes.py Outdated
Comment on lines +92 to +97
root = Path(cargo_home)
out: list[Path] = []
bin_dir = root / "bin"
if bin_dir.is_dir():
out.append(bin_dir)
return tuple(out)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uh?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will simplify this

Comment thread composer/sandbox/recipes.py Outdated
home / ".cache" / "solana",
home / ".local" / "share" / "solana",
]
ro_candidates += list(extra_ro)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

probably want extend

Comment thread rust/run-confined/src/main.rs
Comment thread rust/run-confined/src/main.rs
ericeil and others added 5 commits July 22, 2026 13:10
… 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>
ericeil and others added 4 commits July 22, 2026 15:12
`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 chandrakananandi left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for getting this started!

@jtoman jtoman left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the other python changes look quite cromulent. One glaring hole, which could be attributable to me failing to read...

Comment thread pyproject.toml
autosetup = "certora_autosetup.autosetup.cli:main"
fixconf = "certora_autosetup.fixconf:main"

# Command-sandbox providers, discovered by composer.sandbox.policy.get_provider.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

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.

4 participants