From 74ed6620328cf619f7db44d6d281efa41a9dd44a Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Tue, 14 Jul 2026 15:45:45 -0700 Subject: [PATCH 1/3] =?UTF-8?q?sandbox:=20command=20sandbox=20infrastructu?= =?UTF-8?q?re=20(Python=20+=20Rust)=20=E2=80=94=20PR=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .dockerignore | 4 + .gitignore | 4 + composer/sandbox/__init__.py | 62 ++++ composer/sandbox/command.py | 156 +++++++++ composer/sandbox/config.py | 98 ++++++ composer/sandbox/launcher.py | 114 +++++++ composer/sandbox/policy.py | 165 ++++++++++ composer/sandbox/recipes.py | 158 +++++++++ docs/command-sandbox.md | 511 +++++++++++++++++++++++++++++ rust/Cargo.lock | 113 +++++++ rust/Cargo.toml | 21 ++ rust/run-confined/Cargo.toml | 19 ++ rust/run-confined/src/main.rs | 288 ++++++++++++++++ scripts/Dockerfile | 16 + tests/test_sandbox.py | 127 +++++++ tests/test_sandbox_command.py | 64 ++++ tests/test_sandbox_config.py | 102 ++++++ tests/test_sandbox_escape.py | 143 ++++++++ tests/test_sandbox_launcher.py | 106 ++++++ tests/test_sandbox_run_confined.py | 105 ++++++ 20 files changed, 2376 insertions(+) create mode 100644 composer/sandbox/__init__.py create mode 100644 composer/sandbox/command.py create mode 100644 composer/sandbox/config.py create mode 100644 composer/sandbox/launcher.py create mode 100644 composer/sandbox/policy.py create mode 100644 composer/sandbox/recipes.py create mode 100644 docs/command-sandbox.md create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/run-confined/Cargo.toml create mode 100644 rust/run-confined/src/main.rs create mode 100644 tests/test_sandbox.py create mode 100644 tests/test_sandbox_command.py create mode 100644 tests/test_sandbox_config.py create mode 100644 tests/test_sandbox_escape.py create mode 100644 tests/test_sandbox_launcher.py create mode 100644 tests/test_sandbox_run_confined.py diff --git a/.dockerignore b/.dockerignore index 884b27b4..7ffd6643 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,3 +28,7 @@ build/ dist/ .idea/ .vscode/ + +# Cargo build output — the image's sandbox-builder stage recompiles run-confined; +# copying host target/ (large) would only bloat the build context. +**/target/ diff --git a/.gitignore b/.gitignore index 6c0a11c8..4f415c38 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ local/ test_scenarios/**/certora/ test_scenarios/**/out/ test_scenarios/**/lib/forge-std + +# Rust build output (the run-confined command-sandbox workspace). Matches the +# **/target/ pattern the .dockerignore uses. +**/target/ diff --git a/composer/sandbox/__init__.py b/composer/sandbox/__init__.py new file mode 100644 index 00000000..ff1decda --- /dev/null +++ b/composer/sandbox/__init__.py @@ -0,0 +1,62 @@ +"""Run local commands, optionally confined to an unprivileged in-kernel sandbox. + +A backend-agnostic home for the ``RunCommand`` execution primitive +(:func:`run_local_command`) and the swappable sandbox seam +(``docs/command-sandbox.md``). It lives outside ``rustapp`` so **any** backend — +Rust-IoC *or* Python — can run untrusted native code (a compiler, a fuzzer) +confined: no network, no inherited secrets, only its own inputs on disk. + +- :mod:`composer.sandbox.policy` — the tool-agnostic seam: :class:`SandboxPolicy` + (confinement intent), :class:`SandboxProvider` (maps policy+command → a + :class:`LaunchSpec`), the ``none`` passthrough, the provider registry, and the + fail-closed helpers. +- :mod:`composer.sandbox.launcher` — the ``run-confined`` launcher provider + (Landlock + seccomp). Import it to register the ``"launcher"`` provider; the + *seam* deliberately never imports a concrete mechanism. +- :mod:`composer.sandbox.command` — :func:`run_local_command`, the single choke + point that materializes files into a workdir and runs a command there. +""" + +from composer.sandbox.command import ( + DEFAULT_TIMEOUT_S, + NOT_FOUND_EXIT, + CommandResult, + UnsafePath, + run_local_command, +) +from composer.sandbox.config import SandboxConfig +from composer.sandbox.policy import ( + Availability, + LaunchSpec, + NoneProvider, + SandboxPolicy, + SandboxProvider, + SandboxUnavailable, + ensure_available, + get_provider, + register_provider, +) +from composer.sandbox.recipes import DEFAULT_ENV_PASSTHROUGH, rust_build_policy + +__all__ = [ + # command runner + "run_local_command", + "CommandResult", + "UnsafePath", + "DEFAULT_TIMEOUT_S", + "NOT_FOUND_EXIT", + # sandbox seam + "SandboxPolicy", + "SandboxProvider", + "LaunchSpec", + "Availability", + "NoneProvider", + "SandboxUnavailable", + "get_provider", + "register_provider", + "ensure_available", + # config + recipes + "SandboxConfig", + "rust_build_policy", + "DEFAULT_ENV_PASSTHROUGH", +] diff --git a/composer/sandbox/command.py b/composer/sandbox/command.py new file mode 100644 index 00000000..d3720965 --- /dev/null +++ b/composer/sandbox/command.py @@ -0,0 +1,156 @@ +"""The local-command runner behind the ``RunCommand`` effect. + +A single choke point: materialize a set of files into a workdir, run a command +over them (as a child process, **never** a shell), and capture the result. The +trusted Python build steps (the Solana sBPF build / IDL step) route through here. + +(A Rust backend's own ``compile``/``validate`` toolchain runs no longer go through +this: they spawn the ``run-confined`` launcher directly from the wheel via +``autoprover_sdk::run_confined`` — see ``docs/rust-backend-api.md``. This runner and the +launcher share the same :mod:`composer.sandbox.policy` seam, which is why it lives in +:mod:`composer.sandbox` rather than under ``rustapp``.) + +Optional confinement is applied via a :class:`~composer.sandbox.policy.SandboxProvider` ++ :class:`~composer.sandbox.policy.SandboxPolicy` (``docs/command-sandbox.md``): +``None`` / the ``none`` provider is a passthrough; the ``launcher`` provider wraps +the argv in ``run-confined`` (Landlock + seccomp) and is fail-closed. + +**Trust boundary** (``docs/command-sandbox.md`` §2): the *caller* — a trusted Rust +decider or a trusted Python build step — supplies ``program`` and ``args``; only +file *contents* may derive from LLM output. We enforce path confinement here +(no absolute paths, no ``..`` traversal) in addition to whatever the provider does. +""" + +import asyncio +import logging +import os +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +from composer.sandbox.policy import ( + NoneProvider, + SandboxPolicy, + SandboxProvider, + ensure_available, +) + +_log = logging.getLogger(__name__) + +# Generous default; individual callers (a fuzz run vs a quick dry-run) pass their own. +DEFAULT_TIMEOUT_S = 600 + +# Exit code we synthesize when the binary isn't on PATH (mirrors shells' 127). +NOT_FOUND_EXIT = 127 + + +class UnsafePath(ValueError): + """A requested file path is absolute or escapes the workdir.""" + + +@dataclass(frozen=True) +class CommandResult: + exit_code: int + stdout: str + stderr: str + + 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, + } + + +def _confined_target(workdir: Path, rel: str) -> Path: + """Resolve ``rel`` under ``workdir``, rejecting absolute paths / ``..`` escapes.""" + p = PurePosixPath(rel) + if p.is_absolute() or ".." in p.parts: + raise UnsafePath( + f"file path {rel!r} is absolute or traverses outside the workdir" + ) + target = workdir / p + # 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 + + +async def run_local_command( + program: str, + args: list[str], + files: dict[str, str], + *, + workdir: Path, + timeout_s: int = DEFAULT_TIMEOUT_S, + sem: asyncio.Semaphore | None = None, + provider: SandboxProvider | None = None, + policy: SandboxPolicy | None = None, + env_overlay: dict[str, str] | None = None, +) -> CommandResult: + """Write ``files`` into ``workdir``, then run ``program args`` there and capture output. + + ``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. + + ``provider`` selects the sandbox mechanism (``docs/command-sandbox.md``); with + the default (``None`` → the ``none`` passthrough) the command runs exactly as + before. A real provider maps ``policy`` → a confined launch and is **fail-closed** + (raises :class:`~composer.sandbox.policy.SandboxUnavailable` if it can't confine, + rather than running unsandboxed). The untrusted ``files`` are materialized by + trusted Python (path-confined via ``_confined_target``) and then the command runs + — as one unit under ``sem`` when given, so that when callers share a workdir the + file-write and the run don't interleave (a concurrent caller can't overwrite these + files between our write and our run). Path confinement complements the sandbox. + + ``env_overlay`` sets extra env vars on the child on top of what it would otherwise + inherit — used by the *unsandboxed* prep steps (e.g. `cargo fetch` with a per-run + ``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 + spec = prov.wrap(policy if policy is not None else SandboxPolicy(), program, list(args)) + child_env = dict(spec.env) if spec.env is not None else None + if env_overlay: + # Overlay onto the effective env (the inherited parent env when the provider + # didn't set one — i.e. the `none`/unsandboxed path). + child_env = {**(child_env if child_env is not None else os.environ), **env_overlay} + + async def _run() -> CommandResult: + # Materialize files + launch as one unit so a shared workdir stays consistent + # for this command's duration (see `sem`). + workdir.mkdir(parents=True, exist_ok=True) + for rel, contents in files.items(): + target = _confined_target(workdir, rel) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(contents) + try: + proc = await asyncio.create_subprocess_exec( + *spec.argv, + cwd=str(workdir), + env=child_env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + return CommandResult(NOT_FOUND_EXIT, "", f"{spec.argv[0]}: not found on PATH") + try: + out_b, err_b = await asyncio.wait_for( + proc.communicate(), timeout=timeout_s + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return CommandResult(-1, "", f"command timed out after {timeout_s}s") + rc = proc.returncode if proc.returncode is not None else -1 + return CommandResult( + rc, out_b.decode(errors="replace"), err_b.decode(errors="replace") + ) + + if sem is not None: + async with sem: + return await _run() + return await _run() diff --git a/composer/sandbox/config.py b/composer/sandbox/config.py new file mode 100644 index 00000000..8b9824a2 --- /dev/null +++ b/composer/sandbox/config.py @@ -0,0 +1,98 @@ +"""Runtime selection of the command-sandbox provider + policy. + +A backend constructs a :class:`SandboxConfig` (usually via :meth:`from_env`) and +hands it to the command path (``RealEffects`` / ``build_program``), which turns it +into a concrete ``(provider, policy)`` per command via :meth:`resolve_provider` and +:meth:`build_policy`. Keeping selection here — rather than in :func:`run_local_command` +— means the runner stays mechanism-agnostic (``docs/command-sandbox.md`` §4/§7). + +The library default provider is ``"none"`` (passthrough). Backends that run +untrusted native code (Crucible) construct a config with ``provider="launcher"`` +by default; override with ``COMPOSER_SANDBOX_PROVIDER=none`` for trusted-input dev. +""" + +import os +from dataclasses import dataclass +from pathlib import Path + +from composer.sandbox.policy import SandboxPolicy, SandboxProvider, ensure_available, get_provider +from composer.sandbox.recipes import DEFAULT_ENV_PASSTHROUGH, rust_build_policy + +_ENV_VAR = "COMPOSER_SANDBOX_PROVIDER" + + +@dataclass(frozen=True) +class SandboxConfig: + """Which provider to use + the inputs for building its policy.""" + + provider: str = "none" + extra_ro: tuple[Path, ...] = () + extra_rw: tuple[Path, ...] = () + env_passthrough: tuple[str, ...] = DEFAULT_ENV_PASSTHROUGH + offline: bool = True # sandbox has no network → force cargo offline (§5) + mem_bytes: int | None = None + cpu_seconds: int | None = None + nproc: int | None = None + fsize_bytes: int | None = None + + @classmethod + def from_env(cls, **overrides) -> "SandboxConfig": + """Read the provider from ``$COMPOSER_SANDBOX_PROVIDER`` (default ``none``); + remaining fields come from ``overrides`` (e.g. a backend's ``extra_ro``).""" + return cls(provider=os.environ.get(_ENV_VAR, "none"), **overrides) + + @property + def enabled(self) -> bool: + return self.provider != "none" + + def resolve_provider(self) -> SandboxProvider: + # Importing the launcher module registers the "launcher" provider; the seam + # itself never imports a concrete mechanism (docs/command-sandbox.md §6). + if self.provider == "launcher": + import composer.sandbox.launcher # noqa: F401 + return get_provider(self.provider) + + def build_policy(self, workdir: str | Path) -> SandboxPolicy: + """The concrete policy for a command running in ``workdir``. The ``none`` + provider ignores the policy, so a bare :class:`SandboxPolicy` suffices there.""" + if not self.enabled: + return SandboxPolicy() + return rust_build_policy( + workdir, + extra_ro=self.extra_ro, + extra_rw=self.extra_rw, + env_passthrough=self.env_passthrough, + offline=self.offline, + mem_bytes=self.mem_bytes, + cpu_seconds=self.cpu_seconds, + nproc=self.nproc, + fsize_bytes=self.fsize_bytes, + ) + + def backend_spec(self, workdir: str | Path, *, timeout_s: int) -> dict: + """The ``Sandbox`` JSON a Rust backend's ``compile``/``validate`` consume to build + their own ``run-confined`` launch (`autoprover_sdk::Sandbox`). Python keeps ownership + of the confinement *intent* (this policy); the backend only assembles it into an argv. + + For a real provider this resolves the ``run-confined`` path and is **fail-closed** + (``ensure_available`` raises if the launcher can't confine here). The ``none`` provider + yields ``run_confined=None`` — the backend runs the command directly (trusted input).""" + if not self.enabled: + return {"run_confined": None, "timeout_s": timeout_s} + provider = self.resolve_provider() + ensure_available(provider) # fail-closed: raise before any untrusted code runs + policy = self.build_policy(workdir) + return { + "run_confined": getattr(provider, "binary", None), + "ro": [str(p) for p in policy.ro_paths], + "rw": [str(p) for p in policy.rw_paths], + "allow_env": [f"{k}={v}" for k, v in policy.env_allowlist.items()], + "network": policy.network, + "rlimits": { + "mem_bytes": policy.mem_bytes, + "cpu_seconds": policy.cpu_seconds, + "nproc": policy.nproc, + "fsize_bytes": policy.fsize_bytes, + }, + "timeout_s": timeout_s, + } diff --git a/composer/sandbox/launcher.py b/composer/sandbox/launcher.py new file mode 100644 index 00000000..0aff4bce --- /dev/null +++ b/composer/sandbox/launcher.py @@ -0,0 +1,114 @@ +"""The ``run-confined`` launcher provider — the first real :class:`SandboxProvider`. + +Maps a tool-agnostic :class:`SandboxPolicy` to an invocation of the ``run-confined`` +trusted Rust binary (``rust/run-confined``), which applies Landlock + seccomp + +rlimits + a scrubbed env to itself and then ``execve``s the command +(``docs/command-sandbox.md`` §6). This module is deliberately *separate* from the +:mod:`composer.sandbox.policy` seam: importing it registers the ``"launcher"`` +provider, so the seam never imports a concrete mechanism. + +``wrap`` is pure argv construction (unit-testable, no subprocess); ``available`` +shells out to ``run-confined --probe`` once to confirm the kernel supports Landlock +(fail-closed otherwise). +""" + +import os +import shutil +import subprocess +from pathlib import Path + +from composer.sandbox.policy import ( + Availability, + LaunchSpec, + SandboxPolicy, + register_provider, +) + +_BIN_NAME = "run-confined" +_PROBE_TIMEOUT_S = 10 + + +def _resolve_binary() -> str | None: + """Locate the ``run-confined`` binary: ``$RUN_CONFINED_BIN`` → ``PATH`` → the + dev build under ``rust/target/release`` (repo-relative). ``None`` if unbuilt.""" + override = os.environ.get("RUN_CONFINED_BIN") + if override and Path(override).is_file(): + return override + on_path = shutil.which(_BIN_NAME) + if on_path: + return on_path + # Dev fallback: composer/sandbox/launcher.py → repo root is parents[2]. + repo_root = Path(__file__).resolve().parents[2] + cand = repo_root / "rust" / "target" / "release" / _BIN_NAME + return str(cand) if cand.is_file() else None + + +class LauncherProvider: + """Confines commands via the ``run-confined`` launcher (Landlock + seccomp).""" + + name = "launcher" + + def __init__(self, binary: str | None = None): + # Resolve at construction so `available()` and `wrap()` agree on the path; + # tests pass an explicit binary to keep `wrap()` golden-testable offline. + self._binary = binary if binary is not None else _resolve_binary() + + @property + def binary(self) -> str | None: + return self._binary + + def available(self) -> Availability: + if self._binary is None: + return Availability( + ok=False, + reason=( + f"{_BIN_NAME} binary not found; build rust/run-confined " + f"(cargo build -p run-confined --release) or set RUN_CONFINED_BIN" + ), + ) + try: + proc = subprocess.run( + [self._binary, "--probe"], + capture_output=True, + text=True, + timeout=_PROBE_TIMEOUT_S, + ) + except OSError as e: + return Availability(ok=False, reason=f"{_BIN_NAME} --probe could not run: {e}") + if proc.returncode != 0: + reason = proc.stderr.strip() or f"{_BIN_NAME} --probe reported no Landlock support" + return Availability(ok=False, reason=reason) + return Availability(ok=True) + + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: + """Build the ``run-confined … -- program args`` argv from ``policy``. + + Emits ``--allow-env NAME=VALUE`` (explicit values — the allowlist holds only + benign build vars, never secrets). ``env`` stays ``None``: the launcher + inherits AutoProver's env but scrubs it to the allowlist for the child, so + the child's environment is fully determined by the flags.""" + argv: list[str] = [self._binary or _BIN_NAME] + for p in policy.ro_paths: + argv += ["--ro", str(p)] + for p in policy.rw_paths: + argv += ["--rw", str(p)] + for name, value in policy.env_allowlist.items(): + argv += ["--allow-env", f"{name}={value}"] + if policy.network: + argv.append("--allow-network") + if policy.mem_bytes is not None: + argv += ["--rlimit-as", str(policy.mem_bytes)] + if policy.cpu_seconds is not None: + argv += ["--rlimit-cpu", str(policy.cpu_seconds)] + if policy.nproc is not None: + argv += ["--rlimit-nproc", str(policy.nproc)] + if policy.fsize_bytes is not None: + argv += ["--rlimit-fsize", str(policy.fsize_bytes)] + argv += ["--", program, *args] + return LaunchSpec(argv=tuple(argv), env=None) + + +# Registering on import keeps the `composer.sandbox.policy` seam free of any +# concrete-mechanism import. Consumers (RealEffects, tests) `import` this module to +# make ``get_provider("launcher")`` resolvable. +register_provider("launcher", LauncherProvider) diff --git a/composer/sandbox/policy.py b/composer/sandbox/policy.py new file mode 100644 index 00000000..67c12f87 --- /dev/null +++ b/composer/sandbox/policy.py @@ -0,0 +1,165 @@ +"""The command-sandbox provider seam (``docs/command-sandbox.md`` §4, §7). + +Every ``RunCommand`` invocation compiles and/or runs *untrusted native code* (an +LLM-authored harness, a user program's ``build.rs``), so it must run confined — +no network, no inherited secrets, only its own inputs on the filesystem. This +module is the **tool-agnostic isolation layer** that makes that confinement +*swappable*: + +- :class:`SandboxPolicy` — the confinement *intent* (rw/ro paths, env allowlist, + network on/off, resource caps). It names **no mechanism**, so swapping the + sandbox tool never changes the policy. +- :class:`SandboxProvider` — maps a policy + a command to a concrete + :class:`LaunchSpec` (the argv/env to actually exec). The mechanism (a + Landlock+seccomp launcher, or an off-the-shelf tool like ``landrun`` / + ``sandlock``) lives entirely behind this protocol. + +Because :func:`composer.sandbox.command.run_local_command` will depend only on +this seam — never on a concrete tool — a provider can be swapped without touching +the command runner, ``RealEffects``, or the escape-test gate. It lives outside +``rustapp`` so Python-based backends can use it too, not just the Rust-IoC ones. + +This module ships the policy, the protocol, the ``none`` passthrough provider, +and the provider registry. The ``run-confined`` launcher provider (Landlock + +seccomp) registers itself under ``"launcher"`` when +:mod:`composer.sandbox.launcher` is imported (typically via +:meth:`composer.sandbox.config.SandboxConfig.resolve_provider`). + +**Trust boundary** (``docs/command-sandbox.md`` §7.2): the policy and the emitted +``LaunchSpec`` are authored by trusted Python — never the LLM, which controls only +file *contents*. +""" + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol, runtime_checkable + + +class SandboxUnavailable(RuntimeError): + """A sandbox provider was requested but cannot confine the command. + + Raised (fail-closed) instead of silently running unconfined — untrusted input + must never run without the sandbox (``docs/command-sandbox.md`` §7). Carries the + provider name + a human reason so the caller can surface a prominent message. + """ + + def __init__(self, provider: str, reason: str): + self.provider = provider + self.reason = reason + super().__init__(f"command sandbox provider {provider!r} is unavailable: {reason}") + + +@dataclass(frozen=True) +class SandboxPolicy: + """The confinement *intent* — tool-agnostic (``docs/command-sandbox.md`` §7). + + Every :class:`SandboxProvider` consumes *this* shape, so a mechanism swap needs + no policy change. ``program``/``args`` are passed per-call to + :meth:`SandboxProvider.wrap`, not stored here. Resource caps default to ``None`` + (unset); a provider maps them to its own limit mechanism (rlimits for the + launcher). + """ + + rw_paths: tuple[Path, ...] = () # writable: the workdir (+ any scratch) + ro_paths: tuple[Path, ...] = () # read+exec: toolchains, crucible checkout, /usr… + env_allowlist: Mapping[str, str] = field(default_factory=dict) + network: bool = False # egress allowed? default off + mem_bytes: int | None = None # RLIMIT_AS + cpu_seconds: int | None = None # RLIMIT_CPU + nproc: int | None = None # RLIMIT_NPROC + fsize_bytes: int | None = None # RLIMIT_FSIZE + + +@dataclass(frozen=True) +class LaunchSpec: + """How :func:`run_local_command` should actually launch the (confined) command. + + ``argv`` is the full argument vector to exec; ``env`` is the environment to pass + (``None`` = inherit the parent's, i.e. today's unconfined behavior). Both are + authored by trusted code, never the LLM. + """ + + argv: tuple[str, ...] + env: Mapping[str, str] | None = None + + +@dataclass(frozen=True) +class Availability: + """Result of :meth:`SandboxProvider.available` — whether the provider can + actually confine here (e.g. the launcher probes the kernel's Landlock ABI).""" + + ok: bool + reason: str = "" + + +@runtime_checkable +class SandboxProvider(Protocol): + """Maps a :class:`SandboxPolicy` + a command to a concrete :class:`LaunchSpec`. + + The one seam every sandbox mechanism implements. Implementations are pure with + respect to :meth:`wrap` (argv construction only — no subprocess), so they are + trivially unit-testable; the actual confinement happens in the launched process. + """ + + name: str + + def available(self) -> Availability: + """Whether this provider can confine a command in the current environment.""" + ... + + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: + """Translate ``policy`` into how to launch ``program args`` confined.""" + ... + + +class NoneProvider: + """Passthrough — **no confinement**. Exec the command directly, inheriting the + environment: byte-for-byte today's behavior. + + An *explicit, logged* choice for the trusted EVM/Foundry callers and + trusted-input dev runs. It is never reached as a silent fallback from a failed + real sandbox (``docs/command-sandbox.md`` §7) — the caller selects it on purpose. + """ + + name = "none" + + def available(self) -> Availability: + return Availability(ok=True) + + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: + # Policy is intentionally ignored: this provider provides no isolation. + return LaunchSpec(argv=(program, *args), env=None) + + +# Provider registry. The ``launcher`` factory is registered by importing +# :mod:`composer.sandbox.launcher` (see :meth:`SandboxConfig.resolve_provider`). +_PROVIDERS: dict[str, Callable[[], SandboxProvider]] = { + "none": NoneProvider, +} + + +def register_provider(name: str, factory: Callable[[], SandboxProvider]) -> None: + """Register a provider factory under ``name`` (used by later steps to add the + launcher / off-the-shelf providers without this module importing them).""" + _PROVIDERS[name] = factory + + +def get_provider(name: str) -> SandboxProvider: + """Construct the provider registered under ``name``. Raises ``ValueError`` for an + unknown name (a config error, distinct from a provider being *unavailable*).""" + try: + factory = _PROVIDERS[name] + except KeyError: + raise ValueError( + f"unknown sandbox provider {name!r}; known: {sorted(_PROVIDERS)}" + ) from None + return factory() + + +def ensure_available(provider: SandboxProvider) -> None: + """Fail-closed check: raise :class:`SandboxUnavailable` unless ``provider`` can + confine here. Call before running untrusted input under a real provider.""" + avail = provider.available() + if not avail.ok: + raise SandboxUnavailable(provider.name, avail.reason) diff --git a/composer/sandbox/recipes.py b/composer/sandbox/recipes.py new file mode 100644 index 00000000..edb78701 --- /dev/null +++ b/composer/sandbox/recipes.py @@ -0,0 +1,158 @@ +"""Ready-made :class:`SandboxPolicy` recipes. + +The seam (:mod:`composer.sandbox.policy`) is mechanism- *and* workload-agnostic; +this module holds opinionated builders for common workloads. :func:`rust_build_policy` +covers "compile and/or run Rust" (``cargo build-sbf``, ``cargo build``, ``crucible +run``): it grants the workdir read-write, the discoverable Rust/Solana toolchains +read-only, the device nodes the toolchain needs, and an env allowlist — with the +network off. Any Rust backend reuses it; Crucible adds its own paths via ``extra_ro``. + +Paths are included only if they exist, so the same recipe works across machines +with different toolchain layouts (and the escape-test gate can prove exactly what +was and wasn't granted). +""" + +import os +import shutil +from pathlib import Path + +from composer.sandbox.policy import SandboxPolicy + +# Benign build vars passed through to the child (values read from the current env). +# Never secrets — the whole point is that secrets are *not* inherited. +DEFAULT_ENV_PASSTHROUGH: tuple[str, ...] = ( + "PATH", + "HOME", + "TERM", + "LANG", + "LC_ALL", + "USER", + "LOGNAME", + "TMPDIR", + "CARGO_HOME", + "RUSTUP_HOME", + "SSL_CERT_FILE", + "SSL_CERT_DIR", +) + +# Read-only system directories the toolchain + its dynamic linker need. ``/etc`` is +# included because glibc NSS (``getpwuid`` via ``getuser``, CA-cert lookup) reads +# ``/etc/passwd`` / ``/etc/nsswitch.conf``; it holds no AutoProver secret (those are +# in the scrubbed env and in files we never grant). The escape gate must therefore +# probe a *planted* host file / the parent's environ, not ``/etc/passwd``. +_SYSTEM_RO: tuple[str, ...] = ("/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc") + +# Device nodes the toolchain opens (rw so ``/dev/null`` writes work). Granting the +# node files — not the whole ``/dev`` tree; ``mknod`` stays blocked (no capability). +_DEV_NODES: tuple[str, ...] = ( + "/dev/null", + "/dev/zero", + "/dev/full", + "/dev/random", + "/dev/urandom", + "/dev/tty", +) + + +def sandbox_cargo_home(workdir: str | Path) -> Path: + """The **private, per-run `CARGO_HOME`** for a sandboxed build, under the workdir. + + Why a private cargo home rather than the shared `~/.cargo`: + + An offline `cargo build` doesn't just *read* the cache — it *writes* to `CARGO_HOME` + (extracts crate sources into `registry/src`, takes `.package-cache` locks). To let + the confined build do that we'd have to grant `CARGO_HOME` read-write. But the same + build runs **untrusted `build.rs`/proc-macro code**, so a writable *shared* cargo + home is a cross-run attack surface: a malicious build could overwrite an extracted + source under `registry/src` and poison a *later* run that compiles that crate (cargo + checksums the downloaded `.crate`, but trusts an already-extracted `registry/src`). + + A per-run home under the (already-writable, per-run) workdir removes that: any write + the untrusted build makes touches only this run's throwaway cache, never a shared one. + The cost is that deps are fetched per run (the warm step downloads into this home); + a shared *read-only* index/cache to avoid re-download is a deferred optimization + (command-sandbox.md §11 item 5). + """ + return Path(workdir).resolve() / ".sandbox_cargo" + + +def rust_build_policy( + workdir: str | Path, + *, + extra_ro: tuple[Path, ...] = (), + extra_rw: tuple[Path, ...] = (), + env_passthrough: tuple[str, ...] = DEFAULT_ENV_PASSTHROUGH, + offline: bool = True, + mem_bytes: int | None = None, + cpu_seconds: int | None = None, + nproc: int | None = None, + fsize_bytes: int | None = None, +) -> SandboxPolicy: + """Build a network-off policy for compiling/running Rust in ``workdir``. + + Grants: ``workdir`` + the device nodes (+ ``extra_rw``) read-write; the Rust + (``RUSTUP_HOME``/``CARGO_HOME``) and Solana platform-tool directories, the system + dirs, and ``extra_ro`` read-only. Non-existent paths are dropped. + + With ``offline`` (the default — the sandbox has no network, §5), ``CARGO_NET_OFFLINE=1`` + is set in the child env. That one var forces *every* cargo invocation offline, + including the nested ``cargo`` that ``crucible run`` spawns to build the harness — + so the deps must already be warm in ``CARGO_HOME`` (see :func:`warm_cargo_cache`, + run *outside* the sandbox first). + """ + home = Path.home() + rustup = Path(os.environ.get("RUSTUP_HOME", home / ".rustup")) + cargo = Path(os.environ.get("CARGO_HOME", home / ".cargo")) + + ro_candidates: list[Path] = [Path(p) for p in _SYSTEM_RO] + ro_candidates += [ + rustup, + cargo, + # cargo-build-sbf's downloaded sBPF platform-tools (layout varies by version). + home / ".cache" / "solana", + home / ".local" / "share" / "solana", + ] + ro_candidates += list(extra_ro) + # Absolute paths only: the launcher opens each relative to *its* cwd (the workdir), + # so a relative grant would resolve wrong. resolve() also canonicalizes symlinks. + ro_paths = tuple(p.resolve() for p in ro_candidates if p.exists()) + + dev = tuple(Path(d).resolve() for d in _DEV_NODES if Path(d).exists()) + wd = Path(workdir).resolve() + rw_paths = (wd, *dev, *(p.resolve() for p in extra_rw)) + + env = {name: os.environ[name] for name in env_passthrough if name in os.environ} + if offline: + env["CARGO_NET_OFFLINE"] = "1" + # A private temp dir UNDER the (writable) workdir, so tools that need scratch space + # — notably the linker, which writes to $TMPDIR (default /tmp) during `cargo build` — + # work without granting the shared /tmp (which may hold host/other-run secrets and + # would defeat the escape test). Created here so $TMPDIR points at an existing dir. + sandbox_tmp = wd / ".sandbox_tmp" + sandbox_tmp.mkdir(parents=True, exist_ok=True) + for var in ("TMPDIR", "TMP", "TEMP"): + env[var] = str(sandbox_tmp) + + # Point CARGO_HOME at a PRIVATE per-run cargo home under the workdir (see + # sandbox_cargo_home for the reasoning). The shared ~/.cargo stays read-only (its + # `bin/cargo` is still on PATH; we only redirect where cargo *writes*). Copy the + # user's global cargo config in so registry mirrors / build settings still apply. + cargo_home = sandbox_cargo_home(wd) + cargo_home.mkdir(parents=True, exist_ok=True) + shared_cargo = Path(os.environ.get("CARGO_HOME", Path.home() / ".cargo")) + for cfg in ("config.toml", "config"): + src = shared_cargo / cfg + if src.is_file() and not (cargo_home / cfg).exists(): + shutil.copy(src, cargo_home / cfg) + env["CARGO_HOME"] = str(cargo_home) + + return SandboxPolicy( + rw_paths=rw_paths, + ro_paths=ro_paths, + env_allowlist=env, + network=False, + mem_bytes=mem_bytes, + cpu_seconds=cpu_seconds, + nproc=nproc, + fsize_bytes=fsize_bytes, + ) diff --git a/docs/command-sandbox.md b/docs/command-sandbox.md new file mode 100644 index 00000000..7376a38a --- /dev/null +++ b/docs/command-sandbox.md @@ -0,0 +1,511 @@ +# Design — Sandboxing the `RunCommand` effect (Phase 6) + +**Status:** implemented. Design + record for [crucible-application.md §7.4](./crucible-application.md#L436) +and [§9 Phase 6](./crucible-application.md#L634) — the *required*, definition-of-done phase. The +sandbox mechanism is built and validated (§9 steps 1–5 done, gate §10 green — incl. the full LLM +e2e passing under the launcher); Crucible runs confined by default. Open items are orthogonal to the +sandbox (§11): a shared-`Cargo.toml` feature race that lost one of three instructions, and per-run +`CARGO_HOME`/tightening follow-ups. + +**One-line summary.** Every command run through the `RunCommand` effect compiles and/or runs +LLM-authored *native* code (§7.2). Today that runs with the full ambient environment of the +AutoProver process. Phase 6 confines each such command — with no network, no inherited secrets, and +only its own inputs on the filesystem — using **unprivileged, in-process kernel sandboxing +(Landlock + seccomp)** that needs no container changes, no namespaces, no capabilities, and no +custom runtime. It is a single wrapper around [`run_local_command`](../composer/sandbox/command.py). +Done is proven by an escape test. + +--- + +## 1. Why this is required, not optional + +The outer AutoProver container protects the *host* from AutoProver. It does **not** protect +AutoProver's own secrets, network access, and filesystem from code running *inside* it. And the +`RunCommand` effect deliberately runs untrusted native code: + +- `cargo build-sbf` on the **user-supplied program** compiles it natively — running its + `build.rs`, its proc-macros, and (for a future Prover/CVLR backend) LLM-munged source. +- `crucible run` compiles the **LLM-authored harness** (its `setup()`, `action_*`, `build.rs`) + and then runs it as a native LiteSVM-in-process binary (§7.2 — verified native, no SVM sandbox). + +So arbitrary code of the LLM's (and the analyzed program's) choosing executes with whatever +ambient authority the AutoProver process has: `ANTHROPIC_API_KEY`, `CERTORA*` cloud tokens, +`AWS_*`, `PG*`, the network route to `169.254.169.254`, and the entire bind-mounted host project. +The trust boundary from §7.2 ("the LLM authors only file *contents*, never argv") stops the LLM +from choosing *what command runs* — it does nothing about what that command, once running, can +*reach*. That is this phase's job. + +Until this phase is green the backend may run only in a trusted, offline environment on trusted +input (the gate scenario). This is the definition of done. + +--- + +## 2. Threat model + +| | | +|---|---| +| **Asset** | AutoProver's ambient secrets (LLM/cloud API keys, DB creds), its network egress (incl. `169.254.169.254` metadata → IAM role creds on EC2), and host files outside the command's declared inputs. | +| **Adversary** | Native code the LLM authored (harness `setup`/`action`/`build.rs`) **and** native code in the analyzed program (its `build.rs`, proc-macros) that `cargo build-sbf` runs. Assume it is actively hostile and knows it is being fuzzed. | +| **Trust boundary** | The process boundary of each `RunCommand` invocation. Inside: untrusted. Outside: the trusted AutoProver process. `program`+`args` are trusted (Rust decider / Python build step author them, §7.2); only the *files* are untrusted. | +| **Assumptions** | (1) The outer container/host is the infrastructure's boundary against the host machine and other tenants (on EC2, the Nitro hypervisor) — this phase is the boundary *within* the container, between AutoProver and its own untrusted child. (2) The kernel is patched and Landlock-capable (§8). (3) The host toolchains we grant read access are trusted. | +| **Non-goals** | Protecting the host machine *from the container* (the infrastructure does that). A full VM boundary between AutoProver and the child (that is what gVisor/Kata/VM-per-run would add at the infra layer, orthogonal to this phase — §6). Defending against a malicious *`program`/`args`* — those are trusted by construction (§7.2). | + +**Explicit guarantees the sandbox must provide:** + +1. **No network** — no egress at all, including DNS and `169.254.169.254`. +2. **No secrets** — the child's environment is a scrubbed allowlist, and it cannot recover + AutoProver's secrets out-of-band (via `/proc//environ` or `ptrace` — see §6, the + same-uid caveats). +3. **Minimal filesystem** — only the command's own inputs are writable; toolchains are read-only; + nothing else of the host is readable. +4. **Resource caps + wall-clock kill** — memory / CPU-time / pids / file-size bounded; a hung or + runaway command is killed. +5. **Offline, code-exec-free dependency resolution** — all network dep-fetching happens *outside* + the sandbox and *before* any untrusted code runs (§5); the sandboxed build is `--offline`. + +--- + +## 3. What runs inside, and what it legitimately needs + +The hard part of sandboxing a compiler+fuzzer is that it needs a *lot* of real toolchain — the +sandbox is only useful if it grants exactly that and nothing more. The three command shapes and +their real needs: + +| Command | Reads (grant **ro+x**) | Writes (grant **rw**) | Network | +|---|---|---|---| +| `cargo build-sbf ` | rust toolchain (`RUSTUP_HOME`), solana platform-tools (the sBPF toolchain), warm cargo registry (`CARGO_HOME/registry`), program crate source | program crate `target/` | none (offline) | +| `crucible run …` | the `crucible` binary + its libs, rust toolchain, cargo registry, the **crucible checkout crates** (path deps from `CrucibleDep`, §6.1), the built `.so` + IDL | the harness crate `target/`, corpus/output dirs | none (offline) | +| `cargo build` (harness, if run directly) | as above | harness `target/` | none (offline) | + +Common surface, resolved once at sandbox-config time and expressed as Landlock rules (§6): + +- **Rust toolchain** — `RUSTUP_HOME` (default `~/.rustup`), `cargo`/`rustc` shims — read+exec. +- **Cargo home** — `CARGO_HOME` (default `~/.cargo`): the `cargo` binary and the **registry cache**, + read-only inside; warmed *outside* (§5). +- **Solana platform-tools** — cargo-build-sbf's sBPF rust toolchain — read+exec. +- **The `crucible` binary** and libs it dlopens — read+exec. +- **The crucible checkout** (`$CRUCIBLE_REPO/crates/…`) — the path deps — read-only. +- **System runtime** — `/usr`, `/bin`, `/lib`, `/lib64` — read+exec (needed for the toolchain's own + dynamic linking and subprocesses). +- **Device nodes** — `/dev/null`, `/dev/urandom`, `/dev/zero`, `/dev/tty` — read+write. The toolchain + opens these constantly (a build *fails* without `/dev/null` — validated during step 2). Landlock + rules can target individual files, so we grant these specific nodes rather than the whole `/dev` + tree; either way `mknod` stays blocked (no capability), so no new devices can be created. +- **Workdir** — the crate tree + `target/` + corpus/output — the primary read-write grant. +- **A private temp dir** — `/.sandbox_tmp`, with `TMPDIR`/`TMP`/`TEMP` pointed at it. The + **linker** writes scratch files to `$TMPDIR` (default `/tmp`) during `cargo build`; we do *not* + grant the shared `/tmp` (it may hold host/other-run secrets and would defeat the escape test), so a + per-run temp under the already-writable workdir is redirected in. (A fresh harness build fails at + the link step — "Cannot create temporary file in /tmp/" — without this; found via the e2e gate.) +- **A private cargo home** — `/.sandbox_cargo`, with `CARGO_HOME` pointed at it (§11 item 5). + The offline `cargo build` writes there (source extraction, locks); keeping it per-run means + untrusted build code can't poison a *shared* `~/.cargo` (which stays read-only, for the `cargo` + binary). The warm step (§5) fetches into this same home. + +Everything else — the rest of the bind-mounted project, `/etc`, `/proc/`, `$HOME`, the +process environment — is **not granted**, therefore inaccessible. Confinement is default-deny. + +> The exact host paths (`RUSTUP_HOME`, platform-tools dir, crucible binary) are **resolved by the +> host at config time**, not hardcoded — see the `SandboxPolicy` in §7. They are discovered from the +> environment the same way `resolve_crucible_repo` already discovers the checkout. + +--- + +## 4. The seam — one function, unchanged signature + +All command execution already funnels through +[`run_local_command`](../composer/sandbox/command.py) (both the IoC `RunCommand` effect via +[`RealEffects.run_command`](../composer/rustapp/adapter.py#L120) and the Solana build step +[`build_program`](../composer/spec/solana/build.py)). It lives in the backend-agnostic +[`composer/sandbox`](../composer/sandbox/) package — outside `rustapp` — so Python-based backends can +run confined commands too, not just the Rust-IoC ones. The sandbox wraps exactly this one function. + +**The mechanism sits behind a `SandboxProvider` seam, so it is swappable.** `run_local_command` +never names a concrete tool. It holds a **tool-agnostic `SandboxPolicy`** (the *intent*: rw paths, +ro paths, env allowlist, rlimits, network-off — §7) and a `SandboxProvider` that translates that +intent into a concrete launch: + +```python +class SandboxProvider(Protocol): + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: ... + def available(self) -> Availability: ... # drives fail-closed (§7) + +# run_local_command, unchanged shape: +spec = provider.wrap(policy, program, args) +create_subprocess_exec(*spec.argv, cwd=workdir, env=spec.env, …) +``` + +The first provider is our **custom launcher shim** (§6): `LaunchSpec.argv == ["run-confined", +*policy_argv, "--", program, *args]`, all authored by trusted Python (never the LLM). Swapping to an +off-the-shelf tool later — `landrun`, `sandlock` — is a *new `SandboxProvider` implementation that +maps the same `SandboxPolicy` to that tool's flags*; the policy, this seam, `run_local_command`, +`RealEffects`, and the escape-test gate (§10) are all untouched. The provider is chosen by config +(`CommandConfig` / an env var), defaulting to the custom launcher. The `none` provider is a +passthrough (`argv == [program, *args]`) — byte-for-byte today's behavior for the EVM/Foundry paths +and explicit trusted-input dev runs. + +Nothing in the Rust decider, the ABI, the driver, or the artifact store changes — this is why §7.4 +could defer it to last. + +Two properties `run_local_command` *already* enforces stay in force and are the first line of +defense (the sandbox is the second): the command runs via **exec, not a shell**, and every written +file path is **confined to the workdir** (`_confined_target`). The sandbox does not replace these; +it assumes them. + +--- + +## 5. Offline dependency resolution — split fetch (network, no exec) from build (exec, no network) + +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 + executes during fetch. So the *fetch* happens **outside** the sandbox, with network, as a trusted + prep step, warming `CARGO_HOME/registry` (or producing a vendored dir + source-replacement + config). +- **`cargo build` runs build scripts and proc-macros** — this is where untrusted code executes, so + it happens **inside** the sandbox, `--offline`, against the already-warm cache. + +The harness `Cargo.toml` is **host-owned** (`CrucibleDep.render_deps`, pinned versions, §6.1), so +its dep graph is fixed and vendorable deterministically. The program-under-test's `Cargo.toml` is +user-supplied, but `cargo fetch` on it is still exec-free, so the same split holds for the build-sbf +step. This also closes the build-time supply-chain vector: with offline + a pre-warmed cache, a +malicious `build.rs` cannot pull a payload at build time. + +**Implementation (step 4).** "Offline inside" is one env var, not per-tool flags: the policy sets +**`CARGO_NET_OFFLINE=1`** in the child env, which forces *every* cargo invocation offline — including +the nested `cargo` that `crucible run` spawns to build the harness — so we never thread `--offline` +through each tool ([recipes.py](../composer/sandbox/recipes.py), `offline=True` default). "Fetch +outside" is [`warm_cargo_cache`](../composer/spec/solana/build.py) — a `cargo fetch` run *unsandboxed* +(no provider → network on) before the confined build; `build_program` calls it before the sandboxed +`cargo build-sbf`. The harness crate has its own deps (libafl, litesvm, …), so it needs its own warm +at manifest-assembly time; wiring that exact call site (and confirming whether `CARGO_HOME` must be +granted rw for cargo's build-time source extraction, or pre-extracted during the warm) lands with the +gate in step 5, where a real offline build proves it. All of this is inert until a sandbox is enabled. + +--- + +## 6. Mechanism: unprivileged Landlock + seccomp self-sandboxing + +### Why not a namespace sandbox (bwrap/nsjail) or gVisor + +The obvious tools (bwrap, nsjail) build the sandbox out of **namespaces** (user + mount + net + +pid), then `pivot_root` into a minimal filesystem. That model **fights the container**: creating an +unprivileged user namespace and mounting inside it is exactly what Docker's default seccomp + +AppArmor block. Validated empirically (python:3.12-slim, host kernel 7.0.11, `bwrap 0.11.0`, 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 capability bounding set drops `CAP_SETPCAP`) | + +Making bwrap work would mean either **stripping the container's own seccomp/AppArmor** (widening the +host-kernel attack surface across *all* of AutoProver — the opposite of what a sandboxing phase +should do) or running AutoProver under a **gVisor/Kata** runtime. gVisor works, but (a) it imposes +its *heaviest* overhead precisely on our syscall/I/O-bound compile+fuzz workload, and (b) its benefit +— protecting the host kernel — is an *infrastructure* boundary that on EC2 is already provided by the +Nitro hypervisor. Neither is worth coupling this phase to a deployment decision. + +### The chosen model: the process sandboxes itself + +Instead of building a new namespace *around* the command, the command **restricts itself** using two +unprivileged kernel facilities — the model Chrome, OpenSSH, and systemd use. Both need **no +namespaces, no capabilities, no root, and no `--security-opt`**, and both work in a **stock** +container. Validated (stock python:3.12-slim, uid 1000, Docker default profile): + +| Guarantee | Probe result | Mechanism | +|---|---|---| +| filesystem — write outside workdir | ✗ `EACCES` | **Landlock** (full ABI FS bit set, grant only workdir rw) | +| filesystem — read host file (`/etc/passwd`) | ✗ `EACCES` | Landlock (no grant) | +| **secret** — read `/proc//environ` | ✗ `EACCES` | Landlock (no `/proc` grant) | +| **secret** — `ptrace(ATTACH, parent)` | ✗ `EPERM` | **seccomp** (deny `ptrace`, `process_vm_readv`) | +| network — `socket(AF_INET)` | ✗ `EPERM` | seccomp (deny inet-domain sockets → blocks TCP, UDP/DNS, IMDS) | +| legitimate — write workdir, `exec` toolchain | ✓ works | Landlock rw grant + r+x on toolchain paths | + +- **[Landlock](https://docs.kernel.org/userspace-api/landlock.html)** (LSM; Linux ≥5.13, we observed + ABI **8**) — an unprivileged process installs a filesystem ruleset on itself: default-deny, then + grant rw to the workdir and read+exec to the toolchain paths of §3, handling the *full* set of FS + access rights the running ABI supports (else unhandled operations stay unrestricted). This is what + confines reads *and* writes and — crucially — closes the `/proc//environ` leak that a user + namespace would otherwise have closed for free. +- **seccomp-BPF self-filter** (`PR_SET_NO_NEW_PRIVS` + `SECCOMP_SET_MODE_FILTER`) — installing a + *stricter* filter on yourself is unprivileged and permitted by Docker's default profile. It denies + the network (`socket` with `AF_INET`/`AF_INET6` — covering TCP, UDP/DNS, and the IMDS endpoint, + while leaving `AF_UNIX` for benign local IPC) and the remaining same-uid secret vectors + (`ptrace`, `process_vm_readv`/`writev`). +- **env allowlist** — the launcher `execve`s with a scrubbed environment (PATH, HOME, CARGO_HOME, + RUSTUP_HOME, TERM, and benign build vars only). The `--clearenv` equivalent, done in-process. +- **rlimits** — `setrlimit` for `RLIMIT_AS` / `RLIMIT_CPU` / `RLIMIT_NPROC` / `RLIMIT_FSIZE` (§7). + +Landlock and seccomp are **preserved across `execve`** (with `NO_NEW_PRIVS`) and **inherited across +`fork`**, so the launcher applies them once and every descendant — `cargo`, `rustc`, each `build.rs`, +the linker, the fuzz binary — runs confined. + +### The same-uid caveat, and why it is closed + +A user namespace (bwrap) would have run the child under a *remapped* uid, so cross-process access to +AutoProver was denied by credential mismatch. Self-sandboxing keeps the child at AutoProver's **own +uid**, so the two out-of-band secret vectors must be closed *explicitly* — and are: `/proc// +environ` by **not granting `/proc`** in the Landlock ruleset (proven `EACCES`), and `ptrace`/ +`process_vm_readv` by the **seccomp deny-list** (proven `EPERM`). These are the only same-uid vectors +to AutoProver's memory/env; both verified closed in the stock container. + +### The launcher: a custom shim over audited crates (not hand-rolled primitives) + +The first `SandboxProvider` (§4) is a small **trusted Rust launcher** (`run-confined`) that applies the +four confinements to itself, then `execve`s the command. It does **not** hand-write raw seccomp BPF +or raw Landlock syscalls — it composes two mature, permissively-licensed crates: + +- **[`landlock`](https://crates.io/crates/landlock)** — the reference Rust binding; does ABI + negotiation and the full FS access-right set (the fiddly part §11 Q1 warns about). +- **[`seccompiler`](https://crates.io/crates/seccompiler)** — the seccomp-BPF compiler from **AWS + Firecracker**; we hand it a small allow/deny policy, not raw bytecode. + +plus `setrlimit` and an env allowlist. So the security-sensitive primitives are audited upstream; +our code is the glue + the policy. We build Rust already, so this adds no new toolchain. + +### Alternatives considered — and why the seam stays swappable (§4) + +Two off-the-shelf tools do essentially this model. Neither is adopted *now*, but the `SandboxProvider` +seam means either can be dropped in later as a new provider mapping the same `SandboxPolicy`: + +- **[`landrun`](https://github.com/zouuup/landrun)** (Go CLI, **MIT**, mature ~2.2k★, FS floor 5.13): + excellent for Landlock FS + env, and the reference for our CLI shape. But it blocks network via + **Landlock network rules (TCP-only, kernel ≥6.7)** — it does **not** block UDP/DNS, and degrades + fail-open on older kernels — and has no rlimits. It would need a seccomp companion anyway, so it + doesn't save the hard part. +- **[`sandlock`](https://github.com/multikernel/sandlock)** (Python+Rust, Landlock+seccomp): the + closest match to our full model, but requires **kernel ≥6.12 (Landlock ABI v6)** — above Amazon + Linux 2023's 6.1 — and ships an **unstated license** plus more surface than we need (MITM proxy, + COW, notification supervisor). A strong candidate to revisit *if* the kernel-floor and license + questions are resolved and reviewers prefer an off-the-shelf boundary. + +The custom launcher wins for now on **kernel floor** (5.13, because we block network with seccomp not +Landlock), **license clarity**, and **minimal surface** — while the provider seam keeps the door open +to swap in `sandlock`/`landrun` with no change to the policy or the gate. + +### The chief advantage: deployment-independence + +Because it needs nothing from the container, the same code path runs identically on a dev laptop, +self-managed EC2, ECS, EKS, and even Fargate, and under `runc` or gVisor alike. **It decouples Phase +6 from the open deployment/tenancy questions** — those can be settled later as an *infrastructure* +hardening decision (VM-per-run / gVisor / IMDSv2 hop-limit / least-privilege IAM), layered *on top* +of this in-process boundary, not blocking it. + +**Residual risk:** a Landlock/seccomp bypass or a kernel LPE would let the child reach the container +(and then only as far as the infrastructure boundary allows — the container, or on EC2 the Nitro +VM). Named; mitigated by keeping the kernel patched, by the env/network already being denied, and by +the orthogonal infra hardening above for higher-trust-risk deployments. + +--- + +## 7. Resource limits, and the config surface + +**Resource caps** are `setrlimit` calls the launcher makes on itself before `execve` (lowering your +own limits is unprivileged; inherited by all descendants): `RLIMIT_AS` (address space / memory-ish), +`RLIMIT_CPU` (CPU-seconds — a wall-clock-independent bound), `RLIMIT_NPROC` (fork-bomb guard), +`RLIMIT_FSIZE` (disk-fill guard). `RLIMIT_AS` is crude (address space, not RSS) but dependency-free; +a **cgroup v2** scope (`memory.max`, `pids.max`, `cpu.max`) is the robust upgrade if the container +grants writable cgroup delegation — note it, defer it. The existing asyncio `wait_for(..., +timeout_s)` in `run_local_command` stays the primary wall-clock kill. + +The confinement *intent* is a **tool-agnostic** policy object (the same one every `SandboxProvider` +consumes, §4) — deliberately naming no mechanism, so a future provider swap needs no policy change: + +```python +@dataclass(frozen=True) +class SandboxPolicy: + rw_paths: tuple[Path, ...] # the workdir (+ any writable scratch) + ro_paths: tuple[Path, ...] # toolchains, crucible checkout, platform-tools, /usr… + env_allowlist: Mapping[str, str] # PATH, HOME, CARGO_HOME, RUSTUP_HOME, TERM, … + network: bool = False # egress allowed? default off + mem_bytes: int = ... + cpu_seconds: int = ... + nproc: int = ... + fsize_bytes: int = ... + # program + args come per-call from run_local_command +``` + +**Provider selection is separate config, not part of the policy** — a `CommandConfig.sandbox_provider` +knob (`"launcher"` = the custom Rust shim, default; `"none"` = passthrough; later `"landrun"` / +`"sandlock"`), overridable by env var. `run_local_command` gains `policy: SandboxPolicy | None` + +the resolved provider (default provider `"none"` when no policy, so existing callers and the EVM path +are unchanged). `RealEffects` builds the policy from a host-resolved config (toolchain paths +discovered like `resolve_crucible_repo` already does), and `build_program` uses the same. + +**Fail-closed.** Before running under a real sandbox provider, `provider.available()` is checked +(for the launcher: kernel Landlock ABI present). If it isn't — or the provider cannot apply its +confinement — the command **refuses to run** rather than silently executing unconfined. The failure +is a **prominent, actionable message** naming the reason ("the command sandbox requires a +Landlock-capable kernel (Linux ≥5.13); this backend cannot run without it — see +docs/command-sandbox.md §8"). The `none` provider is a *separate*, explicit, logged choice for the +trusted EVM/Foundry callers and trusted-input dev runs — never reached as a fallback from a failed +sandbox setup. + +--- + +## 8. Platform requirements — Linux with Landlock; nothing else supported + +Landlock and seccomp are **Linux** facilities. This backend is supported only on a Linux host with a +**Landlock-capable kernel (≥5.13; ≥6.7 adds Landlock network rules as defense-in-depth)** — which +AutoProver's own container already provides (Amazon Linux 2023 = 6.1, recent Ubuntu, and the dev +container all qualify). **macOS is not a supported configuration** (team decision): there is no +Landlock, and no macOS-native equivalent is planned. A Mac developer runs this backend the way +AutoProver already runs — inside the Linux container. + +If the sandbox cannot be established (non-Linux host, or a kernel without Landlock), the run +**fails immediately** with the §7 fail-closed message. This is the one uniform response everywhere +the sandbox is unavailable: refuse to run, loudly, rather than run untrusted native code unconfined. + +--- + +## 9. Implementation plan + +1. **The `SandboxProvider` seam + `SandboxPolicy`** — *done* ([composer/sandbox/policy.py](../composer/sandbox/policy.py)): + the tool-agnostic policy (§7), the `SandboxProvider` protocol (`wrap` → `LaunchSpec`, `available`), + the `none` passthrough provider, the name registry, and `ensure_available` / `SandboxUnavailable`. + Pure, unit-tested. **This is the isolation layer that makes the mechanism swappable** — everything + else depends only on this interface, never on a concrete tool. Lives in the backend-agnostic + [`composer/sandbox`](../composer/sandbox/) package (with `run_local_command`), not under `rustapp`. +2. **The custom launcher provider** — *done*: the `run-confined` **trusted Rust binary** + ([rust/run-confined](../rust/run-confined)) + the `LauncherProvider` + ([composer/sandbox/launcher.py](../composer/sandbox/launcher.py)) that maps a + `SandboxPolicy` to its argv. `run-confined --ro … --rw … --allow-env NAME[=VAL]… + --rlimit-* … [--allow-network] -- ` sets rlimits + `NO_NEW_PRIVS`, builds the + Landlock ruleset (best-effort ABI negotiation, full FS bit set, deny-by-default + §3 grants) via + the [`landlock`](https://crates.io/crates/landlock) crate, builds the seccomp filter (deny inet + sockets + ptrace/process_vm_*) via [`seccompiler`](https://crates.io/crates/seccompiler), applies + both, then `execve`s the command with an env scrubbed to the allowlist. `--probe` reports the + kernel Landlock ABI and drives `available()` → fail-closed (§7). Enforcement smoke-tested on the + host (write-outside / `/etc/passwd` / `/proc//environ` / inet-socket all denied; workdir + write, AF_UNIX, and toolchain `exec` allowed); argv mapping golden-tested. Full escape gate is + step 5. +3. **Thread `policy` + provider through `run_local_command`** — *done*: the runner accepts + `provider`/`policy` (default `None` → the `none` passthrough, byte-for-byte today's behavior) and + is fail-closed via `ensure_available`. A `SandboxConfig` ([composer/sandbox/config.py](../composer/sandbox/config.py)) + selects the provider (`$COMPOSER_SANDBOX_PROVIDER`, default `none`) and builds the policy via the + `rust_build_policy` recipe ([composer/sandbox/recipes.py](../composer/sandbox/recipes.py) — the + workdir and `/dev` nodes rw; discovered rust/cargo/platform-tool and system dirs ro, incl. `/etc` + for NSS; env allowlist; network off). Threaded through `RealEffects` and `RustBackend`/`RustFormalizer` + ([composer/rustapp/adapter.py](../composer/rustapp/adapter.py)), `build_program` + ([composer/spec/solana/build.py](../composer/spec/solana/build.py)), and the Crucible pipeline + (which adds the crucible checkout + binary to `extra_ro`). Integration-tested: `run_local_command` + under the launcher denies out-of-workdir reads and network while allowing the workdir + toolchain. +4. **Offline prep (§5)** — *done*: `warm_cargo_cache` (a `cargo fetch` run outside the sandbox, + network on) warms the registry, and the policy sets `CARGO_NET_OFFLINE=1` so the confined build — + and the nested cargo `crucible run` spawns — run offline. Wired into `build_program`; the + harness-dir warm is `CrucibleArtifactStore.warm_dependencies`, called from `prepare_formalization` + after the manifest is placed when a sandbox is on. `CARGO_HOME` is granted rw (the crucible policy) + so cargo can extract crate sources offline. +5. **The escape-test gate (§10)** — *done*, and **Crucible's default provider is now `launcher`** + (`_crucible_sandbox`; override with `COMPOSER_SANDBOX_PROVIDER=none`). Validated: + - **Part A (escape suite) — green** ([tests/test_sandbox_escape.py](../tests/test_sandbox_escape.py)): + a `rustc`-compiled malicious program run through the real launcher has every vector *denied* + (secret env, `/proc//environ`, host file outside the workdir, external TCP, and + `169.254.169.254`), with an unconfined control confirming the leaks would otherwise happen. + - **Part B — green**: a real `cargo-build-sbf` of `solana_vault` under the launcher (offline, + confined) produces the `.so` ([tests/test_crucible_sandbox_gate.py](../tests/test_crucible_sandbox_gate.py) + — this caught the relative-policy-path bug; grants must be absolute), and a real + `crucible run --dry-run` under the launcher builds the harness *offline* and runs LiteSVM + (`Harness validation passed!`). + - **Full LLM vertical — green**: the e2e gate (`tests/test_crucible_e2e_gate.py`) passes under the + launcher (`COMPOSER_SANDBOX_PROVIDER=launcher`): analysis → 23 properties → shared fixture + authored → per-instruction harness build + fuzz, all confined + offline, with **all three + instructions (initialize / deposit / withdraw) delivered with fuzz verdicts** (`BAD` — + counterexamples found). Getting here required the `/tmp` fix below and the shared-crate + concurrency fix (§11 item 8); before the latter, `initialize` was dropped to a `Cargo.toml` + feature race. + + **Root cause found via the gate:** every fresh harness build initially failed at the *link* step — + `Cannot create temporary file in /tmp/: Permission denied` (the linker's `$TMPDIR` scratch, which + the policy didn't grant). A link failure reads as "could not compile", so the LLM kept rewriting a + fine fixture. Fixed by redirecting `TMPDIR` to a private `/.sandbox_tmp` (§3) rather than + granting the shared `/tmp`. The `RunCommand` failure logging added alongside the authoring + improvements is what surfaced it. + +Each step is behind the seam, so the earlier Phase 1–5 gates keep passing. **Prerequisite of the +flip:** `run-confined` must be on PATH (built into the Docker image; `cargo build -p run-confined +--release` in dev) — otherwise Crucible fail-closes (§7/§8). A later off-the-shelf swap +(`landrun`/`sandlock`) is *only* a new step-2-style provider — the seam, policy, and gate are +untouched. + +--- + +## 10. The gate — an escape test + +A new expensive gate (`tests/test_crucible_sandbox_gate.py`) with two halves: + +**A. Escape attempts are denied.** Author a *malicious* harness whose `setup()` (and a companion +`build.rs`) attempts, and record the outcome of each: + +- **Read a planted secret** — set a fake `ANTHROPIC_API_KEY=canary-` in AutoProver's env + before the run; the harness (a) reads `std::env::var("ANTHROPIC_API_KEY")` and (b) tries to read + it out-of-band from `/proc//environ`, writing whatever it finds to a workdir file. Assert + the value is **absent** by both routes. +- **Read a host file outside the workdir** — the harness tries to open `/etc/passwd` and a file in + the real project root *outside* the granted workdir. Assert **permission denied**. +- **Reach the network** — the harness tries to connect to an external host **and** to + `http://169.254.169.254/latest/meta-data/` (the EC2 metadata endpoint → IAM creds). Assert **both + fail** (socket creation denied). + +The harness must not be able to fail the assertions silently — it writes each probe's result into +the workdir (allowed) and the test reads them back, asserting every probe reports *denied*. + +**B. The legitimate path still works.** The existing `solana_vault` gate ([§8](./crucible-application.md#L545)) +passes **unchanged** under the launcher provider — the shared fixture is authored, the `.so` builds, +tests compile and fuzz, verdicts are produced. This proves the sandbox grants exactly the toolchain +the real work needs and nothing more. + +Because the gate is written against the `SandboxProvider` seam (§4), not a specific tool, it doubles +as the **conformance test any future provider must pass** — swapping in `landrun`/`sandlock` means +re-running this same gate green, nothing more. + +Only when both halves are green may the backend run on untrusted input (the §9 definition of done). + +--- + +## 11. Open questions + +1. **Landlock ABI coverage / negotiation.** The launcher must handle the full FS access-right set of + the *running* kernel's ABI (unhandled rights stay unrestricted) with best-effort fallback on older + kernels. The `landlock` crate does this; confirm the minimum supported ABI on our target AMIs and + what "best-effort" degrades to (e.g. pre-ABI-3 has no `TRUNCATE` handling). +2. **AF_UNIX / netlink allowance.** The seccomp filter denies `AF_INET`/`AF_INET6` but allows + `AF_UNIX`. Confirm the toolchain (cargo jobserver, rustc, linker) needs nothing more; if a + benign `AF_NETLINK` use surfaces, decide whether to allow it (it can read but not egress). +3. **rlimits vs cgroup v2 (§7).** Is `RLIMIT_AS` enough to contain a memory-hungry fuzzer, or do we + need cgroup `memory.max` (and thus writable cgroup delegation in the container) sooner? +4. **Cache warming cost (§5).** Per-run `cargo fetch` adds latency; is a shared, pre-warmed + read-only registry volume worth it for CI throughput? +5. **Per-run `CARGO_HOME` — done.** An offline `cargo build` *writes* to `CARGO_HOME` (extracts crate + sources, takes locks), and that build runs untrusted `build.rs`/proc-macro code — so a writable + *shared* `~/.cargo` was a cross-run poisoning surface (overwrite an extracted `registry/src` to + hit a later run). Fixed: `rust_build_policy` points `CARGO_HOME` at a **private per-run dir under + the workdir** (`sandbox_cargo_home` → `/.sandbox_cargo`), the warm step (`warm_cargo_cache`, + unsandboxed) fetches *into that same home*, and the shared `~/.cargo` is granted read-only (for the + `cargo` binary) — so untrusted writes touch only the run's throwaway cache. Validated: a fresh + fetch into an empty private home + a confined offline build succeed. **Remaining cost:** deps are + re-fetched per run (no shared writable cache); a shared *read-only* index/cache to avoid the + re-download is the deferred optimization. +6. **Off-the-shelf provider swap (deferred, seam is ready — §4/§6).** `sandlock` (needs kernel + ≥6.12; unstated license) or `landrun` (+ a seccomp companion for UDP/DNS + rlimits) could replace + the custom launcher as a new `SandboxProvider` if reviewers prefer an off-the-shelf boundary. Blocked + today on the kernel-floor (target AMI ≥6.12?) and license questions; revisit once those resolve. + The provider seam + the gate-as-conformance-test (§10) make the swap mechanical. +7. **Infra-layer hardening (orthogonal, non-blocking).** Independent of this in-process boundary, + deployments running genuinely untrusted programs should also apply the standard EC2 hardening — + least-privilege instance IAM role, IMDSv2 with hop limit 1, egress-restricted security group, and + (if desired) VM-per-run or a gVisor runtime. Decide per deployment when the tenancy model is + settled; none of it blocks Phase 6. +8. **Shared-`Cargo.toml`/`main.rs` race (crucible backend) — fixed.** The per-component sessions + share one `fuzz//` crate; concurrent runs raced on both files (the observed + "package does not contain this feature: `c_`" that dropped `initialize`, and a latent + `main.rs` clobber). Fixed two ways: `prepare_component` now reserves Cargo features + **cumulatively** (the manifest only grows, so no feature is lost), and per-component command runs + are **serialized + atomic** (`run_local_command` materializes files and runs as one unit under a + `Semaphore(1)` shared by `RustFormalizer`), while the LLM authoring turns still run concurrently. + The remaining parallelism win — concurrent *builds/fuzzing* — needs a crate-per-component (§10 Q1); + deferred. diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 00000000..6e3ef630 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,113 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "landlock" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" +dependencies = [ + "enumflags2", + "libc", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "run-confined" +version = "0.1.0" +dependencies = [ + "landlock", + "libc", + "seccompiler", +] + +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 00000000..be0ad6b3 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,21 @@ +# Cargo workspace for AutoProver's Rust components. +# +# In this PR the workspace contains a single member: +# +# * `run-confined` — the trusted command-sandbox launcher (Landlock filesystem + +# seccomp network/ptrace + rlimits + scrubbed env, then `execve`). The first +# `SandboxProvider` for the `RunCommand` effect — see docs/command-sandbox.md. +# +# Later PRs re-add the Rust *application framework* crates to `members`: +# `autoprover-sdk` (the ABI + `export_app!` macro) and `example-app` (its +# round-trip test wheel), then the `crucible-app` wheel. Those crates depend on +# the shared `[workspace.dependencies]` (serde / pyo3); `run-confined` does not, +# so this trimmed workspace declares none. +[workspace] +resolver = "2" +members = ["run-confined"] + +[workspace.package] +edition = "2021" +version = "0.1.0" +license = "MIT" diff --git a/rust/run-confined/Cargo.toml b/rust/run-confined/Cargo.toml new file mode 100644 index 00000000..b02f9e46 --- /dev/null +++ b/rust/run-confined/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "run-confined" +edition.workspace = true +version.workspace = true +license.workspace = true +description = "Trusted launcher that confines a command (Landlock filesystem + seccomp network/ptrace + rlimits + scrubbed env), then execs it. The first SandboxProvider for the RunCommand effect — see docs/command-sandbox.md." + +[[bin]] +name = "run-confined" +path = "src/main.rs" + +[dependencies] +# Filesystem confinement (ABI negotiation + full access-right set). +landlock = "0.4" +# seccomp-BPF compiler from AWS Firecracker — we hand it a small allow/deny +# policy, not raw bytecode. +seccompiler = "0.5" +# setrlimit / prctl / syscall numbers / AF_* constants. +libc = "0.2" diff --git a/rust/run-confined/src/main.rs b/rust/run-confined/src/main.rs new file mode 100644 index 00000000..bb296137 --- /dev/null +++ b/rust/run-confined/src/main.rs @@ -0,0 +1,288 @@ +//! `run-confined` — the trusted launcher for the `RunCommand` sandbox. +//! +//! It applies four unprivileged, in-kernel confinements to *itself*, then `execve`s +//! the requested command (which inherits all of them across the exec): +//! +//! 1. **Landlock** — a filesystem ruleset: default-deny, then grant `--rw` paths +//! full access and `--ro` paths read+execute. Confines reads *and* writes and, +//! by not granting `/proc`, closes the same-uid `/proc//environ` leak. +//! 2. **seccomp** — deny inet-domain `socket()` (blocks TCP, UDP/DNS, and the EC2 +//! metadata endpoint) and `ptrace`/`process_vm_readv`/`process_vm_writev`. +//! 3. **env allowlist** — `execve` with only `--allow-env` variables (a scrubbed +//! environment). +//! 4. **rlimits** — `--rlimit-*` caps on address space / CPU-seconds / pids / file size. +//! +//! This is trusted code: its argv is authored by the Python side (never the LLM, +//! which controls only file *contents*). It is **fail-closed** — any setup failure, +//! or a kernel without Landlock, exits nonzero *without* execing the command, so +//! untrusted input never runs unconfined. +//! +//! See `docs/command-sandbox.md` (§6) for the design and the validation matrix. + +use std::collections::BTreeMap; +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::Command; + +use landlock::{ + Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr, + RulesetCreatedAttr, RulesetStatus, ABI, +}; +use seccompiler::{ + apply_filter, BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, + SeccompFilter, SeccompRule, TargetArch, +}; + +/// Bad command line (a programming error on the trusted caller's side). +const EXIT_USAGE: i32 = 2; +/// The sandbox could not be established — fail-closed, command NOT run. +const EXIT_SANDBOX_UNAVAILABLE: i32 = 3; +/// The confined `execve` itself failed (e.g. program not found on PATH). +const EXIT_EXEC_FAILED: i32 = 127; + +/// `landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)` returns the +/// kernel's supported ABI version, or fails if Landlock is unavailable. +const LANDLOCK_CREATE_RULESET_VERSION: libc::c_ulong = 1; + +#[derive(Default)] +struct Config { + rw_paths: Vec, + ro_paths: Vec, + env: Vec<(String, String)>, + allow_network: bool, + rlimit_as: Option, + rlimit_cpu: Option, + rlimit_nproc: Option, + rlimit_fsize: Option, + program: String, + args: Vec, +} + +fn die(code: i32, msg: &str) -> ! { + eprintln!("run-confined: {msg}"); + std::process::exit(code); +} + +fn main() { + let argv: Vec = std::env::args().skip(1).collect(); + + if argv.first().map(String::as_str) == Some("--probe") { + probe(); + } + + let cfg = parse(&argv).unwrap_or_else(|e| die(EXIT_USAGE, &e)); + + // Order matters: rlimits + env are harmless early; apply Landlock, then seccomp + // LAST so our own setup syscalls aren't caught by the filter; then exec. + set_rlimits(&cfg); + set_no_new_privs(); + if let Err(e) = apply_landlock(&cfg) { + die(EXIT_SANDBOX_UNAVAILABLE, &format!("Landlock setup failed: {e}")); + } + if let Err(e) = apply_seccomp(&cfg) { + die(EXIT_SANDBOX_UNAVAILABLE, &format!("seccomp setup failed: {e}")); + } + + let mut cmd = Command::new(&cfg.program); + cmd.args(&cfg.args).env_clear().envs(cfg.env.iter().cloned()); + // `exec` replaces this process image; it only returns on failure. + let err = cmd.exec(); + die(EXIT_EXEC_FAILED, &format!("exec {:?} failed: {err}", cfg.program)); +} + +/// `--probe`: report whether the kernel supports Landlock. Exit 0 + print the ABI +/// if so; exit `EXIT_SANDBOX_UNAVAILABLE` otherwise. Drives Python's fail-closed +/// `available()` check without restricting this (throwaway) process. +fn probe() -> ! { + let abi = unsafe { + libc::syscall( + libc::SYS_landlock_create_ruleset, + std::ptr::null::(), + 0usize, + LANDLOCK_CREATE_RULESET_VERSION, + ) + }; + if abi > 0 { + println!("landlock abi {abi}"); + std::process::exit(0); + } + die( + EXIT_SANDBOX_UNAVAILABLE, + "kernel does not support Landlock (need Linux >= 5.13); refusing to run unconfined", + ); +} + +fn parse(argv: &[String]) -> Result { + let mut cfg = Config::default(); + let mut i = 0; + + let take = |i: &mut usize, flag: &str| -> Result { + *i += 1; + argv.get(*i) + .cloned() + .ok_or_else(|| format!("{flag} requires a value")) + }; + let parse_u64 = |s: &str, flag: &str| -> Result { + s.parse::().map_err(|_| format!("{flag} expects an integer, got {s:?}")) + }; + + while i < argv.len() { + match argv[i].as_str() { + "--rw" => cfg.rw_paths.push(PathBuf::from(take(&mut i, "--rw")?)), + "--ro" => cfg.ro_paths.push(PathBuf::from(take(&mut i, "--ro")?)), + "--allow-network" => cfg.allow_network = true, + "--rlimit-as" => cfg.rlimit_as = Some(parse_u64(&take(&mut i, "--rlimit-as")?, "--rlimit-as")?), + "--rlimit-cpu" => cfg.rlimit_cpu = Some(parse_u64(&take(&mut i, "--rlimit-cpu")?, "--rlimit-cpu")?), + "--rlimit-nproc" => cfg.rlimit_nproc = Some(parse_u64(&take(&mut i, "--rlimit-nproc")?, "--rlimit-nproc")?), + "--rlimit-fsize" => cfg.rlimit_fsize = Some(parse_u64(&take(&mut i, "--rlimit-fsize")?, "--rlimit-fsize")?), + "--allow-env" => { + let spec = take(&mut i, "--allow-env")?; + if let Some((name, value)) = spec.split_once('=') { + cfg.env.push((name.to_string(), value.to_string())); + } else if let Ok(value) = std::env::var(&spec) { + // NAME with no '=': pass through from the current environment if set. + cfg.env.push((spec, value)); + } + // NAME not present in the environment: silently skip (nothing to pass). + } + "--" => { + i += 1; + if i >= argv.len() { + return Err("no program given after `--`".to_string()); + } + cfg.program = argv[i].clone(); + cfg.args = argv[i + 1..].to_vec(); + return Ok(cfg); + } + other => return Err(format!("unknown flag {other:?} (did you forget `--` before the command?)")), + } + i += 1; + } + Err("missing `--` and command to run".to_string()) +} + +fn set_rlimits(cfg: &Config) { + let set = |resource: libc::__rlimit_resource_t, value: u64| { + let lim = libc::rlimit { rlim_cur: value, rlim_max: value }; + // Best-effort: a failure to *lower* a limit is not worth aborting the run over. + unsafe { libc::setrlimit(resource, &lim) }; + }; + if let Some(v) = cfg.rlimit_as { + set(libc::RLIMIT_AS, v); + } + if let Some(v) = cfg.rlimit_cpu { + set(libc::RLIMIT_CPU, v); + } + if let Some(v) = cfg.rlimit_nproc { + set(libc::RLIMIT_NPROC, v); + } + if let Some(v) = cfg.rlimit_fsize { + set(libc::RLIMIT_FSIZE, v); + } +} + +fn set_no_new_privs() { + // Required before loading a seccomp filter (and by Landlock) for an unprivileged + // process; ensures no exec can regain privileges. + unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; +} + +fn apply_landlock(cfg: &Config) -> Result<(), String> { + // Handle the full access-right set the crate knows; BestEffort tolerates a kernel + // that lacks the newest rights, but we still require Landlock to be *enforcing* + // at all (checked below) — otherwise we would silently run unconfined. + let abi = ABI::V5; + + let mut created = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(AccessFs::from_all(abi)) + .map_err(|e| e.to_string())? + .create() + .map_err(|e| e.to_string())?; + + for p in &cfg.ro_paths { + match PathFd::new(p) { + Ok(fd) => { + created = created + .add_rule(PathBeneath::new(fd, AccessFs::from_read(abi))) + .map_err(|e| e.to_string())?; + } + Err(e) => eprintln!("run-confined: skipping missing --ro path {p:?}: {e}"), + } + } + for p in &cfg.rw_paths { + match PathFd::new(p) { + Ok(fd) => { + created = created + .add_rule(PathBeneath::new(fd, AccessFs::from_all(abi))) + .map_err(|e| e.to_string())?; + } + Err(e) => return Err(format!("required --rw path {p:?} is unopenable: {e}")), + } + } + + let status = created.restrict_self().map_err(|e| e.to_string())?; + if matches!(status.ruleset, RulesetStatus::NotEnforced) { + return Err("kernel did not enforce Landlock (need Linux >= 5.13)".to_string()); + } + Ok(()) +} + +fn apply_seccomp(cfg: &Config) -> Result<(), String> { + let mut rules: BTreeMap> = BTreeMap::new(); + + if !cfg.allow_network { + // Deny socket() for AF_INET / AF_INET6 (arg 0) — blocks TCP, UDP (incl. DNS), + // and the IMDS endpoint. AF_UNIX and other local families still work. + let cond = |domain: i32| -> Result { + SeccompRule::new(vec![SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Eq, + domain as u64, + ) + .map_err(|e| e.to_string())?]) + .map_err(|e| e.to_string()) + }; + rules.insert( + libc::SYS_socket as i64, + vec![cond(libc::AF_INET)?, cond(libc::AF_INET6)?], + ); + } + + // Deny cross-process memory/ptrace (belt-and-suspenders to Landlock's own + // out-of-domain ptrace restriction). An empty rule vec = match unconditionally. + for nr in [ + libc::SYS_ptrace, + libc::SYS_process_vm_readv, + libc::SYS_process_vm_writev, + ] { + rules.insert(nr as i64, Vec::new()); + } + + let filter = SeccompFilter::new( + rules, + SeccompAction::Allow, // default: allow syscalls we didn't name + SeccompAction::Errno(libc::EPERM as u32), // named + matched: deny with EPERM + target_arch(), + ) + .map_err(|e| e.to_string())?; + + let program: BpfProgram = filter.try_into().map_err(|e: seccompiler::BackendError| e.to_string())?; + apply_filter(&program).map_err(|e| e.to_string()) +} + +fn target_arch() -> TargetArch { + #[cfg(target_arch = "x86_64")] + { + TargetArch::x86_64 + } + #[cfg(target_arch = "aarch64")] + { + TargetArch::aarch64 + } + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + { + compile_error!("run-confined supports only x86_64 and aarch64") + } +} diff --git a/scripts/Dockerfile b/scripts/Dockerfile index bf94e041..57d98ca6 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -59,6 +59,16 @@ RUN set -eux; \ ( cd "$target" && /venv/bin/sphinx-build -M singlehtml . tmp ); \ cp "$target/tmp/singlehtml/index.html" /out/cvl.html +# ---- Stage 1b: build the `run-confined` command-sandbox launcher ---- +# The `launcher` sandbox provider confines every RunCommand via this small Rust binary +# (Landlock + seccomp; docs/command-sandbox.md). It must be on PATH in the runtime image +# or the provider fail-closes. Built in a throwaway Rust stage; only the binary is copied out. +FROM --platform=linux/amd64 rust:1-slim AS sandbox-builder +WORKDIR /build +COPY rust/ ./rust/ +# Only run-confined (+ its landlock/seccompiler/libc deps) — not the pyo3 members. +RUN cd rust && cargo build -p run-confined --release + # ---- Stage 2: final runtime image ---- FROM --platform=linux/amd64 python:3.12-slim AS final @@ -85,6 +95,8 @@ RUN mkdir -p $HOME/.cache/huggingface \ # postgresql-client provides `psql` for the entrypoint's setup-db; git is # occasionally shelled out to by tooling. No openssh-client / SSH — the # Python install pulls from PyPI + the vendored graphcore submodule. +# (The `RunCommand` sandbox uses in-kernel Landlock + seccomp — no package +# needed; see docs/command-sandbox.md §6.) RUN apt-get update \ && apt-get install -y --no-install-recommends \ git ca-certificates \ @@ -149,6 +161,10 @@ RUN --mount=type=cache,id=uv-cache,target=/root/.cache/uv \ # Pre-built CVL HTML — the entrypoint's setup-db reads this to populate rag_db. COPY --from=docs-builder /out/cvl.html $AUTOPROVE_HOME/prover-docs/cvl.html +# The command-sandbox launcher, on PATH (/usr/local/bin is in PATH above). Any consumer +# that selects the `launcher` provider needs this present, or the provider fail-closes. +COPY --from=sandbox-builder /build/rust/target/release/run-confined /usr/local/bin/run-confined + # Pre-download the sentence-transformers embedding model so first runs are # offline-fast. nomic-embed-text-v1.5 ships custom modeling code, hence # trust_remote_code. Then make the cache world-writable so the HF library can diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 00000000..f3618fa0 --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,127 @@ +"""Unit tests for the command-sandbox provider seam (step 1). + +Pure and fast — no Rust wheel, no subprocess, no Postgres/LLM. They pin the +tool-agnostic contract (``docs/command-sandbox.md`` §4/§7) that every provider +must honor: the ``none`` passthrough is byte-for-byte today's behavior, the +registry resolves/rejects names, and the fail-closed check fires only when a +provider reports itself unavailable. +""" + +from pathlib import Path + +import pytest + +from composer.sandbox.policy import ( + Availability, + LaunchSpec, + NoneProvider, + SandboxPolicy, + SandboxProvider, + SandboxUnavailable, + ensure_available, + get_provider, + register_provider, +) + + +def test_policy_defaults_are_locked_down(): + """A default policy denies everything: no paths, empty env, network off, no caps.""" + p = SandboxPolicy() + assert p.rw_paths == () + assert p.ro_paths == () + assert dict(p.env_allowlist) == {} + assert p.network is False + assert p.mem_bytes is None and p.cpu_seconds is None + assert p.nproc is None and p.fsize_bytes is None + + +def test_policy_is_frozen(): + p = SandboxPolicy(rw_paths=(Path("/work"),)) + with pytest.raises((AttributeError, TypeError)): + p.network = True # type: ignore[misc] + + +def test_none_provider_is_a_passthrough(): + """``none`` execs the command verbatim and inherits the env (env is None).""" + spec = NoneProvider().wrap(SandboxPolicy(), "cargo", ["build", "--offline"]) + assert spec == LaunchSpec(argv=("cargo", "build", "--offline"), env=None) + + +def test_none_provider_ignores_policy(): + """Passthrough grants no isolation, so a rich policy must not alter its argv.""" + rich = SandboxPolicy( + rw_paths=(Path("/work"),), + ro_paths=(Path("/usr"),), + env_allowlist={"PATH": "/usr/bin"}, + network=True, + mem_bytes=1 << 32, + ) + spec = NoneProvider().wrap(rich, "echo", ["hi"]) + assert spec.argv == ("echo", "hi") + assert spec.env is None + + +def test_none_provider_available(): + assert NoneProvider().available() == Availability(ok=True) + + +def test_none_provider_satisfies_protocol(): + # runtime_checkable structural check: the concrete class implements the seam. + assert isinstance(NoneProvider(), SandboxProvider) + + +def test_get_provider_known(): + prov = get_provider("none") + assert isinstance(prov, NoneProvider) + assert prov.name == "none" + + +def test_get_provider_unknown_is_value_error(): + with pytest.raises(ValueError, match="unknown sandbox provider 'bogus'"): + get_provider("bogus") + + +def test_register_provider_roundtrip(): + """A newly registered factory becomes resolvable by name (how step 2 adds the + launcher without this module importing it).""" + + class _Fake: + name = "fake" + + def available(self) -> Availability: + return Availability(ok=True) + + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: + return LaunchSpec(argv=("fake", program, *args)) + + register_provider("fake", _Fake) + try: + assert isinstance(get_provider("fake"), _Fake) + finally: + # keep the module-level registry clean for other tests + from composer.sandbox import policy as _s + + _s._PROVIDERS.pop("fake", None) + + +def test_ensure_available_passes_for_ok_provider(): + ensure_available(NoneProvider()) # must not raise + + +def test_ensure_available_fails_closed(): + """An unavailable provider raises rather than letting the command run unconfined.""" + + class _Unavailable: + name = "landlock-missing" + + def available(self) -> Availability: + return Availability(ok=False, reason="kernel lacks Landlock (need Linux >= 5.13)") + + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: + raise AssertionError("wrap must not be reached when unavailable") + + with pytest.raises(SandboxUnavailable) as ei: + ensure_available(_Unavailable()) + assert ei.value.provider == "landlock-missing" + assert "Landlock" in ei.value.reason + assert "unavailable" in str(ei.value) diff --git a/tests/test_sandbox_command.py b/tests/test_sandbox_command.py new file mode 100644 index 00000000..62a6453f --- /dev/null +++ b/tests/test_sandbox_command.py @@ -0,0 +1,64 @@ +"""Unit tests for the shared local-command runner (``run_local_command``). + +Needs neither a Rust wheel nor Postgres/LLM: ``run_local_command`` shells out to +trivial system binaries. Covers file materialization, path confinement, and the +error/timeout paths. (It still backs the trusted Python build steps — e.g. the sBPF +build; the Rust backend's own toolchain runs now go through ``run-confined`` in the +wheel, see ``docs/rust-backend-api.md``.) +""" + +import pytest + +from composer.sandbox.command import ( + NOT_FOUND_EXIT, + UnsafePath, + run_local_command, +) + + +@pytest.mark.asyncio +async def test_run_local_command_materializes_files_and_captures_output(tmp_path): + res = await run_local_command( + "printf", ["%s", "hello"], {"note.txt": "hi", "sub/deep.txt": "deep"}, workdir=tmp_path + ) + assert res.exit_code == 0 + assert res.stdout == "hello" + # files (incl. a nested path) were materialized into the workdir. + assert (tmp_path / "note.txt").read_text() == "hi" + assert (tmp_path / "sub" / "deep.txt").read_text() == "deep" + + +@pytest.mark.asyncio +async def test_run_local_command_missing_binary(tmp_path): + res = await run_local_command("autoprover-no-such-binary-xyz", [], {}, workdir=tmp_path) + assert res.exit_code == NOT_FOUND_EXIT + assert "not found" in res.stderr + + +@pytest.mark.asyncio +async def test_run_local_command_nonzero_exit(tmp_path): + res = await run_local_command("false", [], {}, workdir=tmp_path) + assert res.exit_code != 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("bad", ["../evil.txt", "/etc/evil", "a/../../evil"]) +async def test_run_local_command_rejects_path_escape(tmp_path, bad): + with pytest.raises(UnsafePath): + await run_local_command("true", [], {bad: "x"}, workdir=tmp_path) + + +@pytest.mark.asyncio +async def test_run_local_command_no_shell_injection(tmp_path): + # Args are argv, never a shell string: a shell metacharacter is inert. `printf` + # emits it literally rather than a subshell running `id`. + res = await run_local_command("printf", ["%s", "$(id)"], {}, workdir=tmp_path) + assert res.exit_code == 0 + assert res.stdout == "$(id)" + + +@pytest.mark.asyncio +async def test_run_local_command_timeout(tmp_path): + res = await run_local_command("sleep", ["5"], {}, workdir=tmp_path, timeout_s=1) + assert res.exit_code == -1 + assert "timed out" in res.stderr diff --git a/tests/test_sandbox_config.py b/tests/test_sandbox_config.py new file mode 100644 index 00000000..da7e0c0c --- /dev/null +++ b/tests/test_sandbox_config.py @@ -0,0 +1,102 @@ +"""Unit tests for the sandbox config + the Rust-build policy recipe (step 3). + +Pure: no subprocess, no Rust binary. They pin provider selection (default ``none``, +``$COMPOSER_SANDBOX_PROVIDER`` override) and that the recipe grants the workdir +read-write, discoverable toolchain dirs read-only, and a scrubbed env with the +network off. +""" + +from pathlib import Path + +from composer.sandbox.config import SandboxConfig +from composer.sandbox.launcher import LauncherProvider +from composer.sandbox.policy import NoneProvider, SandboxPolicy +from composer.sandbox.recipes import rust_build_policy + + +def test_config_default_is_none_and_disabled(): + cfg = SandboxConfig() + assert cfg.provider == "none" + assert cfg.enabled is False + assert isinstance(cfg.resolve_provider(), NoneProvider) + + +def test_config_from_env_default(monkeypatch): + monkeypatch.delenv("COMPOSER_SANDBOX_PROVIDER", raising=False) + assert SandboxConfig.from_env().provider == "none" + + +def test_config_from_env_launcher(monkeypatch): + monkeypatch.setenv("COMPOSER_SANDBOX_PROVIDER", "launcher") + cfg = SandboxConfig.from_env(extra_ro=(Path("/usr"),)) + assert cfg.provider == "launcher" + assert cfg.enabled is True + assert isinstance(cfg.resolve_provider(), LauncherProvider) + assert cfg.extra_ro == (Path("/usr"),) + + +def test_config_none_build_policy_is_empty(): + """The none provider ignores the policy, so build_policy returns a bare one.""" + pol = SandboxConfig().build_policy("/work") + assert pol == SandboxPolicy() + + +def test_config_enabled_build_policy_grants_workdir(tmp_path): + cfg = SandboxConfig(provider="launcher", mem_bytes=1 << 30) + pol = cfg.build_policy(tmp_path) + assert tmp_path in pol.rw_paths + assert pol.network is False + assert pol.mem_bytes == (1 << 30) + + +def test_rust_build_policy_shape(tmp_path, monkeypatch): + monkeypatch.setenv("PATH", "/usr/bin:/bin") + monkeypatch.setenv("MY_SECRET", "do-not-pass") + extra_ro_dir = tmp_path / "toolchain" + extra_ro_dir.mkdir() + extra_rw_dir = tmp_path / "scratch" + extra_rw_dir.mkdir() + + pol = rust_build_policy( + tmp_path, + extra_ro=(extra_ro_dir, tmp_path / "does-not-exist"), + extra_rw=(extra_rw_dir,), + cpu_seconds=900, + ) + + # workdir + existing extra_rw are writable + assert tmp_path in pol.rw_paths + assert extra_rw_dir in pol.rw_paths + # existing extra_ro granted; non-existent dropped + assert extra_ro_dir in pol.ro_paths + assert (tmp_path / "does-not-exist") not in pol.ro_paths + # env: only allowlisted names pass through; secrets do not + assert pol.env_allowlist.get("PATH") == "/usr/bin:/bin" + assert "MY_SECRET" not in pol.env_allowlist + # network off, caps threaded + assert pol.network is False + assert pol.cpu_seconds == 900 + + +def test_rust_build_policy_offline_sets_cargo_net_offline(tmp_path): + """Default (offline) forces every cargo — incl. the one `crucible run` spawns — + offline via CARGO_NET_OFFLINE; opting out drops it.""" + on = rust_build_policy(tmp_path) + assert on.env_allowlist.get("CARGO_NET_OFFLINE") == "1" + off = rust_build_policy(tmp_path, offline=False) + assert "CARGO_NET_OFFLINE" not in off.env_allowlist + + +def test_config_enabled_policy_is_offline_by_default(tmp_path): + pol = SandboxConfig(provider="launcher").build_policy(tmp_path) + assert pol.env_allowlist.get("CARGO_NET_OFFLINE") == "1" + pol_net = SandboxConfig(provider="launcher", offline=False).build_policy(tmp_path) + assert "CARGO_NET_OFFLINE" not in pol_net.env_allowlist + + +def test_rust_build_policy_includes_system_and_dev_when_present(): + pol = rust_build_policy("/tmp") + if Path("/usr").exists(): + assert Path("/usr") in pol.ro_paths + if Path("/dev/null").exists(): + assert Path("/dev/null") in pol.rw_paths diff --git a/tests/test_sandbox_escape.py b/tests/test_sandbox_escape.py new file mode 100644 index 00000000..93752b4f --- /dev/null +++ b/tests/test_sandbox_escape.py @@ -0,0 +1,143 @@ +"""The escape suite — Part A of the Phase-6 gate (docs/command-sandbox.md §10). + +A *malicious* program (standing in for a harness `setup()` / a program's `build.rs`) +is compiled with `rustc`, then run through the **real** `run-confined` launcher via +`run_local_command` under a Crucible-representative policy (`rust_build_policy`). It +attempts every escape and writes each result into the workdir (allowed); the test +reads them back and asserts *denied* for all. A no-sandbox control runs the same +binary unconfined and confirms the leaks would otherwise happen — proving it is the +sandbox doing the blocking. + +Runnable without the full Crucible stack (std-only program, no crates, no network +needed to compile). Skipped unless `rustc` and a working launcher are present. The +*legitimate* half (a real `solana_vault` build+fuzz under the launcher) is the +expensive Part B in `tests/test_crucible_sandbox_gate.py`. +""" + +import asyncio +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from composer.sandbox.command import run_local_command +from composer.sandbox.launcher import LauncherProvider +from composer.sandbox.recipes import rust_build_policy + +pytestmark = pytest.mark.asyncio + +_PROVIDER = LauncherProvider() +_needs = pytest.mark.skipif( + shutil.which("rustc") is None or not _PROVIDER.available().ok, + reason="needs rustc + a working run-confined launcher (Linux/Landlock)", +) + +_ENV_CANARY = "ENVCANARY-a1b2c3" +_HOSTFILE_CANARY = "HOSTFILECANARY-d4e5f6" + +# Standing in for hostile code in setup()/build.rs. std-only so it compiles offline. +_MALICIOUS_RS = """ +use std::fs; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +fn probe(name: &str, result: &str) { + let _ = fs::write(format!("probe_{}.txt", name), result); +} + +fn net(addr: &str) -> String { + let sa: SocketAddr = addr.parse().unwrap(); + match TcpStream::connect_timeout(&sa, Duration::from_secs(2)) { + Ok(_) => "LEAK:connected".to_string(), + Err(_) => "denied".to_string(), + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let outside = args.get(1).cloned().unwrap_or_default(); + let parent_pid = args.get(2).cloned().unwrap_or_default(); + + probe("env", &match std::env::var("ANTHROPIC_API_KEY") { + Ok(v) => format!("LEAK:{}", v), + Err(_) => "denied".to_string(), + }); + + probe("procenv", &match fs::read_to_string(format!("/proc/{}/environ", parent_pid)) { + Ok(s) if s.contains("ENVCANARY") => "LEAK:found-canary".to_string(), + Ok(_) => "LEAK:proc-readable".to_string(), + Err(_) => "denied".to_string(), + }); + + probe("hostfile", &match fs::read_to_string(&outside) { + Ok(s) => format!("LEAK:{}", s.trim()), + Err(_) => "denied".to_string(), + }); + + probe("net_ext", &net("1.1.1.1:80")); + probe("imds", &net("169.254.169.254:80")); +} +""" + + +def _compile(tmp_path: Path, workdir: Path) -> None: + src = tmp_path / "malicious.rs" + src.write_text(_MALICIOUS_RS) + # Compiled UNSANDBOXED (we're testing runtime confinement, not the build here). + subprocess.run( + ["rustc", "-O", str(src), "-o", str(workdir / "malicious")], + check=True, + capture_output=True, + ) + + +@pytest.fixture +def scenario(tmp_path, monkeypatch): + workdir = tmp_path / "work" + workdir.mkdir() + _compile(tmp_path, workdir) + outside = tmp_path / "host_secret.txt" # OUTSIDE the granted workdir + outside.write_text(_HOSTFILE_CANARY) + # Plant the secret in *this* process's env; run-confined must scrub it, and the + # /proc//environ read (ppid = this pytest process) must be denied. + monkeypatch.setenv("ANTHROPIC_API_KEY", _ENV_CANARY) + return workdir, outside + + +@_needs +async def test_all_escapes_denied(scenario): + workdir, outside = scenario + policy = rust_build_policy(workdir) # grants workdir + toolchains; NOT /proc, NOT `outside` + res = await run_local_command( + "./malicious", [str(outside), str(os.getpid())], {}, + workdir=workdir, provider=_PROVIDER, policy=policy, + ) + assert res.exit_code == 0, res.stderr + + def probe(name: str) -> str: + return (workdir / f"probe_{name}.txt").read_text().strip() + + # every vector denied — and specifically no canary leaked + assert probe("env") == "denied" + assert probe("procenv") == "denied" + assert probe("hostfile") == "denied" + assert probe("net_ext") == "denied" + assert probe("imds") == "denied" + for name in ("env", "procenv", "hostfile", "net_ext", "imds"): + assert "LEAK" not in probe(name) + + +@_needs +async def test_control_unconfined_would_leak(scenario): + """Without the sandbox the same binary reads the secret env + the host file — + confirming the assertions above are enforced by the sandbox, not by accident.""" + workdir, outside = scenario + res = await run_local_command( + "./malicious", [str(outside), str(os.getpid())], {}, + workdir=workdir, # provider=None → unconfined passthrough + ) + assert res.exit_code == 0, res.stderr + assert (workdir / "probe_env.txt").read_text().strip() == f"LEAK:{_ENV_CANARY}" + assert _HOSTFILE_CANARY in (workdir / "probe_hostfile.txt").read_text() diff --git a/tests/test_sandbox_launcher.py b/tests/test_sandbox_launcher.py new file mode 100644 index 00000000..a1e36418 --- /dev/null +++ b/tests/test_sandbox_launcher.py @@ -0,0 +1,106 @@ +"""Tests for the ``run-confined`` launcher provider (Phase 6 step 2). + +The ``wrap`` tests are pure argv construction (no binary, no subprocess) and pin +the exact flag mapping. The ``available`` / ``--probe`` tests exercise the real +binary when it has been built (``cargo build -p run-confined --release``) and skip +otherwise, so the suite stays green on a machine without the Rust build. +""" + +from pathlib import Path + +import pytest + +from composer.sandbox.launcher import LauncherProvider, _resolve_binary +from composer.sandbox.policy import Availability, LaunchSpec, SandboxPolicy, get_provider + +_FAKE_BIN = "/opt/run-confined" + + +def _provider() -> LauncherProvider: + return LauncherProvider(binary=_FAKE_BIN) + + +def test_wrap_minimal_policy(): + """A workdir-only policy maps to the workdir grant + the command after `--`.""" + policy = SandboxPolicy(rw_paths=(Path("/work"),)) + spec = _provider().wrap(policy, "cargo", ["build", "--offline"]) + assert spec == LaunchSpec( + argv=(_FAKE_BIN, "--rw", "/work", "--", "cargo", "build", "--offline"), + env=None, + ) + + +def test_wrap_full_policy_flag_order(): + """ro before rw, then env, network, then rlimits, then `-- program args`.""" + policy = SandboxPolicy( + rw_paths=(Path("/work"), Path("/dev")), + ro_paths=(Path("/usr"), Path("/lib")), + env_allowlist={"PATH": "/usr/bin", "HOME": "/work"}, + network=False, + mem_bytes=4 << 30, + cpu_seconds=900, + nproc=512, + fsize_bytes=1 << 30, + ) + spec = _provider().wrap(policy, "crucible", ["run", "vault", "c_deposit"]) + assert spec.argv == ( + _FAKE_BIN, + "--ro", "/usr", + "--ro", "/lib", + "--rw", "/work", + "--rw", "/dev", + "--allow-env", "PATH=/usr/bin", + "--allow-env", "HOME=/work", + "--rlimit-as", str(4 << 30), + "--rlimit-cpu", "900", + "--rlimit-nproc", "512", + "--rlimit-fsize", str(1 << 30), + "--", "crucible", "run", "vault", "c_deposit", + ) + assert spec.env is None + + +def test_wrap_network_flag(): + policy = SandboxPolicy(rw_paths=(Path("/work"),), network=True) + spec = _provider().wrap(policy, "echo", []) + assert "--allow-network" in spec.argv + # no rlimit flags when caps are unset + assert not any(a.startswith("--rlimit") for a in spec.argv) + + +def test_wrap_uses_binary_name_when_unresolved(): + """With no binary resolved, argv[0] falls back to the bare name (kept runnable + if it is later placed on PATH); wrap never crashes on a missing binary.""" + prov = LauncherProvider(binary=None) + prov._binary = None # force the unresolved case regardless of the dev tree + spec = prov.wrap(SandboxPolicy(rw_paths=(Path("/w"),)), "true", []) + assert spec.argv[0] == "run-confined" + + +def test_available_reports_missing_binary(): + prov = LauncherProvider(binary=None) + prov._binary = None + avail = prov.available() + assert avail.ok is False + assert "run-confined" in avail.reason + + +def test_launcher_registered_in_seam(): + """Importing this module registered the provider under its name.""" + prov = get_provider("launcher") + assert isinstance(prov, LauncherProvider) + assert prov.name == "launcher" + + +# --- tests that need the actual built binary (skip if unbuilt) --- + +_REAL_BIN = _resolve_binary() +_needs_bin = pytest.mark.skipif(_REAL_BIN is None, reason="run-confined not built") + + +@_needs_bin +def test_probe_reports_available_on_this_host(): + """On a Landlock-capable host the real binary's --probe → available().""" + avail = LauncherProvider().available() + assert isinstance(avail, Availability) + assert avail.ok is True, avail.reason diff --git a/tests/test_sandbox_run_confined.py b/tests/test_sandbox_run_confined.py new file mode 100644 index 00000000..f9d1ba05 --- /dev/null +++ b/tests/test_sandbox_run_confined.py @@ -0,0 +1,105 @@ +"""Integration test: `run_local_command` actually confines via the launcher provider. + +This proves the *wiring* (step 3) end-to-end — the runner routes a command through +a real `SandboxProvider` and the confinement takes effect — as opposed to the pure +argv/golden tests elsewhere. Skipped unless the `run-confined` binary is built and +the kernel supports Landlock (so CI without the Rust build stays green); the full +escape gate on the real Crucible build is step 5. +""" + +import os +from pathlib import Path + +import pytest + +from composer.sandbox.command import run_local_command +from composer.sandbox.launcher import LauncherProvider +from composer.sandbox.policy import SandboxPolicy, SandboxUnavailable + +pytestmark = pytest.mark.asyncio + +_PROVIDER = LauncherProvider() +_needs_sandbox = pytest.mark.skipif( + not _PROVIDER.available().ok, reason="run-confined unbuilt or kernel lacks Landlock" +) + + +def _system_policy(workdir: Path) -> SandboxPolicy: + """A minimal policy: workdir + the dev nodes rw, the system dirs ro. Deliberately + does NOT grant /etc, so reading a host file outside the workdir is denied.""" + ro = tuple(p for p in (Path("/usr"), Path("/lib"), Path("/lib64"), Path("/bin")) if p.exists()) + rw = (workdir, *(Path(d) for d in ("/dev/null", "/dev/urandom") if Path(d).exists())) + return SandboxPolicy(rw_paths=rw, ro_paths=ro, env_allowlist={"PATH": os.environ.get("PATH", "/usr/bin:/bin")}) + + +@_needs_sandbox +async def test_confined_command_can_write_workdir(tmp_path): + res = await run_local_command( + "bash", ["-c", "echo hi > w.txt"], {}, workdir=tmp_path, + provider=_PROVIDER, policy=_system_policy(tmp_path), + ) + assert res.exit_code == 0, res.stderr + assert (tmp_path / "w.txt").read_text().strip() == "hi" + + +@_needs_sandbox +async def test_confined_command_cannot_read_outside_workdir(tmp_path): + outside = tmp_path.parent / f"secret-{tmp_path.name}.txt" + outside.write_text("TOPSECRET") + try: + res = await run_local_command( + "bash", ["-c", f"cat {outside} && echo LEAK || echo denied"], {}, workdir=tmp_path, + provider=_PROVIDER, policy=_system_policy(tmp_path), + ) + finally: + outside.unlink(missing_ok=True) + assert "TOPSECRET" not in res.stdout + assert "LEAK" not in res.stdout + assert "denied" in res.stdout + + +@_needs_sandbox +async def test_confined_command_has_no_network(tmp_path): + res = await run_local_command( + "python3", + ["-c", "import socket; socket.socket(socket.AF_INET, socket.SOCK_STREAM); print('LEAK')"], + {}, workdir=tmp_path, provider=_PROVIDER, policy=_system_policy(tmp_path), + ) + assert res.exit_code != 0 + assert "LEAK" not in res.stdout + + +@_needs_sandbox +async def test_none_provider_is_not_confined(tmp_path): + """Control: without a provider the same outside-read succeeds — proving it is the + sandbox, not something else, doing the blocking above.""" + outside = tmp_path.parent / f"plain-{tmp_path.name}.txt" + outside.write_text("readable") + try: + res = await run_local_command( + "bash", ["-c", f"cat {outside}"], {}, workdir=tmp_path, # provider=None (passthrough) + ) + finally: + outside.unlink(missing_ok=True) + assert res.exit_code == 0 + assert "readable" in res.stdout + + +async def test_unavailable_provider_fails_closed(tmp_path): + """A provider that reports unavailable must raise, never run unconfined.""" + + class _Unavailable: + name = "x" + + def available(self): + from composer.sandbox.policy import Availability + + return Availability(ok=False, reason="nope") + + def wrap(self, policy, program, args): # pragma: no cover - must not be called + raise AssertionError("wrap reached despite unavailable") + + with pytest.raises(SandboxUnavailable): + await run_local_command( + "true", [], {}, workdir=tmp_path, provider=_Unavailable(), policy=SandboxPolicy() + ) From ab320154da69f9d2938848555c22fe2946f8c43a Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Tue, 14 Jul 2026 16:02:18 -0700 Subject: [PATCH 2/3] run-confined: probe Landlock via the crate's public API 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) --- rust/run-confined/src/main.rs | 45 ++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/rust/run-confined/src/main.rs b/rust/run-confined/src/main.rs index bb296137..b4a169e4 100644 --- a/rust/run-confined/src/main.rs +++ b/rust/run-confined/src/main.rs @@ -40,10 +40,6 @@ const EXIT_SANDBOX_UNAVAILABLE: i32 = 3; /// The confined `execve` itself failed (e.g. program not found on PATH). const EXIT_EXEC_FAILED: i32 = 127; -/// `landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)` returns the -/// kernel's supported ABI version, or fails if Landlock is unavailable. -const LANDLOCK_CREATE_RULESET_VERSION: libc::c_ulong = 1; - #[derive(Default)] struct Config { rw_paths: Vec, @@ -90,26 +86,31 @@ fn main() { die(EXIT_EXEC_FAILED, &format!("exec {:?} failed: {err}", cfg.program)); } -/// `--probe`: report whether the kernel supports Landlock. Exit 0 + print the ABI -/// if so; exit `EXIT_SANDBOX_UNAVAILABLE` otherwise. Drives Python's fail-closed -/// `available()` check without restricting this (throwaway) process. +/// `--probe`: report whether the kernel supports Landlock. Exit 0 + print the +/// enforcement status if so; exit `EXIT_SANDBOX_UNAVAILABLE` otherwise. Drives +/// Python's fail-closed `available()` check. +/// +/// We probe through the crate's public API rather than the raw +/// `landlock_create_ruleset` syscall — the crate deliberately hides the numeric +/// ABI, and this reuses the exact BestEffort negotiation `apply_landlock` does. +/// It restricts *this* process as a side effect, which is harmless: `--probe` is +/// a throwaway process that exits immediately after reporting. fn probe() -> ! { - let abi = unsafe { - libc::syscall( - libc::SYS_landlock_create_ruleset, - std::ptr::null::(), - 0usize, - LANDLOCK_CREATE_RULESET_VERSION, - ) - }; - if abi > 0 { - println!("landlock abi {abi}"); - std::process::exit(0); + let status = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(AccessFs::from_all(ABI::V5)) + .and_then(|r| r.create()) + .and_then(|r| r.restrict_self()); + match status { + Ok(s) if !matches!(s.ruleset, RulesetStatus::NotEnforced) => { + println!("landlock {:?}", s.ruleset); + std::process::exit(0); + } + _ => die( + EXIT_SANDBOX_UNAVAILABLE, + "kernel does not support Landlock (need Linux >= 5.13); refusing to run unconfined", + ), } - die( - EXIT_SANDBOX_UNAVAILABLE, - "kernel does not support Landlock (need Linux >= 5.13); refusing to run unconfined", - ); } fn parse(argv: &[String]) -> Result { From d79d216c7a9eb630b8dcda7904c31520818adc4b Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Tue, 14 Jul 2026 16:12:20 -0700 Subject: [PATCH 3/3] docs/command-sandbox: correct what --probe reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/command-sandbox.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/command-sandbox.md b/docs/command-sandbox.md index 7376a38a..c33d8a1c 100644 --- a/docs/command-sandbox.md +++ b/docs/command-sandbox.md @@ -337,7 +337,7 @@ are unchanged). `RealEffects` builds the policy from a host-resolved config (too discovered like `resolve_crucible_repo` already does), and `build_program` uses the same. **Fail-closed.** Before running under a real sandbox provider, `provider.available()` is checked -(for the launcher: kernel Landlock ABI present). If it isn't — or the provider cannot apply its +(for the launcher: Landlock is present *and* actually enforcing). If it isn't — or the provider cannot apply its confinement — the command **refuses to run** rather than silently executing unconfined. The failure is a **prominent, actionable message** naming the reason ("the command sandbox requires a Landlock-capable kernel (Linux ≥5.13); this backend cannot run without it — see @@ -378,8 +378,9 @@ the sandbox is unavailable: refuse to run, loudly, rather than run untrusted nat Landlock ruleset (best-effort ABI negotiation, full FS bit set, deny-by-default + §3 grants) via the [`landlock`](https://crates.io/crates/landlock) crate, builds the seccomp filter (deny inet sockets + ptrace/process_vm_*) via [`seccompiler`](https://crates.io/crates/seccompiler), applies - both, then `execve`s the command with an env scrubbed to the allowlist. `--probe` reports the - kernel Landlock ABI and drives `available()` → fail-closed (§7). Enforcement smoke-tested on the + both, then `execve`s the command with an env scrubbed to the allowlist. `--probe` builds a + best-effort ruleset and reports whether Landlock actually *enforces* (not the numeric ABI, which + the crate hides), driving `available()` → fail-closed (§7). Enforcement smoke-tested on the host (write-outside / `/etc/passwd` / `/proc//environ` / inet-socket all denied; workdir write, AF_UNIX, and toolchain `exec` allowed); argv mapping golden-tested. Full escape gate is step 5.