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..c33d8a1c --- /dev/null +++ b/docs/command-sandbox.md @@ -0,0 +1,512 @@ +# 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: 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 +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` 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. +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..b4a169e4 --- /dev/null +++ b/rust/run-confined/src/main.rs @@ -0,0 +1,289 @@ +//! `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; + +#[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 +/// 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 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", + ), + } +} + +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() + )