From 74ed6620328cf619f7db44d6d281efa41a9dd44a Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Tue, 14 Jul 2026 15:45:45 -0700 Subject: [PATCH 01/12] =?UTF-8?q?sandbox:=20command=20sandbox=20infrastruc?= =?UTF-8?q?ture=20(Python=20+=20Rust)=20=E2=80=94=20PR=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained command-sandbox mechanism, extracted from eric/crucible as the first of the 4 stacked PRs (docs/pr-split-plan.md). Both sides ship together so a parallel Python project can adopt it immediately: from composer.sandbox import run_local_command, rust_build_policy, ... Python (composer/sandbox/, stdlib-only, no composer deps outside itself): - policy.py tool-agnostic seam: SandboxPolicy / SandboxProvider / LaunchSpec, the `none` passthrough, the provider registry, fail-closed helpers - command.py run_local_command — the RunCommand primitive (materialize input files into a workdir, run a command there); the LLM controls only file contents, never the command line - launcher.py the `launcher` provider: maps a policy to a run-confined invocation ($RUN_CONFINED_BIN -> PATH -> repo build dir) - recipes.py rust_build_policy + DEFAULT_ENV_PASSTHROUGH (the shared "compile/run Rust" confinement recipe, usable by Rust and future Python backends) - config.py SandboxConfig Rust (rust/run-confined): the trusted launcher — Landlock filesystem + seccomp network/ptrace + rlimits + scrubbed env, then execve. Workspace root trimmed to members = ["run-confined"] (the pyo3 framework crates rejoin in later PRs); Cargo.lock regenerated for that subset. Packaging: scripts/Dockerfile builds run-confined into the image on PATH; .dockerignore + .gitignore exclude the Rust build output. Docs: docs/command-sandbox.md. Gate: tests/test_sandbox{,_command,_config,_launcher,_run_confined,_escape}.py — 42 passed (escape gate: all vectors denied + unconfined control; run-confined confinement exercised, not skipped). Co-Authored-By: Claude Opus 4.8 --- .dockerignore | 4 + .gitignore | 4 + composer/sandbox/__init__.py | 62 ++++ composer/sandbox/command.py | 156 +++++++++ composer/sandbox/config.py | 98 ++++++ composer/sandbox/launcher.py | 114 +++++++ composer/sandbox/policy.py | 165 ++++++++++ composer/sandbox/recipes.py | 158 +++++++++ docs/command-sandbox.md | 511 +++++++++++++++++++++++++++++ rust/Cargo.lock | 113 +++++++ rust/Cargo.toml | 21 ++ rust/run-confined/Cargo.toml | 19 ++ rust/run-confined/src/main.rs | 288 ++++++++++++++++ scripts/Dockerfile | 16 + tests/test_sandbox.py | 127 +++++++ tests/test_sandbox_command.py | 64 ++++ tests/test_sandbox_config.py | 102 ++++++ tests/test_sandbox_escape.py | 143 ++++++++ tests/test_sandbox_launcher.py | 106 ++++++ tests/test_sandbox_run_confined.py | 105 ++++++ 20 files changed, 2376 insertions(+) create mode 100644 composer/sandbox/__init__.py create mode 100644 composer/sandbox/command.py create mode 100644 composer/sandbox/config.py create mode 100644 composer/sandbox/launcher.py create mode 100644 composer/sandbox/policy.py create mode 100644 composer/sandbox/recipes.py create mode 100644 docs/command-sandbox.md create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/run-confined/Cargo.toml create mode 100644 rust/run-confined/src/main.rs create mode 100644 tests/test_sandbox.py create mode 100644 tests/test_sandbox_command.py create mode 100644 tests/test_sandbox_config.py create mode 100644 tests/test_sandbox_escape.py create mode 100644 tests/test_sandbox_launcher.py create mode 100644 tests/test_sandbox_run_confined.py diff --git a/.dockerignore b/.dockerignore index 884b27b4..7ffd6643 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,3 +28,7 @@ build/ dist/ .idea/ .vscode/ + +# Cargo build output — the image's sandbox-builder stage recompiles run-confined; +# copying host target/ (large) would only bloat the build context. +**/target/ diff --git a/.gitignore b/.gitignore index 6c0a11c8..4f415c38 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ local/ test_scenarios/**/certora/ test_scenarios/**/out/ test_scenarios/**/lib/forge-std + +# Rust build output (the run-confined command-sandbox workspace). Matches the +# **/target/ pattern the .dockerignore uses. +**/target/ diff --git a/composer/sandbox/__init__.py b/composer/sandbox/__init__.py new file mode 100644 index 00000000..ff1decda --- /dev/null +++ b/composer/sandbox/__init__.py @@ -0,0 +1,62 @@ +"""Run local commands, optionally confined to an unprivileged in-kernel sandbox. + +A backend-agnostic home for the ``RunCommand`` execution primitive +(:func:`run_local_command`) and the swappable sandbox seam +(``docs/command-sandbox.md``). It lives outside ``rustapp`` so **any** backend — +Rust-IoC *or* Python — can run untrusted native code (a compiler, a fuzzer) +confined: no network, no inherited secrets, only its own inputs on disk. + +- :mod:`composer.sandbox.policy` — the tool-agnostic seam: :class:`SandboxPolicy` + (confinement intent), :class:`SandboxProvider` (maps policy+command → a + :class:`LaunchSpec`), the ``none`` passthrough, the provider registry, and the + fail-closed helpers. +- :mod:`composer.sandbox.launcher` — the ``run-confined`` launcher provider + (Landlock + seccomp). Import it to register the ``"launcher"`` provider; the + *seam* deliberately never imports a concrete mechanism. +- :mod:`composer.sandbox.command` — :func:`run_local_command`, the single choke + point that materializes files into a workdir and runs a command there. +""" + +from composer.sandbox.command import ( + DEFAULT_TIMEOUT_S, + NOT_FOUND_EXIT, + CommandResult, + UnsafePath, + run_local_command, +) +from composer.sandbox.config import SandboxConfig +from composer.sandbox.policy import ( + Availability, + LaunchSpec, + NoneProvider, + SandboxPolicy, + SandboxProvider, + SandboxUnavailable, + ensure_available, + get_provider, + register_provider, +) +from composer.sandbox.recipes import DEFAULT_ENV_PASSTHROUGH, rust_build_policy + +__all__ = [ + # command runner + "run_local_command", + "CommandResult", + "UnsafePath", + "DEFAULT_TIMEOUT_S", + "NOT_FOUND_EXIT", + # sandbox seam + "SandboxPolicy", + "SandboxProvider", + "LaunchSpec", + "Availability", + "NoneProvider", + "SandboxUnavailable", + "get_provider", + "register_provider", + "ensure_available", + # config + recipes + "SandboxConfig", + "rust_build_policy", + "DEFAULT_ENV_PASSTHROUGH", +] diff --git a/composer/sandbox/command.py b/composer/sandbox/command.py new file mode 100644 index 00000000..d3720965 --- /dev/null +++ b/composer/sandbox/command.py @@ -0,0 +1,156 @@ +"""The local-command runner behind the ``RunCommand`` effect. + +A single choke point: materialize a set of files into a workdir, run a command +over them (as a child process, **never** a shell), and capture the result. The +trusted Python build steps (the Solana sBPF build / IDL step) route through here. + +(A Rust backend's own ``compile``/``validate`` toolchain runs no longer go through +this: they spawn the ``run-confined`` launcher directly from the wheel via +``autoprover_sdk::run_confined`` — see ``docs/rust-backend-api.md``. This runner and the +launcher share the same :mod:`composer.sandbox.policy` seam, which is why it lives in +:mod:`composer.sandbox` rather than under ``rustapp``.) + +Optional confinement is applied via a :class:`~composer.sandbox.policy.SandboxProvider` ++ :class:`~composer.sandbox.policy.SandboxPolicy` (``docs/command-sandbox.md``): +``None`` / the ``none`` provider is a passthrough; the ``launcher`` provider wraps +the argv in ``run-confined`` (Landlock + seccomp) and is fail-closed. + +**Trust boundary** (``docs/command-sandbox.md`` §2): the *caller* — a trusted Rust +decider or a trusted Python build step — supplies ``program`` and ``args``; only +file *contents* may derive from LLM output. We enforce path confinement here +(no absolute paths, no ``..`` traversal) in addition to whatever the provider does. +""" + +import asyncio +import logging +import os +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + +from composer.sandbox.policy import ( + NoneProvider, + SandboxPolicy, + SandboxProvider, + ensure_available, +) + +_log = logging.getLogger(__name__) + +# Generous default; individual callers (a fuzz run vs a quick dry-run) pass their own. +DEFAULT_TIMEOUT_S = 600 + +# Exit code we synthesize when the binary isn't on PATH (mirrors shells' 127). +NOT_FOUND_EXIT = 127 + + +class UnsafePath(ValueError): + """A requested file path is absolute or escapes the workdir.""" + + +@dataclass(frozen=True) +class CommandResult: + exit_code: int + stdout: str + stderr: str + + def as_observation(self) -> dict: + """The ``Observation::CommandResult`` payload the IoC loop feeds back to Rust.""" + return { + "exit_code": self.exit_code, + "stdout": self.stdout, + "stderr": self.stderr, + } + + +def _confined_target(workdir: Path, rel: str) -> Path: + """Resolve ``rel`` under ``workdir``, rejecting absolute paths / ``..`` escapes.""" + p = PurePosixPath(rel) + if p.is_absolute() or ".." in p.parts: + raise UnsafePath( + f"file path {rel!r} is absolute or traverses outside the workdir" + ) + target = workdir / p + # Belt-and-suspenders: the resolved path must still live under the workdir. + try: + target.resolve().relative_to(workdir.resolve()) + except ValueError as e: + raise UnsafePath(f"file path {rel!r} resolves outside the workdir") from e + return target + + +async def run_local_command( + program: str, + args: list[str], + files: dict[str, str], + *, + workdir: Path, + timeout_s: int = DEFAULT_TIMEOUT_S, + sem: asyncio.Semaphore | None = None, + provider: SandboxProvider | None = None, + policy: SandboxPolicy | None = None, + env_overlay: dict[str, str] | None = None, +) -> CommandResult: + """Write ``files`` into ``workdir``, then run ``program args`` there and capture output. + + ``workdir`` persists across calls (a session materializes its crate once and + runs several commands against it). Concurrency is bounded by ``sem`` when + given — important because fuzzers are resource-hungry. + + ``provider`` selects the sandbox mechanism (``docs/command-sandbox.md``); with + the default (``None`` → the ``none`` passthrough) the command runs exactly as + before. A real provider maps ``policy`` → a confined launch and is **fail-closed** + (raises :class:`~composer.sandbox.policy.SandboxUnavailable` if it can't confine, + rather than running unsandboxed). The untrusted ``files`` are materialized by + trusted Python (path-confined via ``_confined_target``) and then the command runs + — as one unit under ``sem`` when given, so that when callers share a workdir the + file-write and the run don't interleave (a concurrent caller can't overwrite these + files between our write and our run). Path confinement complements the sandbox. + + ``env_overlay`` sets extra env vars on the child on top of what it would otherwise + inherit — used by the *unsandboxed* prep steps (e.g. `cargo fetch` with a per-run + ``CARGO_HOME``); the sandboxed path's env is fully governed by the provider/policy. + """ + prov: SandboxProvider = provider if provider is not None else NoneProvider() + ensure_available(prov) # fail-closed: raises before running if it can't confine + spec = prov.wrap(policy if policy is not None else SandboxPolicy(), program, list(args)) + child_env = dict(spec.env) if spec.env is not None else None + if env_overlay: + # Overlay onto the effective env (the inherited parent env when the provider + # didn't set one — i.e. the `none`/unsandboxed path). + child_env = {**(child_env if child_env is not None else os.environ), **env_overlay} + + async def _run() -> CommandResult: + # Materialize files + launch as one unit so a shared workdir stays consistent + # for this command's duration (see `sem`). + workdir.mkdir(parents=True, exist_ok=True) + for rel, contents in files.items(): + target = _confined_target(workdir, rel) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(contents) + try: + proc = await asyncio.create_subprocess_exec( + *spec.argv, + cwd=str(workdir), + env=child_env, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + except FileNotFoundError: + return CommandResult(NOT_FOUND_EXIT, "", f"{spec.argv[0]}: not found on PATH") + try: + out_b, err_b = await asyncio.wait_for( + proc.communicate(), timeout=timeout_s + ) + except asyncio.TimeoutError: + proc.kill() + await proc.wait() + return CommandResult(-1, "", f"command timed out after {timeout_s}s") + rc = proc.returncode if proc.returncode is not None else -1 + return CommandResult( + rc, out_b.decode(errors="replace"), err_b.decode(errors="replace") + ) + + if sem is not None: + async with sem: + return await _run() + return await _run() diff --git a/composer/sandbox/config.py b/composer/sandbox/config.py new file mode 100644 index 00000000..8b9824a2 --- /dev/null +++ b/composer/sandbox/config.py @@ -0,0 +1,98 @@ +"""Runtime selection of the command-sandbox provider + policy. + +A backend constructs a :class:`SandboxConfig` (usually via :meth:`from_env`) and +hands it to the command path (``RealEffects`` / ``build_program``), which turns it +into a concrete ``(provider, policy)`` per command via :meth:`resolve_provider` and +:meth:`build_policy`. Keeping selection here — rather than in :func:`run_local_command` +— means the runner stays mechanism-agnostic (``docs/command-sandbox.md`` §4/§7). + +The library default provider is ``"none"`` (passthrough). Backends that run +untrusted native code (Crucible) construct a config with ``provider="launcher"`` +by default; override with ``COMPOSER_SANDBOX_PROVIDER=none`` for trusted-input dev. +""" + +import os +from dataclasses import dataclass +from pathlib import Path + +from composer.sandbox.policy import SandboxPolicy, SandboxProvider, ensure_available, get_provider +from composer.sandbox.recipes import DEFAULT_ENV_PASSTHROUGH, rust_build_policy + +_ENV_VAR = "COMPOSER_SANDBOX_PROVIDER" + + +@dataclass(frozen=True) +class SandboxConfig: + """Which provider to use + the inputs for building its policy.""" + + provider: str = "none" + extra_ro: tuple[Path, ...] = () + extra_rw: tuple[Path, ...] = () + env_passthrough: tuple[str, ...] = DEFAULT_ENV_PASSTHROUGH + offline: bool = True # sandbox has no network → force cargo offline (§5) + mem_bytes: int | None = None + cpu_seconds: int | None = None + nproc: int | None = None + fsize_bytes: int | None = None + + @classmethod + def from_env(cls, **overrides) -> "SandboxConfig": + """Read the provider from ``$COMPOSER_SANDBOX_PROVIDER`` (default ``none``); + remaining fields come from ``overrides`` (e.g. a backend's ``extra_ro``).""" + return cls(provider=os.environ.get(_ENV_VAR, "none"), **overrides) + + @property + def enabled(self) -> bool: + return self.provider != "none" + + def resolve_provider(self) -> SandboxProvider: + # Importing the launcher module registers the "launcher" provider; the seam + # itself never imports a concrete mechanism (docs/command-sandbox.md §6). + if self.provider == "launcher": + import composer.sandbox.launcher # noqa: F401 + return get_provider(self.provider) + + def build_policy(self, workdir: str | Path) -> SandboxPolicy: + """The concrete policy for a command running in ``workdir``. The ``none`` + provider ignores the policy, so a bare :class:`SandboxPolicy` suffices there.""" + if not self.enabled: + return SandboxPolicy() + return rust_build_policy( + workdir, + extra_ro=self.extra_ro, + extra_rw=self.extra_rw, + env_passthrough=self.env_passthrough, + offline=self.offline, + mem_bytes=self.mem_bytes, + cpu_seconds=self.cpu_seconds, + nproc=self.nproc, + fsize_bytes=self.fsize_bytes, + ) + + def backend_spec(self, workdir: str | Path, *, timeout_s: int) -> dict: + """The ``Sandbox`` JSON a Rust backend's ``compile``/``validate`` consume to build + their own ``run-confined`` launch (`autoprover_sdk::Sandbox`). Python keeps ownership + of the confinement *intent* (this policy); the backend only assembles it into an argv. + + For a real provider this resolves the ``run-confined`` path and is **fail-closed** + (``ensure_available`` raises if the launcher can't confine here). The ``none`` provider + yields ``run_confined=None`` — the backend runs the command directly (trusted input).""" + if not self.enabled: + return {"run_confined": None, "timeout_s": timeout_s} + provider = self.resolve_provider() + ensure_available(provider) # fail-closed: raise before any untrusted code runs + policy = self.build_policy(workdir) + return { + "run_confined": getattr(provider, "binary", None), + "ro": [str(p) for p in policy.ro_paths], + "rw": [str(p) for p in policy.rw_paths], + "allow_env": [f"{k}={v}" for k, v in policy.env_allowlist.items()], + "network": policy.network, + "rlimits": { + "mem_bytes": policy.mem_bytes, + "cpu_seconds": policy.cpu_seconds, + "nproc": policy.nproc, + "fsize_bytes": policy.fsize_bytes, + }, + "timeout_s": timeout_s, + } diff --git a/composer/sandbox/launcher.py b/composer/sandbox/launcher.py new file mode 100644 index 00000000..0aff4bce --- /dev/null +++ b/composer/sandbox/launcher.py @@ -0,0 +1,114 @@ +"""The ``run-confined`` launcher provider — the first real :class:`SandboxProvider`. + +Maps a tool-agnostic :class:`SandboxPolicy` to an invocation of the ``run-confined`` +trusted Rust binary (``rust/run-confined``), which applies Landlock + seccomp + +rlimits + a scrubbed env to itself and then ``execve``s the command +(``docs/command-sandbox.md`` §6). This module is deliberately *separate* from the +:mod:`composer.sandbox.policy` seam: importing it registers the ``"launcher"`` +provider, so the seam never imports a concrete mechanism. + +``wrap`` is pure argv construction (unit-testable, no subprocess); ``available`` +shells out to ``run-confined --probe`` once to confirm the kernel supports Landlock +(fail-closed otherwise). +""" + +import os +import shutil +import subprocess +from pathlib import Path + +from composer.sandbox.policy import ( + Availability, + LaunchSpec, + SandboxPolicy, + register_provider, +) + +_BIN_NAME = "run-confined" +_PROBE_TIMEOUT_S = 10 + + +def _resolve_binary() -> str | None: + """Locate the ``run-confined`` binary: ``$RUN_CONFINED_BIN`` → ``PATH`` → the + dev build under ``rust/target/release`` (repo-relative). ``None`` if unbuilt.""" + override = os.environ.get("RUN_CONFINED_BIN") + if override and Path(override).is_file(): + return override + on_path = shutil.which(_BIN_NAME) + if on_path: + return on_path + # Dev fallback: composer/sandbox/launcher.py → repo root is parents[2]. + repo_root = Path(__file__).resolve().parents[2] + cand = repo_root / "rust" / "target" / "release" / _BIN_NAME + return str(cand) if cand.is_file() else None + + +class LauncherProvider: + """Confines commands via the ``run-confined`` launcher (Landlock + seccomp).""" + + name = "launcher" + + def __init__(self, binary: str | None = None): + # Resolve at construction so `available()` and `wrap()` agree on the path; + # tests pass an explicit binary to keep `wrap()` golden-testable offline. + self._binary = binary if binary is not None else _resolve_binary() + + @property + def binary(self) -> str | None: + return self._binary + + def available(self) -> Availability: + if self._binary is None: + return Availability( + ok=False, + reason=( + f"{_BIN_NAME} binary not found; build rust/run-confined " + f"(cargo build -p run-confined --release) or set RUN_CONFINED_BIN" + ), + ) + try: + proc = subprocess.run( + [self._binary, "--probe"], + capture_output=True, + text=True, + timeout=_PROBE_TIMEOUT_S, + ) + except OSError as e: + return Availability(ok=False, reason=f"{_BIN_NAME} --probe could not run: {e}") + if proc.returncode != 0: + reason = proc.stderr.strip() or f"{_BIN_NAME} --probe reported no Landlock support" + return Availability(ok=False, reason=reason) + return Availability(ok=True) + + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: + """Build the ``run-confined … -- program args`` argv from ``policy``. + + Emits ``--allow-env NAME=VALUE`` (explicit values — the allowlist holds only + benign build vars, never secrets). ``env`` stays ``None``: the launcher + inherits AutoProver's env but scrubs it to the allowlist for the child, so + the child's environment is fully determined by the flags.""" + argv: list[str] = [self._binary or _BIN_NAME] + for p in policy.ro_paths: + argv += ["--ro", str(p)] + for p in policy.rw_paths: + argv += ["--rw", str(p)] + for name, value in policy.env_allowlist.items(): + argv += ["--allow-env", f"{name}={value}"] + if policy.network: + argv.append("--allow-network") + if policy.mem_bytes is not None: + argv += ["--rlimit-as", str(policy.mem_bytes)] + if policy.cpu_seconds is not None: + argv += ["--rlimit-cpu", str(policy.cpu_seconds)] + if policy.nproc is not None: + argv += ["--rlimit-nproc", str(policy.nproc)] + if policy.fsize_bytes is not None: + argv += ["--rlimit-fsize", str(policy.fsize_bytes)] + argv += ["--", program, *args] + return LaunchSpec(argv=tuple(argv), env=None) + + +# Registering on import keeps the `composer.sandbox.policy` seam free of any +# concrete-mechanism import. Consumers (RealEffects, tests) `import` this module to +# make ``get_provider("launcher")`` resolvable. +register_provider("launcher", LauncherProvider) diff --git a/composer/sandbox/policy.py b/composer/sandbox/policy.py new file mode 100644 index 00000000..67c12f87 --- /dev/null +++ b/composer/sandbox/policy.py @@ -0,0 +1,165 @@ +"""The command-sandbox provider seam (``docs/command-sandbox.md`` §4, §7). + +Every ``RunCommand`` invocation compiles and/or runs *untrusted native code* (an +LLM-authored harness, a user program's ``build.rs``), so it must run confined — +no network, no inherited secrets, only its own inputs on the filesystem. This +module is the **tool-agnostic isolation layer** that makes that confinement +*swappable*: + +- :class:`SandboxPolicy` — the confinement *intent* (rw/ro paths, env allowlist, + network on/off, resource caps). It names **no mechanism**, so swapping the + sandbox tool never changes the policy. +- :class:`SandboxProvider` — maps a policy + a command to a concrete + :class:`LaunchSpec` (the argv/env to actually exec). The mechanism (a + Landlock+seccomp launcher, or an off-the-shelf tool like ``landrun`` / + ``sandlock``) lives entirely behind this protocol. + +Because :func:`composer.sandbox.command.run_local_command` will depend only on +this seam — never on a concrete tool — a provider can be swapped without touching +the command runner, ``RealEffects``, or the escape-test gate. It lives outside +``rustapp`` so Python-based backends can use it too, not just the Rust-IoC ones. + +This module ships the policy, the protocol, the ``none`` passthrough provider, +and the provider registry. The ``run-confined`` launcher provider (Landlock + +seccomp) registers itself under ``"launcher"`` when +:mod:`composer.sandbox.launcher` is imported (typically via +:meth:`composer.sandbox.config.SandboxConfig.resolve_provider`). + +**Trust boundary** (``docs/command-sandbox.md`` §7.2): the policy and the emitted +``LaunchSpec`` are authored by trusted Python — never the LLM, which controls only +file *contents*. +""" + +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from typing import Protocol, runtime_checkable + + +class SandboxUnavailable(RuntimeError): + """A sandbox provider was requested but cannot confine the command. + + Raised (fail-closed) instead of silently running unconfined — untrusted input + must never run without the sandbox (``docs/command-sandbox.md`` §7). Carries the + provider name + a human reason so the caller can surface a prominent message. + """ + + def __init__(self, provider: str, reason: str): + self.provider = provider + self.reason = reason + super().__init__(f"command sandbox provider {provider!r} is unavailable: {reason}") + + +@dataclass(frozen=True) +class SandboxPolicy: + """The confinement *intent* — tool-agnostic (``docs/command-sandbox.md`` §7). + + Every :class:`SandboxProvider` consumes *this* shape, so a mechanism swap needs + no policy change. ``program``/``args`` are passed per-call to + :meth:`SandboxProvider.wrap`, not stored here. Resource caps default to ``None`` + (unset); a provider maps them to its own limit mechanism (rlimits for the + launcher). + """ + + rw_paths: tuple[Path, ...] = () # writable: the workdir (+ any scratch) + ro_paths: tuple[Path, ...] = () # read+exec: toolchains, crucible checkout, /usr… + env_allowlist: Mapping[str, str] = field(default_factory=dict) + network: bool = False # egress allowed? default off + mem_bytes: int | None = None # RLIMIT_AS + cpu_seconds: int | None = None # RLIMIT_CPU + nproc: int | None = None # RLIMIT_NPROC + fsize_bytes: int | None = None # RLIMIT_FSIZE + + +@dataclass(frozen=True) +class LaunchSpec: + """How :func:`run_local_command` should actually launch the (confined) command. + + ``argv`` is the full argument vector to exec; ``env`` is the environment to pass + (``None`` = inherit the parent's, i.e. today's unconfined behavior). Both are + authored by trusted code, never the LLM. + """ + + argv: tuple[str, ...] + env: Mapping[str, str] | None = None + + +@dataclass(frozen=True) +class Availability: + """Result of :meth:`SandboxProvider.available` — whether the provider can + actually confine here (e.g. the launcher probes the kernel's Landlock ABI).""" + + ok: bool + reason: str = "" + + +@runtime_checkable +class SandboxProvider(Protocol): + """Maps a :class:`SandboxPolicy` + a command to a concrete :class:`LaunchSpec`. + + The one seam every sandbox mechanism implements. Implementations are pure with + respect to :meth:`wrap` (argv construction only — no subprocess), so they are + trivially unit-testable; the actual confinement happens in the launched process. + """ + + name: str + + def available(self) -> Availability: + """Whether this provider can confine a command in the current environment.""" + ... + + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: + """Translate ``policy`` into how to launch ``program args`` confined.""" + ... + + +class NoneProvider: + """Passthrough — **no confinement**. Exec the command directly, inheriting the + environment: byte-for-byte today's behavior. + + An *explicit, logged* choice for the trusted EVM/Foundry callers and + trusted-input dev runs. It is never reached as a silent fallback from a failed + real sandbox (``docs/command-sandbox.md`` §7) — the caller selects it on purpose. + """ + + name = "none" + + def available(self) -> Availability: + return Availability(ok=True) + + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: + # Policy is intentionally ignored: this provider provides no isolation. + return LaunchSpec(argv=(program, *args), env=None) + + +# Provider registry. The ``launcher`` factory is registered by importing +# :mod:`composer.sandbox.launcher` (see :meth:`SandboxConfig.resolve_provider`). +_PROVIDERS: dict[str, Callable[[], SandboxProvider]] = { + "none": NoneProvider, +} + + +def register_provider(name: str, factory: Callable[[], SandboxProvider]) -> None: + """Register a provider factory under ``name`` (used by later steps to add the + launcher / off-the-shelf providers without this module importing them).""" + _PROVIDERS[name] = factory + + +def get_provider(name: str) -> SandboxProvider: + """Construct the provider registered under ``name``. Raises ``ValueError`` for an + unknown name (a config error, distinct from a provider being *unavailable*).""" + try: + factory = _PROVIDERS[name] + except KeyError: + raise ValueError( + f"unknown sandbox provider {name!r}; known: {sorted(_PROVIDERS)}" + ) from None + return factory() + + +def ensure_available(provider: SandboxProvider) -> None: + """Fail-closed check: raise :class:`SandboxUnavailable` unless ``provider`` can + confine here. Call before running untrusted input under a real provider.""" + avail = provider.available() + if not avail.ok: + raise SandboxUnavailable(provider.name, avail.reason) diff --git a/composer/sandbox/recipes.py b/composer/sandbox/recipes.py new file mode 100644 index 00000000..edb78701 --- /dev/null +++ b/composer/sandbox/recipes.py @@ -0,0 +1,158 @@ +"""Ready-made :class:`SandboxPolicy` recipes. + +The seam (:mod:`composer.sandbox.policy`) is mechanism- *and* workload-agnostic; +this module holds opinionated builders for common workloads. :func:`rust_build_policy` +covers "compile and/or run Rust" (``cargo build-sbf``, ``cargo build``, ``crucible +run``): it grants the workdir read-write, the discoverable Rust/Solana toolchains +read-only, the device nodes the toolchain needs, and an env allowlist — with the +network off. Any Rust backend reuses it; Crucible adds its own paths via ``extra_ro``. + +Paths are included only if they exist, so the same recipe works across machines +with different toolchain layouts (and the escape-test gate can prove exactly what +was and wasn't granted). +""" + +import os +import shutil +from pathlib import Path + +from composer.sandbox.policy import SandboxPolicy + +# Benign build vars passed through to the child (values read from the current env). +# Never secrets — the whole point is that secrets are *not* inherited. +DEFAULT_ENV_PASSTHROUGH: tuple[str, ...] = ( + "PATH", + "HOME", + "TERM", + "LANG", + "LC_ALL", + "USER", + "LOGNAME", + "TMPDIR", + "CARGO_HOME", + "RUSTUP_HOME", + "SSL_CERT_FILE", + "SSL_CERT_DIR", +) + +# Read-only system directories the toolchain + its dynamic linker need. ``/etc`` is +# included because glibc NSS (``getpwuid`` via ``getuser``, CA-cert lookup) reads +# ``/etc/passwd`` / ``/etc/nsswitch.conf``; it holds no AutoProver secret (those are +# in the scrubbed env and in files we never grant). The escape gate must therefore +# probe a *planted* host file / the parent's environ, not ``/etc/passwd``. +_SYSTEM_RO: tuple[str, ...] = ("/usr", "/bin", "/sbin", "/lib", "/lib64", "/etc") + +# Device nodes the toolchain opens (rw so ``/dev/null`` writes work). Granting the +# node files — not the whole ``/dev`` tree; ``mknod`` stays blocked (no capability). +_DEV_NODES: tuple[str, ...] = ( + "/dev/null", + "/dev/zero", + "/dev/full", + "/dev/random", + "/dev/urandom", + "/dev/tty", +) + + +def sandbox_cargo_home(workdir: str | Path) -> Path: + """The **private, per-run `CARGO_HOME`** for a sandboxed build, under the workdir. + + Why a private cargo home rather than the shared `~/.cargo`: + + An offline `cargo build` doesn't just *read* the cache — it *writes* to `CARGO_HOME` + (extracts crate sources into `registry/src`, takes `.package-cache` locks). To let + the confined build do that we'd have to grant `CARGO_HOME` read-write. But the same + build runs **untrusted `build.rs`/proc-macro code**, so a writable *shared* cargo + home is a cross-run attack surface: a malicious build could overwrite an extracted + source under `registry/src` and poison a *later* run that compiles that crate (cargo + checksums the downloaded `.crate`, but trusts an already-extracted `registry/src`). + + A per-run home under the (already-writable, per-run) workdir removes that: any write + the untrusted build makes touches only this run's throwaway cache, never a shared one. + The cost is that deps are fetched per run (the warm step downloads into this home); + a shared *read-only* index/cache to avoid re-download is a deferred optimization + (command-sandbox.md §11 item 5). + """ + return Path(workdir).resolve() / ".sandbox_cargo" + + +def rust_build_policy( + workdir: str | Path, + *, + extra_ro: tuple[Path, ...] = (), + extra_rw: tuple[Path, ...] = (), + env_passthrough: tuple[str, ...] = DEFAULT_ENV_PASSTHROUGH, + offline: bool = True, + mem_bytes: int | None = None, + cpu_seconds: int | None = None, + nproc: int | None = None, + fsize_bytes: int | None = None, +) -> SandboxPolicy: + """Build a network-off policy for compiling/running Rust in ``workdir``. + + Grants: ``workdir`` + the device nodes (+ ``extra_rw``) read-write; the Rust + (``RUSTUP_HOME``/``CARGO_HOME``) and Solana platform-tool directories, the system + dirs, and ``extra_ro`` read-only. Non-existent paths are dropped. + + With ``offline`` (the default — the sandbox has no network, §5), ``CARGO_NET_OFFLINE=1`` + is set in the child env. That one var forces *every* cargo invocation offline, + including the nested ``cargo`` that ``crucible run`` spawns to build the harness — + so the deps must already be warm in ``CARGO_HOME`` (see :func:`warm_cargo_cache`, + run *outside* the sandbox first). + """ + home = Path.home() + rustup = Path(os.environ.get("RUSTUP_HOME", home / ".rustup")) + cargo = Path(os.environ.get("CARGO_HOME", home / ".cargo")) + + ro_candidates: list[Path] = [Path(p) for p in _SYSTEM_RO] + ro_candidates += [ + rustup, + cargo, + # cargo-build-sbf's downloaded sBPF platform-tools (layout varies by version). + home / ".cache" / "solana", + home / ".local" / "share" / "solana", + ] + ro_candidates += list(extra_ro) + # Absolute paths only: the launcher opens each relative to *its* cwd (the workdir), + # so a relative grant would resolve wrong. resolve() also canonicalizes symlinks. + ro_paths = tuple(p.resolve() for p in ro_candidates if p.exists()) + + dev = tuple(Path(d).resolve() for d in _DEV_NODES if Path(d).exists()) + wd = Path(workdir).resolve() + rw_paths = (wd, *dev, *(p.resolve() for p in extra_rw)) + + env = {name: os.environ[name] for name in env_passthrough if name in os.environ} + if offline: + env["CARGO_NET_OFFLINE"] = "1" + # A private temp dir UNDER the (writable) workdir, so tools that need scratch space + # — notably the linker, which writes to $TMPDIR (default /tmp) during `cargo build` — + # work without granting the shared /tmp (which may hold host/other-run secrets and + # would defeat the escape test). Created here so $TMPDIR points at an existing dir. + sandbox_tmp = wd / ".sandbox_tmp" + sandbox_tmp.mkdir(parents=True, exist_ok=True) + for var in ("TMPDIR", "TMP", "TEMP"): + env[var] = str(sandbox_tmp) + + # Point CARGO_HOME at a PRIVATE per-run cargo home under the workdir (see + # sandbox_cargo_home for the reasoning). The shared ~/.cargo stays read-only (its + # `bin/cargo` is still on PATH; we only redirect where cargo *writes*). Copy the + # user's global cargo config in so registry mirrors / build settings still apply. + cargo_home = sandbox_cargo_home(wd) + cargo_home.mkdir(parents=True, exist_ok=True) + shared_cargo = Path(os.environ.get("CARGO_HOME", Path.home() / ".cargo")) + for cfg in ("config.toml", "config"): + src = shared_cargo / cfg + if src.is_file() and not (cargo_home / cfg).exists(): + shutil.copy(src, cargo_home / cfg) + env["CARGO_HOME"] = str(cargo_home) + + return SandboxPolicy( + rw_paths=rw_paths, + ro_paths=ro_paths, + env_allowlist=env, + network=False, + mem_bytes=mem_bytes, + cpu_seconds=cpu_seconds, + nproc=nproc, + fsize_bytes=fsize_bytes, + ) diff --git a/docs/command-sandbox.md b/docs/command-sandbox.md new file mode 100644 index 00000000..7376a38a --- /dev/null +++ b/docs/command-sandbox.md @@ -0,0 +1,511 @@ +# Design — Sandboxing the `RunCommand` effect (Phase 6) + +**Status:** implemented. Design + record for [crucible-application.md §7.4](./crucible-application.md#L436) +and [§9 Phase 6](./crucible-application.md#L634) — the *required*, definition-of-done phase. The +sandbox mechanism is built and validated (§9 steps 1–5 done, gate §10 green — incl. the full LLM +e2e passing under the launcher); Crucible runs confined by default. Open items are orthogonal to the +sandbox (§11): a shared-`Cargo.toml` feature race that lost one of three instructions, and per-run +`CARGO_HOME`/tightening follow-ups. + +**One-line summary.** Every command run through the `RunCommand` effect compiles and/or runs +LLM-authored *native* code (§7.2). Today that runs with the full ambient environment of the +AutoProver process. Phase 6 confines each such command — with no network, no inherited secrets, and +only its own inputs on the filesystem — using **unprivileged, in-process kernel sandboxing +(Landlock + seccomp)** that needs no container changes, no namespaces, no capabilities, and no +custom runtime. It is a single wrapper around [`run_local_command`](../composer/sandbox/command.py). +Done is proven by an escape test. + +--- + +## 1. Why this is required, not optional + +The outer AutoProver container protects the *host* from AutoProver. It does **not** protect +AutoProver's own secrets, network access, and filesystem from code running *inside* it. And the +`RunCommand` effect deliberately runs untrusted native code: + +- `cargo build-sbf` on the **user-supplied program** compiles it natively — running its + `build.rs`, its proc-macros, and (for a future Prover/CVLR backend) LLM-munged source. +- `crucible run` compiles the **LLM-authored harness** (its `setup()`, `action_*`, `build.rs`) + and then runs it as a native LiteSVM-in-process binary (§7.2 — verified native, no SVM sandbox). + +So arbitrary code of the LLM's (and the analyzed program's) choosing executes with whatever +ambient authority the AutoProver process has: `ANTHROPIC_API_KEY`, `CERTORA*` cloud tokens, +`AWS_*`, `PG*`, the network route to `169.254.169.254`, and the entire bind-mounted host project. +The trust boundary from §7.2 ("the LLM authors only file *contents*, never argv") stops the LLM +from choosing *what command runs* — it does nothing about what that command, once running, can +*reach*. That is this phase's job. + +Until this phase is green the backend may run only in a trusted, offline environment on trusted +input (the gate scenario). This is the definition of done. + +--- + +## 2. Threat model + +| | | +|---|---| +| **Asset** | AutoProver's ambient secrets (LLM/cloud API keys, DB creds), its network egress (incl. `169.254.169.254` metadata → IAM role creds on EC2), and host files outside the command's declared inputs. | +| **Adversary** | Native code the LLM authored (harness `setup`/`action`/`build.rs`) **and** native code in the analyzed program (its `build.rs`, proc-macros) that `cargo build-sbf` runs. Assume it is actively hostile and knows it is being fuzzed. | +| **Trust boundary** | The process boundary of each `RunCommand` invocation. Inside: untrusted. Outside: the trusted AutoProver process. `program`+`args` are trusted (Rust decider / Python build step author them, §7.2); only the *files* are untrusted. | +| **Assumptions** | (1) The outer container/host is the infrastructure's boundary against the host machine and other tenants (on EC2, the Nitro hypervisor) — this phase is the boundary *within* the container, between AutoProver and its own untrusted child. (2) The kernel is patched and Landlock-capable (§8). (3) The host toolchains we grant read access are trusted. | +| **Non-goals** | Protecting the host machine *from the container* (the infrastructure does that). A full VM boundary between AutoProver and the child (that is what gVisor/Kata/VM-per-run would add at the infra layer, orthogonal to this phase — §6). Defending against a malicious *`program`/`args`* — those are trusted by construction (§7.2). | + +**Explicit guarantees the sandbox must provide:** + +1. **No network** — no egress at all, including DNS and `169.254.169.254`. +2. **No secrets** — the child's environment is a scrubbed allowlist, and it cannot recover + AutoProver's secrets out-of-band (via `/proc//environ` or `ptrace` — see §6, the + same-uid caveats). +3. **Minimal filesystem** — only the command's own inputs are writable; toolchains are read-only; + nothing else of the host is readable. +4. **Resource caps + wall-clock kill** — memory / CPU-time / pids / file-size bounded; a hung or + runaway command is killed. +5. **Offline, code-exec-free dependency resolution** — all network dep-fetching happens *outside* + the sandbox and *before* any untrusted code runs (§5); the sandboxed build is `--offline`. + +--- + +## 3. What runs inside, and what it legitimately needs + +The hard part of sandboxing a compiler+fuzzer is that it needs a *lot* of real toolchain — the +sandbox is only useful if it grants exactly that and nothing more. The three command shapes and +their real needs: + +| Command | Reads (grant **ro+x**) | Writes (grant **rw**) | Network | +|---|---|---|---| +| `cargo build-sbf ` | rust toolchain (`RUSTUP_HOME`), solana platform-tools (the sBPF toolchain), warm cargo registry (`CARGO_HOME/registry`), program crate source | program crate `target/` | none (offline) | +| `crucible run …` | the `crucible` binary + its libs, rust toolchain, cargo registry, the **crucible checkout crates** (path deps from `CrucibleDep`, §6.1), the built `.so` + IDL | the harness crate `target/`, corpus/output dirs | none (offline) | +| `cargo build` (harness, if run directly) | as above | harness `target/` | none (offline) | + +Common surface, resolved once at sandbox-config time and expressed as Landlock rules (§6): + +- **Rust toolchain** — `RUSTUP_HOME` (default `~/.rustup`), `cargo`/`rustc` shims — read+exec. +- **Cargo home** — `CARGO_HOME` (default `~/.cargo`): the `cargo` binary and the **registry cache**, + read-only inside; warmed *outside* (§5). +- **Solana platform-tools** — cargo-build-sbf's sBPF rust toolchain — read+exec. +- **The `crucible` binary** and libs it dlopens — read+exec. +- **The crucible checkout** (`$CRUCIBLE_REPO/crates/…`) — the path deps — read-only. +- **System runtime** — `/usr`, `/bin`, `/lib`, `/lib64` — read+exec (needed for the toolchain's own + dynamic linking and subprocesses). +- **Device nodes** — `/dev/null`, `/dev/urandom`, `/dev/zero`, `/dev/tty` — read+write. The toolchain + opens these constantly (a build *fails* without `/dev/null` — validated during step 2). Landlock + rules can target individual files, so we grant these specific nodes rather than the whole `/dev` + tree; either way `mknod` stays blocked (no capability), so no new devices can be created. +- **Workdir** — the crate tree + `target/` + corpus/output — the primary read-write grant. +- **A private temp dir** — `/.sandbox_tmp`, with `TMPDIR`/`TMP`/`TEMP` pointed at it. The + **linker** writes scratch files to `$TMPDIR` (default `/tmp`) during `cargo build`; we do *not* + grant the shared `/tmp` (it may hold host/other-run secrets and would defeat the escape test), so a + per-run temp under the already-writable workdir is redirected in. (A fresh harness build fails at + the link step — "Cannot create temporary file in /tmp/" — without this; found via the e2e gate.) +- **A private cargo home** — `/.sandbox_cargo`, with `CARGO_HOME` pointed at it (§11 item 5). + The offline `cargo build` writes there (source extraction, locks); keeping it per-run means + untrusted build code can't poison a *shared* `~/.cargo` (which stays read-only, for the `cargo` + binary). The warm step (§5) fetches into this same home. + +Everything else — the rest of the bind-mounted project, `/etc`, `/proc/`, `$HOME`, the +process environment — is **not granted**, therefore inaccessible. Confinement is default-deny. + +> The exact host paths (`RUSTUP_HOME`, platform-tools dir, crucible binary) are **resolved by the +> host at config time**, not hardcoded — see the `SandboxPolicy` in §7. They are discovered from the +> environment the same way `resolve_crucible_repo` already discovers the checkout. + +--- + +## 4. The seam — one function, unchanged signature + +All command execution already funnels through +[`run_local_command`](../composer/sandbox/command.py) (both the IoC `RunCommand` effect via +[`RealEffects.run_command`](../composer/rustapp/adapter.py#L120) and the Solana build step +[`build_program`](../composer/spec/solana/build.py)). It lives in the backend-agnostic +[`composer/sandbox`](../composer/sandbox/) package — outside `rustapp` — so Python-based backends can +run confined commands too, not just the Rust-IoC ones. The sandbox wraps exactly this one function. + +**The mechanism sits behind a `SandboxProvider` seam, so it is swappable.** `run_local_command` +never names a concrete tool. It holds a **tool-agnostic `SandboxPolicy`** (the *intent*: rw paths, +ro paths, env allowlist, rlimits, network-off — §7) and a `SandboxProvider` that translates that +intent into a concrete launch: + +```python +class SandboxProvider(Protocol): + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: ... + def available(self) -> Availability: ... # drives fail-closed (§7) + +# run_local_command, unchanged shape: +spec = provider.wrap(policy, program, args) +create_subprocess_exec(*spec.argv, cwd=workdir, env=spec.env, …) +``` + +The first provider is our **custom launcher shim** (§6): `LaunchSpec.argv == ["run-confined", +*policy_argv, "--", program, *args]`, all authored by trusted Python (never the LLM). Swapping to an +off-the-shelf tool later — `landrun`, `sandlock` — is a *new `SandboxProvider` implementation that +maps the same `SandboxPolicy` to that tool's flags*; the policy, this seam, `run_local_command`, +`RealEffects`, and the escape-test gate (§10) are all untouched. The provider is chosen by config +(`CommandConfig` / an env var), defaulting to the custom launcher. The `none` provider is a +passthrough (`argv == [program, *args]`) — byte-for-byte today's behavior for the EVM/Foundry paths +and explicit trusted-input dev runs. + +Nothing in the Rust decider, the ABI, the driver, or the artifact store changes — this is why §7.4 +could defer it to last. + +Two properties `run_local_command` *already* enforces stay in force and are the first line of +defense (the sandbox is the second): the command runs via **exec, not a shell**, and every written +file path is **confined to the workdir** (`_confined_target`). The sandbox does not replace these; +it assumes them. + +--- + +## 5. Offline dependency resolution — split fetch (network, no exec) from build (exec, no network) + +The tension: `cargo build` needs its dependency crates, but the sandbox has no network. Resolution +splits cleanly along the code-execution line: + +- **`cargo fetch` / `cargo vendor` download but never run build scripts** — no untrusted code + executes during fetch. So the *fetch* happens **outside** the sandbox, with network, as a trusted + prep step, warming `CARGO_HOME/registry` (or producing a vendored dir + source-replacement + config). +- **`cargo build` runs build scripts and proc-macros** — this is where untrusted code executes, so + it happens **inside** the sandbox, `--offline`, against the already-warm cache. + +The harness `Cargo.toml` is **host-owned** (`CrucibleDep.render_deps`, pinned versions, §6.1), so +its dep graph is fixed and vendorable deterministically. The program-under-test's `Cargo.toml` is +user-supplied, but `cargo fetch` on it is still exec-free, so the same split holds for the build-sbf +step. This also closes the build-time supply-chain vector: with offline + a pre-warmed cache, a +malicious `build.rs` cannot pull a payload at build time. + +**Implementation (step 4).** "Offline inside" is one env var, not per-tool flags: the policy sets +**`CARGO_NET_OFFLINE=1`** in the child env, which forces *every* cargo invocation offline — including +the nested `cargo` that `crucible run` spawns to build the harness — so we never thread `--offline` +through each tool ([recipes.py](../composer/sandbox/recipes.py), `offline=True` default). "Fetch +outside" is [`warm_cargo_cache`](../composer/spec/solana/build.py) — a `cargo fetch` run *unsandboxed* +(no provider → network on) before the confined build; `build_program` calls it before the sandboxed +`cargo build-sbf`. The harness crate has its own deps (libafl, litesvm, …), so it needs its own warm +at manifest-assembly time; wiring that exact call site (and confirming whether `CARGO_HOME` must be +granted rw for cargo's build-time source extraction, or pre-extracted during the warm) lands with the +gate in step 5, where a real offline build proves it. All of this is inert until a sandbox is enabled. + +--- + +## 6. Mechanism: unprivileged Landlock + seccomp self-sandboxing + +### Why not a namespace sandbox (bwrap/nsjail) or gVisor + +The obvious tools (bwrap, nsjail) build the sandbox out of **namespaces** (user + mount + net + +pid), then `pivot_root` into a minimal filesystem. That model **fights the container**: creating an +unprivileged user namespace and mounting inside it is exactly what Docker's default seccomp + +AppArmor block. Validated empirically (python:3.12-slim, host kernel 7.0.11, `bwrap 0.11.0`, uid +1000): + +| Approach under Docker defaults | Outcome | +|---|---| +| unprivileged `bwrap` | ✗ userns creation blocked by default **seccomp** | +| `bwrap`, `seccomp=unconfined` | ✗ `mount --make-rslave` blocked by **AppArmor** `docker-default` | +| `bwrap`, `seccomp=unconfined`+`apparmor=unconfined` | ✓ works — but requires **weakening the whole container's LSMs** (rejected) | +| setuid `bwrap` | ✗ `capset` blocked (Docker capability bounding set drops `CAP_SETPCAP`) | + +Making bwrap work would mean either **stripping the container's own seccomp/AppArmor** (widening the +host-kernel attack surface across *all* of AutoProver — the opposite of what a sandboxing phase +should do) or running AutoProver under a **gVisor/Kata** runtime. gVisor works, but (a) it imposes +its *heaviest* overhead precisely on our syscall/I/O-bound compile+fuzz workload, and (b) its benefit +— protecting the host kernel — is an *infrastructure* boundary that on EC2 is already provided by the +Nitro hypervisor. Neither is worth coupling this phase to a deployment decision. + +### The chosen model: the process sandboxes itself + +Instead of building a new namespace *around* the command, the command **restricts itself** using two +unprivileged kernel facilities — the model Chrome, OpenSSH, and systemd use. Both need **no +namespaces, no capabilities, no root, and no `--security-opt`**, and both work in a **stock** +container. Validated (stock python:3.12-slim, uid 1000, Docker default profile): + +| Guarantee | Probe result | Mechanism | +|---|---|---| +| filesystem — write outside workdir | ✗ `EACCES` | **Landlock** (full ABI FS bit set, grant only workdir rw) | +| filesystem — read host file (`/etc/passwd`) | ✗ `EACCES` | Landlock (no grant) | +| **secret** — read `/proc//environ` | ✗ `EACCES` | Landlock (no `/proc` grant) | +| **secret** — `ptrace(ATTACH, parent)` | ✗ `EPERM` | **seccomp** (deny `ptrace`, `process_vm_readv`) | +| network — `socket(AF_INET)` | ✗ `EPERM` | seccomp (deny inet-domain sockets → blocks TCP, UDP/DNS, IMDS) | +| legitimate — write workdir, `exec` toolchain | ✓ works | Landlock rw grant + r+x on toolchain paths | + +- **[Landlock](https://docs.kernel.org/userspace-api/landlock.html)** (LSM; Linux ≥5.13, we observed + ABI **8**) — an unprivileged process installs a filesystem ruleset on itself: default-deny, then + grant rw to the workdir and read+exec to the toolchain paths of §3, handling the *full* set of FS + access rights the running ABI supports (else unhandled operations stay unrestricted). This is what + confines reads *and* writes and — crucially — closes the `/proc//environ` leak that a user + namespace would otherwise have closed for free. +- **seccomp-BPF self-filter** (`PR_SET_NO_NEW_PRIVS` + `SECCOMP_SET_MODE_FILTER`) — installing a + *stricter* filter on yourself is unprivileged and permitted by Docker's default profile. It denies + the network (`socket` with `AF_INET`/`AF_INET6` — covering TCP, UDP/DNS, and the IMDS endpoint, + while leaving `AF_UNIX` for benign local IPC) and the remaining same-uid secret vectors + (`ptrace`, `process_vm_readv`/`writev`). +- **env allowlist** — the launcher `execve`s with a scrubbed environment (PATH, HOME, CARGO_HOME, + RUSTUP_HOME, TERM, and benign build vars only). The `--clearenv` equivalent, done in-process. +- **rlimits** — `setrlimit` for `RLIMIT_AS` / `RLIMIT_CPU` / `RLIMIT_NPROC` / `RLIMIT_FSIZE` (§7). + +Landlock and seccomp are **preserved across `execve`** (with `NO_NEW_PRIVS`) and **inherited across +`fork`**, so the launcher applies them once and every descendant — `cargo`, `rustc`, each `build.rs`, +the linker, the fuzz binary — runs confined. + +### The same-uid caveat, and why it is closed + +A user namespace (bwrap) would have run the child under a *remapped* uid, so cross-process access to +AutoProver was denied by credential mismatch. Self-sandboxing keeps the child at AutoProver's **own +uid**, so the two out-of-band secret vectors must be closed *explicitly* — and are: `/proc// +environ` by **not granting `/proc`** in the Landlock ruleset (proven `EACCES`), and `ptrace`/ +`process_vm_readv` by the **seccomp deny-list** (proven `EPERM`). These are the only same-uid vectors +to AutoProver's memory/env; both verified closed in the stock container. + +### The launcher: a custom shim over audited crates (not hand-rolled primitives) + +The first `SandboxProvider` (§4) is a small **trusted Rust launcher** (`run-confined`) that applies the +four confinements to itself, then `execve`s the command. It does **not** hand-write raw seccomp BPF +or raw Landlock syscalls — it composes two mature, permissively-licensed crates: + +- **[`landlock`](https://crates.io/crates/landlock)** — the reference Rust binding; does ABI + negotiation and the full FS access-right set (the fiddly part §11 Q1 warns about). +- **[`seccompiler`](https://crates.io/crates/seccompiler)** — the seccomp-BPF compiler from **AWS + Firecracker**; we hand it a small allow/deny policy, not raw bytecode. + +plus `setrlimit` and an env allowlist. So the security-sensitive primitives are audited upstream; +our code is the glue + the policy. We build Rust already, so this adds no new toolchain. + +### Alternatives considered — and why the seam stays swappable (§4) + +Two off-the-shelf tools do essentially this model. Neither is adopted *now*, but the `SandboxProvider` +seam means either can be dropped in later as a new provider mapping the same `SandboxPolicy`: + +- **[`landrun`](https://github.com/zouuup/landrun)** (Go CLI, **MIT**, mature ~2.2k★, FS floor 5.13): + excellent for Landlock FS + env, and the reference for our CLI shape. But it blocks network via + **Landlock network rules (TCP-only, kernel ≥6.7)** — it does **not** block UDP/DNS, and degrades + fail-open on older kernels — and has no rlimits. It would need a seccomp companion anyway, so it + doesn't save the hard part. +- **[`sandlock`](https://github.com/multikernel/sandlock)** (Python+Rust, Landlock+seccomp): the + closest match to our full model, but requires **kernel ≥6.12 (Landlock ABI v6)** — above Amazon + Linux 2023's 6.1 — and ships an **unstated license** plus more surface than we need (MITM proxy, + COW, notification supervisor). A strong candidate to revisit *if* the kernel-floor and license + questions are resolved and reviewers prefer an off-the-shelf boundary. + +The custom launcher wins for now on **kernel floor** (5.13, because we block network with seccomp not +Landlock), **license clarity**, and **minimal surface** — while the provider seam keeps the door open +to swap in `sandlock`/`landrun` with no change to the policy or the gate. + +### The chief advantage: deployment-independence + +Because it needs nothing from the container, the same code path runs identically on a dev laptop, +self-managed EC2, ECS, EKS, and even Fargate, and under `runc` or gVisor alike. **It decouples Phase +6 from the open deployment/tenancy questions** — those can be settled later as an *infrastructure* +hardening decision (VM-per-run / gVisor / IMDSv2 hop-limit / least-privilege IAM), layered *on top* +of this in-process boundary, not blocking it. + +**Residual risk:** a Landlock/seccomp bypass or a kernel LPE would let the child reach the container +(and then only as far as the infrastructure boundary allows — the container, or on EC2 the Nitro +VM). Named; mitigated by keeping the kernel patched, by the env/network already being denied, and by +the orthogonal infra hardening above for higher-trust-risk deployments. + +--- + +## 7. Resource limits, and the config surface + +**Resource caps** are `setrlimit` calls the launcher makes on itself before `execve` (lowering your +own limits is unprivileged; inherited by all descendants): `RLIMIT_AS` (address space / memory-ish), +`RLIMIT_CPU` (CPU-seconds — a wall-clock-independent bound), `RLIMIT_NPROC` (fork-bomb guard), +`RLIMIT_FSIZE` (disk-fill guard). `RLIMIT_AS` is crude (address space, not RSS) but dependency-free; +a **cgroup v2** scope (`memory.max`, `pids.max`, `cpu.max`) is the robust upgrade if the container +grants writable cgroup delegation — note it, defer it. The existing asyncio `wait_for(..., +timeout_s)` in `run_local_command` stays the primary wall-clock kill. + +The confinement *intent* is a **tool-agnostic** policy object (the same one every `SandboxProvider` +consumes, §4) — deliberately naming no mechanism, so a future provider swap needs no policy change: + +```python +@dataclass(frozen=True) +class SandboxPolicy: + rw_paths: tuple[Path, ...] # the workdir (+ any writable scratch) + ro_paths: tuple[Path, ...] # toolchains, crucible checkout, platform-tools, /usr… + env_allowlist: Mapping[str, str] # PATH, HOME, CARGO_HOME, RUSTUP_HOME, TERM, … + network: bool = False # egress allowed? default off + mem_bytes: int = ... + cpu_seconds: int = ... + nproc: int = ... + fsize_bytes: int = ... + # program + args come per-call from run_local_command +``` + +**Provider selection is separate config, not part of the policy** — a `CommandConfig.sandbox_provider` +knob (`"launcher"` = the custom Rust shim, default; `"none"` = passthrough; later `"landrun"` / +`"sandlock"`), overridable by env var. `run_local_command` gains `policy: SandboxPolicy | None` + +the resolved provider (default provider `"none"` when no policy, so existing callers and the EVM path +are unchanged). `RealEffects` builds the policy from a host-resolved config (toolchain paths +discovered like `resolve_crucible_repo` already does), and `build_program` uses the same. + +**Fail-closed.** Before running under a real sandbox provider, `provider.available()` is checked +(for the launcher: kernel Landlock ABI present). If it isn't — or the provider cannot apply its +confinement — the command **refuses to run** rather than silently executing unconfined. The failure +is a **prominent, actionable message** naming the reason ("the command sandbox requires a +Landlock-capable kernel (Linux ≥5.13); this backend cannot run without it — see +docs/command-sandbox.md §8"). The `none` provider is a *separate*, explicit, logged choice for the +trusted EVM/Foundry callers and trusted-input dev runs — never reached as a fallback from a failed +sandbox setup. + +--- + +## 8. Platform requirements — Linux with Landlock; nothing else supported + +Landlock and seccomp are **Linux** facilities. This backend is supported only on a Linux host with a +**Landlock-capable kernel (≥5.13; ≥6.7 adds Landlock network rules as defense-in-depth)** — which +AutoProver's own container already provides (Amazon Linux 2023 = 6.1, recent Ubuntu, and the dev +container all qualify). **macOS is not a supported configuration** (team decision): there is no +Landlock, and no macOS-native equivalent is planned. A Mac developer runs this backend the way +AutoProver already runs — inside the Linux container. + +If the sandbox cannot be established (non-Linux host, or a kernel without Landlock), the run +**fails immediately** with the §7 fail-closed message. This is the one uniform response everywhere +the sandbox is unavailable: refuse to run, loudly, rather than run untrusted native code unconfined. + +--- + +## 9. Implementation plan + +1. **The `SandboxProvider` seam + `SandboxPolicy`** — *done* ([composer/sandbox/policy.py](../composer/sandbox/policy.py)): + the tool-agnostic policy (§7), the `SandboxProvider` protocol (`wrap` → `LaunchSpec`, `available`), + the `none` passthrough provider, the name registry, and `ensure_available` / `SandboxUnavailable`. + Pure, unit-tested. **This is the isolation layer that makes the mechanism swappable** — everything + else depends only on this interface, never on a concrete tool. Lives in the backend-agnostic + [`composer/sandbox`](../composer/sandbox/) package (with `run_local_command`), not under `rustapp`. +2. **The custom launcher provider** — *done*: the `run-confined` **trusted Rust binary** + ([rust/run-confined](../rust/run-confined)) + the `LauncherProvider` + ([composer/sandbox/launcher.py](../composer/sandbox/launcher.py)) that maps a + `SandboxPolicy` to its argv. `run-confined --ro … --rw … --allow-env NAME[=VAL]… + --rlimit-* … [--allow-network] -- ` sets rlimits + `NO_NEW_PRIVS`, builds the + Landlock ruleset (best-effort ABI negotiation, full FS bit set, deny-by-default + §3 grants) via + the [`landlock`](https://crates.io/crates/landlock) crate, builds the seccomp filter (deny inet + sockets + ptrace/process_vm_*) via [`seccompiler`](https://crates.io/crates/seccompiler), applies + both, then `execve`s the command with an env scrubbed to the allowlist. `--probe` reports the + kernel Landlock ABI and drives `available()` → fail-closed (§7). Enforcement smoke-tested on the + host (write-outside / `/etc/passwd` / `/proc//environ` / inet-socket all denied; workdir + write, AF_UNIX, and toolchain `exec` allowed); argv mapping golden-tested. Full escape gate is + step 5. +3. **Thread `policy` + provider through `run_local_command`** — *done*: the runner accepts + `provider`/`policy` (default `None` → the `none` passthrough, byte-for-byte today's behavior) and + is fail-closed via `ensure_available`. A `SandboxConfig` ([composer/sandbox/config.py](../composer/sandbox/config.py)) + selects the provider (`$COMPOSER_SANDBOX_PROVIDER`, default `none`) and builds the policy via the + `rust_build_policy` recipe ([composer/sandbox/recipes.py](../composer/sandbox/recipes.py) — the + workdir and `/dev` nodes rw; discovered rust/cargo/platform-tool and system dirs ro, incl. `/etc` + for NSS; env allowlist; network off). Threaded through `RealEffects` and `RustBackend`/`RustFormalizer` + ([composer/rustapp/adapter.py](../composer/rustapp/adapter.py)), `build_program` + ([composer/spec/solana/build.py](../composer/spec/solana/build.py)), and the Crucible pipeline + (which adds the crucible checkout + binary to `extra_ro`). Integration-tested: `run_local_command` + under the launcher denies out-of-workdir reads and network while allowing the workdir + toolchain. +4. **Offline prep (§5)** — *done*: `warm_cargo_cache` (a `cargo fetch` run outside the sandbox, + network on) warms the registry, and the policy sets `CARGO_NET_OFFLINE=1` so the confined build — + and the nested cargo `crucible run` spawns — run offline. Wired into `build_program`; the + harness-dir warm is `CrucibleArtifactStore.warm_dependencies`, called from `prepare_formalization` + after the manifest is placed when a sandbox is on. `CARGO_HOME` is granted rw (the crucible policy) + so cargo can extract crate sources offline. +5. **The escape-test gate (§10)** — *done*, and **Crucible's default provider is now `launcher`** + (`_crucible_sandbox`; override with `COMPOSER_SANDBOX_PROVIDER=none`). Validated: + - **Part A (escape suite) — green** ([tests/test_sandbox_escape.py](../tests/test_sandbox_escape.py)): + a `rustc`-compiled malicious program run through the real launcher has every vector *denied* + (secret env, `/proc//environ`, host file outside the workdir, external TCP, and + `169.254.169.254`), with an unconfined control confirming the leaks would otherwise happen. + - **Part B — green**: a real `cargo-build-sbf` of `solana_vault` under the launcher (offline, + confined) produces the `.so` ([tests/test_crucible_sandbox_gate.py](../tests/test_crucible_sandbox_gate.py) + — this caught the relative-policy-path bug; grants must be absolute), and a real + `crucible run --dry-run` under the launcher builds the harness *offline* and runs LiteSVM + (`Harness validation passed!`). + - **Full LLM vertical — green**: the e2e gate (`tests/test_crucible_e2e_gate.py`) passes under the + launcher (`COMPOSER_SANDBOX_PROVIDER=launcher`): analysis → 23 properties → shared fixture + authored → per-instruction harness build + fuzz, all confined + offline, with **all three + instructions (initialize / deposit / withdraw) delivered with fuzz verdicts** (`BAD` — + counterexamples found). Getting here required the `/tmp` fix below and the shared-crate + concurrency fix (§11 item 8); before the latter, `initialize` was dropped to a `Cargo.toml` + feature race. + + **Root cause found via the gate:** every fresh harness build initially failed at the *link* step — + `Cannot create temporary file in /tmp/: Permission denied` (the linker's `$TMPDIR` scratch, which + the policy didn't grant). A link failure reads as "could not compile", so the LLM kept rewriting a + fine fixture. Fixed by redirecting `TMPDIR` to a private `/.sandbox_tmp` (§3) rather than + granting the shared `/tmp`. The `RunCommand` failure logging added alongside the authoring + improvements is what surfaced it. + +Each step is behind the seam, so the earlier Phase 1–5 gates keep passing. **Prerequisite of the +flip:** `run-confined` must be on PATH (built into the Docker image; `cargo build -p run-confined +--release` in dev) — otherwise Crucible fail-closes (§7/§8). A later off-the-shelf swap +(`landrun`/`sandlock`) is *only* a new step-2-style provider — the seam, policy, and gate are +untouched. + +--- + +## 10. The gate — an escape test + +A new expensive gate (`tests/test_crucible_sandbox_gate.py`) with two halves: + +**A. Escape attempts are denied.** Author a *malicious* harness whose `setup()` (and a companion +`build.rs`) attempts, and record the outcome of each: + +- **Read a planted secret** — set a fake `ANTHROPIC_API_KEY=canary-` in AutoProver's env + before the run; the harness (a) reads `std::env::var("ANTHROPIC_API_KEY")` and (b) tries to read + it out-of-band from `/proc//environ`, writing whatever it finds to a workdir file. Assert + the value is **absent** by both routes. +- **Read a host file outside the workdir** — the harness tries to open `/etc/passwd` and a file in + the real project root *outside* the granted workdir. Assert **permission denied**. +- **Reach the network** — the harness tries to connect to an external host **and** to + `http://169.254.169.254/latest/meta-data/` (the EC2 metadata endpoint → IAM creds). Assert **both + fail** (socket creation denied). + +The harness must not be able to fail the assertions silently — it writes each probe's result into +the workdir (allowed) and the test reads them back, asserting every probe reports *denied*. + +**B. The legitimate path still works.** The existing `solana_vault` gate ([§8](./crucible-application.md#L545)) +passes **unchanged** under the launcher provider — the shared fixture is authored, the `.so` builds, +tests compile and fuzz, verdicts are produced. This proves the sandbox grants exactly the toolchain +the real work needs and nothing more. + +Because the gate is written against the `SandboxProvider` seam (§4), not a specific tool, it doubles +as the **conformance test any future provider must pass** — swapping in `landrun`/`sandlock` means +re-running this same gate green, nothing more. + +Only when both halves are green may the backend run on untrusted input (the §9 definition of done). + +--- + +## 11. Open questions + +1. **Landlock ABI coverage / negotiation.** The launcher must handle the full FS access-right set of + the *running* kernel's ABI (unhandled rights stay unrestricted) with best-effort fallback on older + kernels. The `landlock` crate does this; confirm the minimum supported ABI on our target AMIs and + what "best-effort" degrades to (e.g. pre-ABI-3 has no `TRUNCATE` handling). +2. **AF_UNIX / netlink allowance.** The seccomp filter denies `AF_INET`/`AF_INET6` but allows + `AF_UNIX`. Confirm the toolchain (cargo jobserver, rustc, linker) needs nothing more; if a + benign `AF_NETLINK` use surfaces, decide whether to allow it (it can read but not egress). +3. **rlimits vs cgroup v2 (§7).** Is `RLIMIT_AS` enough to contain a memory-hungry fuzzer, or do we + need cgroup `memory.max` (and thus writable cgroup delegation in the container) sooner? +4. **Cache warming cost (§5).** Per-run `cargo fetch` adds latency; is a shared, pre-warmed + read-only registry volume worth it for CI throughput? +5. **Per-run `CARGO_HOME` — done.** An offline `cargo build` *writes* to `CARGO_HOME` (extracts crate + sources, takes locks), and that build runs untrusted `build.rs`/proc-macro code — so a writable + *shared* `~/.cargo` was a cross-run poisoning surface (overwrite an extracted `registry/src` to + hit a later run). Fixed: `rust_build_policy` points `CARGO_HOME` at a **private per-run dir under + the workdir** (`sandbox_cargo_home` → `/.sandbox_cargo`), the warm step (`warm_cargo_cache`, + unsandboxed) fetches *into that same home*, and the shared `~/.cargo` is granted read-only (for the + `cargo` binary) — so untrusted writes touch only the run's throwaway cache. Validated: a fresh + fetch into an empty private home + a confined offline build succeed. **Remaining cost:** deps are + re-fetched per run (no shared writable cache); a shared *read-only* index/cache to avoid the + re-download is the deferred optimization. +6. **Off-the-shelf provider swap (deferred, seam is ready — §4/§6).** `sandlock` (needs kernel + ≥6.12; unstated license) or `landrun` (+ a seccomp companion for UDP/DNS + rlimits) could replace + the custom launcher as a new `SandboxProvider` if reviewers prefer an off-the-shelf boundary. Blocked + today on the kernel-floor (target AMI ≥6.12?) and license questions; revisit once those resolve. + The provider seam + the gate-as-conformance-test (§10) make the swap mechanical. +7. **Infra-layer hardening (orthogonal, non-blocking).** Independent of this in-process boundary, + deployments running genuinely untrusted programs should also apply the standard EC2 hardening — + least-privilege instance IAM role, IMDSv2 with hop limit 1, egress-restricted security group, and + (if desired) VM-per-run or a gVisor runtime. Decide per deployment when the tenancy model is + settled; none of it blocks Phase 6. +8. **Shared-`Cargo.toml`/`main.rs` race (crucible backend) — fixed.** The per-component sessions + share one `fuzz//` crate; concurrent runs raced on both files (the observed + "package does not contain this feature: `c_`" that dropped `initialize`, and a latent + `main.rs` clobber). Fixed two ways: `prepare_component` now reserves Cargo features + **cumulatively** (the manifest only grows, so no feature is lost), and per-component command runs + are **serialized + atomic** (`run_local_command` materializes files and runs as one unit under a + `Semaphore(1)` shared by `RustFormalizer`), while the LLM authoring turns still run concurrently. + The remaining parallelism win — concurrent *builds/fuzzing* — needs a crate-per-component (§10 Q1); + deferred. diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 00000000..6e3ef630 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,113 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "landlock" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" +dependencies = [ + "enumflags2", + "libc", + "thiserror", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "run-confined" +version = "0.1.0" +dependencies = [ + "landlock", + "libc", + "seccompiler", +] + +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 00000000..be0ad6b3 --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,21 @@ +# Cargo workspace for AutoProver's Rust components. +# +# In this PR the workspace contains a single member: +# +# * `run-confined` — the trusted command-sandbox launcher (Landlock filesystem + +# seccomp network/ptrace + rlimits + scrubbed env, then `execve`). The first +# `SandboxProvider` for the `RunCommand` effect — see docs/command-sandbox.md. +# +# Later PRs re-add the Rust *application framework* crates to `members`: +# `autoprover-sdk` (the ABI + `export_app!` macro) and `example-app` (its +# round-trip test wheel), then the `crucible-app` wheel. Those crates depend on +# the shared `[workspace.dependencies]` (serde / pyo3); `run-confined` does not, +# so this trimmed workspace declares none. +[workspace] +resolver = "2" +members = ["run-confined"] + +[workspace.package] +edition = "2021" +version = "0.1.0" +license = "MIT" diff --git a/rust/run-confined/Cargo.toml b/rust/run-confined/Cargo.toml new file mode 100644 index 00000000..b02f9e46 --- /dev/null +++ b/rust/run-confined/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "run-confined" +edition.workspace = true +version.workspace = true +license.workspace = true +description = "Trusted launcher that confines a command (Landlock filesystem + seccomp network/ptrace + rlimits + scrubbed env), then execs it. The first SandboxProvider for the RunCommand effect — see docs/command-sandbox.md." + +[[bin]] +name = "run-confined" +path = "src/main.rs" + +[dependencies] +# Filesystem confinement (ABI negotiation + full access-right set). +landlock = "0.4" +# seccomp-BPF compiler from AWS Firecracker — we hand it a small allow/deny +# policy, not raw bytecode. +seccompiler = "0.5" +# setrlimit / prctl / syscall numbers / AF_* constants. +libc = "0.2" diff --git a/rust/run-confined/src/main.rs b/rust/run-confined/src/main.rs new file mode 100644 index 00000000..bb296137 --- /dev/null +++ b/rust/run-confined/src/main.rs @@ -0,0 +1,288 @@ +//! `run-confined` — the trusted launcher for the `RunCommand` sandbox. +//! +//! It applies four unprivileged, in-kernel confinements to *itself*, then `execve`s +//! the requested command (which inherits all of them across the exec): +//! +//! 1. **Landlock** — a filesystem ruleset: default-deny, then grant `--rw` paths +//! full access and `--ro` paths read+execute. Confines reads *and* writes and, +//! by not granting `/proc`, closes the same-uid `/proc//environ` leak. +//! 2. **seccomp** — deny inet-domain `socket()` (blocks TCP, UDP/DNS, and the EC2 +//! metadata endpoint) and `ptrace`/`process_vm_readv`/`process_vm_writev`. +//! 3. **env allowlist** — `execve` with only `--allow-env` variables (a scrubbed +//! environment). +//! 4. **rlimits** — `--rlimit-*` caps on address space / CPU-seconds / pids / file size. +//! +//! This is trusted code: its argv is authored by the Python side (never the LLM, +//! which controls only file *contents*). It is **fail-closed** — any setup failure, +//! or a kernel without Landlock, exits nonzero *without* execing the command, so +//! untrusted input never runs unconfined. +//! +//! See `docs/command-sandbox.md` (§6) for the design and the validation matrix. + +use std::collections::BTreeMap; +use std::os::unix::process::CommandExt; +use std::path::PathBuf; +use std::process::Command; + +use landlock::{ + Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr, + RulesetCreatedAttr, RulesetStatus, ABI, +}; +use seccompiler::{ + apply_filter, BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, + SeccompFilter, SeccompRule, TargetArch, +}; + +/// Bad command line (a programming error on the trusted caller's side). +const EXIT_USAGE: i32 = 2; +/// The sandbox could not be established — fail-closed, command NOT run. +const EXIT_SANDBOX_UNAVAILABLE: i32 = 3; +/// The confined `execve` itself failed (e.g. program not found on PATH). +const EXIT_EXEC_FAILED: i32 = 127; + +/// `landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)` returns the +/// kernel's supported ABI version, or fails if Landlock is unavailable. +const LANDLOCK_CREATE_RULESET_VERSION: libc::c_ulong = 1; + +#[derive(Default)] +struct Config { + rw_paths: Vec, + ro_paths: Vec, + env: Vec<(String, String)>, + allow_network: bool, + rlimit_as: Option, + rlimit_cpu: Option, + rlimit_nproc: Option, + rlimit_fsize: Option, + program: String, + args: Vec, +} + +fn die(code: i32, msg: &str) -> ! { + eprintln!("run-confined: {msg}"); + std::process::exit(code); +} + +fn main() { + let argv: Vec = std::env::args().skip(1).collect(); + + if argv.first().map(String::as_str) == Some("--probe") { + probe(); + } + + let cfg = parse(&argv).unwrap_or_else(|e| die(EXIT_USAGE, &e)); + + // Order matters: rlimits + env are harmless early; apply Landlock, then seccomp + // LAST so our own setup syscalls aren't caught by the filter; then exec. + set_rlimits(&cfg); + set_no_new_privs(); + if let Err(e) = apply_landlock(&cfg) { + die(EXIT_SANDBOX_UNAVAILABLE, &format!("Landlock setup failed: {e}")); + } + if let Err(e) = apply_seccomp(&cfg) { + die(EXIT_SANDBOX_UNAVAILABLE, &format!("seccomp setup failed: {e}")); + } + + let mut cmd = Command::new(&cfg.program); + cmd.args(&cfg.args).env_clear().envs(cfg.env.iter().cloned()); + // `exec` replaces this process image; it only returns on failure. + let err = cmd.exec(); + die(EXIT_EXEC_FAILED, &format!("exec {:?} failed: {err}", cfg.program)); +} + +/// `--probe`: report whether the kernel supports Landlock. Exit 0 + print the ABI +/// if so; exit `EXIT_SANDBOX_UNAVAILABLE` otherwise. Drives Python's fail-closed +/// `available()` check without restricting this (throwaway) process. +fn probe() -> ! { + let abi = unsafe { + libc::syscall( + libc::SYS_landlock_create_ruleset, + std::ptr::null::(), + 0usize, + LANDLOCK_CREATE_RULESET_VERSION, + ) + }; + if abi > 0 { + println!("landlock abi {abi}"); + std::process::exit(0); + } + die( + EXIT_SANDBOX_UNAVAILABLE, + "kernel does not support Landlock (need Linux >= 5.13); refusing to run unconfined", + ); +} + +fn parse(argv: &[String]) -> Result { + let mut cfg = Config::default(); + let mut i = 0; + + let take = |i: &mut usize, flag: &str| -> Result { + *i += 1; + argv.get(*i) + .cloned() + .ok_or_else(|| format!("{flag} requires a value")) + }; + let parse_u64 = |s: &str, flag: &str| -> Result { + s.parse::().map_err(|_| format!("{flag} expects an integer, got {s:?}")) + }; + + while i < argv.len() { + match argv[i].as_str() { + "--rw" => cfg.rw_paths.push(PathBuf::from(take(&mut i, "--rw")?)), + "--ro" => cfg.ro_paths.push(PathBuf::from(take(&mut i, "--ro")?)), + "--allow-network" => cfg.allow_network = true, + "--rlimit-as" => cfg.rlimit_as = Some(parse_u64(&take(&mut i, "--rlimit-as")?, "--rlimit-as")?), + "--rlimit-cpu" => cfg.rlimit_cpu = Some(parse_u64(&take(&mut i, "--rlimit-cpu")?, "--rlimit-cpu")?), + "--rlimit-nproc" => cfg.rlimit_nproc = Some(parse_u64(&take(&mut i, "--rlimit-nproc")?, "--rlimit-nproc")?), + "--rlimit-fsize" => cfg.rlimit_fsize = Some(parse_u64(&take(&mut i, "--rlimit-fsize")?, "--rlimit-fsize")?), + "--allow-env" => { + let spec = take(&mut i, "--allow-env")?; + if let Some((name, value)) = spec.split_once('=') { + cfg.env.push((name.to_string(), value.to_string())); + } else if let Ok(value) = std::env::var(&spec) { + // NAME with no '=': pass through from the current environment if set. + cfg.env.push((spec, value)); + } + // NAME not present in the environment: silently skip (nothing to pass). + } + "--" => { + i += 1; + if i >= argv.len() { + return Err("no program given after `--`".to_string()); + } + cfg.program = argv[i].clone(); + cfg.args = argv[i + 1..].to_vec(); + return Ok(cfg); + } + other => return Err(format!("unknown flag {other:?} (did you forget `--` before the command?)")), + } + i += 1; + } + Err("missing `--` and command to run".to_string()) +} + +fn set_rlimits(cfg: &Config) { + let set = |resource: libc::__rlimit_resource_t, value: u64| { + let lim = libc::rlimit { rlim_cur: value, rlim_max: value }; + // Best-effort: a failure to *lower* a limit is not worth aborting the run over. + unsafe { libc::setrlimit(resource, &lim) }; + }; + if let Some(v) = cfg.rlimit_as { + set(libc::RLIMIT_AS, v); + } + if let Some(v) = cfg.rlimit_cpu { + set(libc::RLIMIT_CPU, v); + } + if let Some(v) = cfg.rlimit_nproc { + set(libc::RLIMIT_NPROC, v); + } + if let Some(v) = cfg.rlimit_fsize { + set(libc::RLIMIT_FSIZE, v); + } +} + +fn set_no_new_privs() { + // Required before loading a seccomp filter (and by Landlock) for an unprivileged + // process; ensures no exec can regain privileges. + unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; +} + +fn apply_landlock(cfg: &Config) -> Result<(), String> { + // Handle the full access-right set the crate knows; BestEffort tolerates a kernel + // that lacks the newest rights, but we still require Landlock to be *enforcing* + // at all (checked below) — otherwise we would silently run unconfined. + let abi = ABI::V5; + + let mut created = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(AccessFs::from_all(abi)) + .map_err(|e| e.to_string())? + .create() + .map_err(|e| e.to_string())?; + + for p in &cfg.ro_paths { + match PathFd::new(p) { + Ok(fd) => { + created = created + .add_rule(PathBeneath::new(fd, AccessFs::from_read(abi))) + .map_err(|e| e.to_string())?; + } + Err(e) => eprintln!("run-confined: skipping missing --ro path {p:?}: {e}"), + } + } + for p in &cfg.rw_paths { + match PathFd::new(p) { + Ok(fd) => { + created = created + .add_rule(PathBeneath::new(fd, AccessFs::from_all(abi))) + .map_err(|e| e.to_string())?; + } + Err(e) => return Err(format!("required --rw path {p:?} is unopenable: {e}")), + } + } + + let status = created.restrict_self().map_err(|e| e.to_string())?; + if matches!(status.ruleset, RulesetStatus::NotEnforced) { + return Err("kernel did not enforce Landlock (need Linux >= 5.13)".to_string()); + } + Ok(()) +} + +fn apply_seccomp(cfg: &Config) -> Result<(), String> { + let mut rules: BTreeMap> = BTreeMap::new(); + + if !cfg.allow_network { + // Deny socket() for AF_INET / AF_INET6 (arg 0) — blocks TCP, UDP (incl. DNS), + // and the IMDS endpoint. AF_UNIX and other local families still work. + let cond = |domain: i32| -> Result { + SeccompRule::new(vec![SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Eq, + domain as u64, + ) + .map_err(|e| e.to_string())?]) + .map_err(|e| e.to_string()) + }; + rules.insert( + libc::SYS_socket as i64, + vec![cond(libc::AF_INET)?, cond(libc::AF_INET6)?], + ); + } + + // Deny cross-process memory/ptrace (belt-and-suspenders to Landlock's own + // out-of-domain ptrace restriction). An empty rule vec = match unconditionally. + for nr in [ + libc::SYS_ptrace, + libc::SYS_process_vm_readv, + libc::SYS_process_vm_writev, + ] { + rules.insert(nr as i64, Vec::new()); + } + + let filter = SeccompFilter::new( + rules, + SeccompAction::Allow, // default: allow syscalls we didn't name + SeccompAction::Errno(libc::EPERM as u32), // named + matched: deny with EPERM + target_arch(), + ) + .map_err(|e| e.to_string())?; + + let program: BpfProgram = filter.try_into().map_err(|e: seccompiler::BackendError| e.to_string())?; + apply_filter(&program).map_err(|e| e.to_string()) +} + +fn target_arch() -> TargetArch { + #[cfg(target_arch = "x86_64")] + { + TargetArch::x86_64 + } + #[cfg(target_arch = "aarch64")] + { + TargetArch::aarch64 + } + #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))] + { + compile_error!("run-confined supports only x86_64 and aarch64") + } +} diff --git a/scripts/Dockerfile b/scripts/Dockerfile index bf94e041..57d98ca6 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -59,6 +59,16 @@ RUN set -eux; \ ( cd "$target" && /venv/bin/sphinx-build -M singlehtml . tmp ); \ cp "$target/tmp/singlehtml/index.html" /out/cvl.html +# ---- Stage 1b: build the `run-confined` command-sandbox launcher ---- +# The `launcher` sandbox provider confines every RunCommand via this small Rust binary +# (Landlock + seccomp; docs/command-sandbox.md). It must be on PATH in the runtime image +# or the provider fail-closes. Built in a throwaway Rust stage; only the binary is copied out. +FROM --platform=linux/amd64 rust:1-slim AS sandbox-builder +WORKDIR /build +COPY rust/ ./rust/ +# Only run-confined (+ its landlock/seccompiler/libc deps) — not the pyo3 members. +RUN cd rust && cargo build -p run-confined --release + # ---- Stage 2: final runtime image ---- FROM --platform=linux/amd64 python:3.12-slim AS final @@ -85,6 +95,8 @@ RUN mkdir -p $HOME/.cache/huggingface \ # postgresql-client provides `psql` for the entrypoint's setup-db; git is # occasionally shelled out to by tooling. No openssh-client / SSH — the # Python install pulls from PyPI + the vendored graphcore submodule. +# (The `RunCommand` sandbox uses in-kernel Landlock + seccomp — no package +# needed; see docs/command-sandbox.md §6.) RUN apt-get update \ && apt-get install -y --no-install-recommends \ git ca-certificates \ @@ -149,6 +161,10 @@ RUN --mount=type=cache,id=uv-cache,target=/root/.cache/uv \ # Pre-built CVL HTML — the entrypoint's setup-db reads this to populate rag_db. COPY --from=docs-builder /out/cvl.html $AUTOPROVE_HOME/prover-docs/cvl.html +# The command-sandbox launcher, on PATH (/usr/local/bin is in PATH above). Any consumer +# that selects the `launcher` provider needs this present, or the provider fail-closes. +COPY --from=sandbox-builder /build/rust/target/release/run-confined /usr/local/bin/run-confined + # Pre-download the sentence-transformers embedding model so first runs are # offline-fast. nomic-embed-text-v1.5 ships custom modeling code, hence # trust_remote_code. Then make the cache world-writable so the HF library can diff --git a/tests/test_sandbox.py b/tests/test_sandbox.py new file mode 100644 index 00000000..f3618fa0 --- /dev/null +++ b/tests/test_sandbox.py @@ -0,0 +1,127 @@ +"""Unit tests for the command-sandbox provider seam (step 1). + +Pure and fast — no Rust wheel, no subprocess, no Postgres/LLM. They pin the +tool-agnostic contract (``docs/command-sandbox.md`` §4/§7) that every provider +must honor: the ``none`` passthrough is byte-for-byte today's behavior, the +registry resolves/rejects names, and the fail-closed check fires only when a +provider reports itself unavailable. +""" + +from pathlib import Path + +import pytest + +from composer.sandbox.policy import ( + Availability, + LaunchSpec, + NoneProvider, + SandboxPolicy, + SandboxProvider, + SandboxUnavailable, + ensure_available, + get_provider, + register_provider, +) + + +def test_policy_defaults_are_locked_down(): + """A default policy denies everything: no paths, empty env, network off, no caps.""" + p = SandboxPolicy() + assert p.rw_paths == () + assert p.ro_paths == () + assert dict(p.env_allowlist) == {} + assert p.network is False + assert p.mem_bytes is None and p.cpu_seconds is None + assert p.nproc is None and p.fsize_bytes is None + + +def test_policy_is_frozen(): + p = SandboxPolicy(rw_paths=(Path("/work"),)) + with pytest.raises((AttributeError, TypeError)): + p.network = True # type: ignore[misc] + + +def test_none_provider_is_a_passthrough(): + """``none`` execs the command verbatim and inherits the env (env is None).""" + spec = NoneProvider().wrap(SandboxPolicy(), "cargo", ["build", "--offline"]) + assert spec == LaunchSpec(argv=("cargo", "build", "--offline"), env=None) + + +def test_none_provider_ignores_policy(): + """Passthrough grants no isolation, so a rich policy must not alter its argv.""" + rich = SandboxPolicy( + rw_paths=(Path("/work"),), + ro_paths=(Path("/usr"),), + env_allowlist={"PATH": "/usr/bin"}, + network=True, + mem_bytes=1 << 32, + ) + spec = NoneProvider().wrap(rich, "echo", ["hi"]) + assert spec.argv == ("echo", "hi") + assert spec.env is None + + +def test_none_provider_available(): + assert NoneProvider().available() == Availability(ok=True) + + +def test_none_provider_satisfies_protocol(): + # runtime_checkable structural check: the concrete class implements the seam. + assert isinstance(NoneProvider(), SandboxProvider) + + +def test_get_provider_known(): + prov = get_provider("none") + assert isinstance(prov, NoneProvider) + assert prov.name == "none" + + +def test_get_provider_unknown_is_value_error(): + with pytest.raises(ValueError, match="unknown sandbox provider 'bogus'"): + get_provider("bogus") + + +def test_register_provider_roundtrip(): + """A newly registered factory becomes resolvable by name (how step 2 adds the + launcher without this module importing it).""" + + class _Fake: + name = "fake" + + def available(self) -> Availability: + return Availability(ok=True) + + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: + return LaunchSpec(argv=("fake", program, *args)) + + register_provider("fake", _Fake) + try: + assert isinstance(get_provider("fake"), _Fake) + finally: + # keep the module-level registry clean for other tests + from composer.sandbox import policy as _s + + _s._PROVIDERS.pop("fake", None) + + +def test_ensure_available_passes_for_ok_provider(): + ensure_available(NoneProvider()) # must not raise + + +def test_ensure_available_fails_closed(): + """An unavailable provider raises rather than letting the command run unconfined.""" + + class _Unavailable: + name = "landlock-missing" + + def available(self) -> Availability: + return Availability(ok=False, reason="kernel lacks Landlock (need Linux >= 5.13)") + + def wrap(self, policy: SandboxPolicy, program: str, args: list[str]) -> LaunchSpec: + raise AssertionError("wrap must not be reached when unavailable") + + with pytest.raises(SandboxUnavailable) as ei: + ensure_available(_Unavailable()) + assert ei.value.provider == "landlock-missing" + assert "Landlock" in ei.value.reason + assert "unavailable" in str(ei.value) diff --git a/tests/test_sandbox_command.py b/tests/test_sandbox_command.py new file mode 100644 index 00000000..62a6453f --- /dev/null +++ b/tests/test_sandbox_command.py @@ -0,0 +1,64 @@ +"""Unit tests for the shared local-command runner (``run_local_command``). + +Needs neither a Rust wheel nor Postgres/LLM: ``run_local_command`` shells out to +trivial system binaries. Covers file materialization, path confinement, and the +error/timeout paths. (It still backs the trusted Python build steps — e.g. the sBPF +build; the Rust backend's own toolchain runs now go through ``run-confined`` in the +wheel, see ``docs/rust-backend-api.md``.) +""" + +import pytest + +from composer.sandbox.command import ( + NOT_FOUND_EXIT, + UnsafePath, + run_local_command, +) + + +@pytest.mark.asyncio +async def test_run_local_command_materializes_files_and_captures_output(tmp_path): + res = await run_local_command( + "printf", ["%s", "hello"], {"note.txt": "hi", "sub/deep.txt": "deep"}, workdir=tmp_path + ) + assert res.exit_code == 0 + assert res.stdout == "hello" + # files (incl. a nested path) were materialized into the workdir. + assert (tmp_path / "note.txt").read_text() == "hi" + assert (tmp_path / "sub" / "deep.txt").read_text() == "deep" + + +@pytest.mark.asyncio +async def test_run_local_command_missing_binary(tmp_path): + res = await run_local_command("autoprover-no-such-binary-xyz", [], {}, workdir=tmp_path) + assert res.exit_code == NOT_FOUND_EXIT + assert "not found" in res.stderr + + +@pytest.mark.asyncio +async def test_run_local_command_nonzero_exit(tmp_path): + res = await run_local_command("false", [], {}, workdir=tmp_path) + assert res.exit_code != 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("bad", ["../evil.txt", "/etc/evil", "a/../../evil"]) +async def test_run_local_command_rejects_path_escape(tmp_path, bad): + with pytest.raises(UnsafePath): + await run_local_command("true", [], {bad: "x"}, workdir=tmp_path) + + +@pytest.mark.asyncio +async def test_run_local_command_no_shell_injection(tmp_path): + # Args are argv, never a shell string: a shell metacharacter is inert. `printf` + # emits it literally rather than a subshell running `id`. + res = await run_local_command("printf", ["%s", "$(id)"], {}, workdir=tmp_path) + assert res.exit_code == 0 + assert res.stdout == "$(id)" + + +@pytest.mark.asyncio +async def test_run_local_command_timeout(tmp_path): + res = await run_local_command("sleep", ["5"], {}, workdir=tmp_path, timeout_s=1) + assert res.exit_code == -1 + assert "timed out" in res.stderr diff --git a/tests/test_sandbox_config.py b/tests/test_sandbox_config.py new file mode 100644 index 00000000..da7e0c0c --- /dev/null +++ b/tests/test_sandbox_config.py @@ -0,0 +1,102 @@ +"""Unit tests for the sandbox config + the Rust-build policy recipe (step 3). + +Pure: no subprocess, no Rust binary. They pin provider selection (default ``none``, +``$COMPOSER_SANDBOX_PROVIDER`` override) and that the recipe grants the workdir +read-write, discoverable toolchain dirs read-only, and a scrubbed env with the +network off. +""" + +from pathlib import Path + +from composer.sandbox.config import SandboxConfig +from composer.sandbox.launcher import LauncherProvider +from composer.sandbox.policy import NoneProvider, SandboxPolicy +from composer.sandbox.recipes import rust_build_policy + + +def test_config_default_is_none_and_disabled(): + cfg = SandboxConfig() + assert cfg.provider == "none" + assert cfg.enabled is False + assert isinstance(cfg.resolve_provider(), NoneProvider) + + +def test_config_from_env_default(monkeypatch): + monkeypatch.delenv("COMPOSER_SANDBOX_PROVIDER", raising=False) + assert SandboxConfig.from_env().provider == "none" + + +def test_config_from_env_launcher(monkeypatch): + monkeypatch.setenv("COMPOSER_SANDBOX_PROVIDER", "launcher") + cfg = SandboxConfig.from_env(extra_ro=(Path("/usr"),)) + assert cfg.provider == "launcher" + assert cfg.enabled is True + assert isinstance(cfg.resolve_provider(), LauncherProvider) + assert cfg.extra_ro == (Path("/usr"),) + + +def test_config_none_build_policy_is_empty(): + """The none provider ignores the policy, so build_policy returns a bare one.""" + pol = SandboxConfig().build_policy("/work") + assert pol == SandboxPolicy() + + +def test_config_enabled_build_policy_grants_workdir(tmp_path): + cfg = SandboxConfig(provider="launcher", mem_bytes=1 << 30) + pol = cfg.build_policy(tmp_path) + assert tmp_path in pol.rw_paths + assert pol.network is False + assert pol.mem_bytes == (1 << 30) + + +def test_rust_build_policy_shape(tmp_path, monkeypatch): + monkeypatch.setenv("PATH", "/usr/bin:/bin") + monkeypatch.setenv("MY_SECRET", "do-not-pass") + extra_ro_dir = tmp_path / "toolchain" + extra_ro_dir.mkdir() + extra_rw_dir = tmp_path / "scratch" + extra_rw_dir.mkdir() + + pol = rust_build_policy( + tmp_path, + extra_ro=(extra_ro_dir, tmp_path / "does-not-exist"), + extra_rw=(extra_rw_dir,), + cpu_seconds=900, + ) + + # workdir + existing extra_rw are writable + assert tmp_path in pol.rw_paths + assert extra_rw_dir in pol.rw_paths + # existing extra_ro granted; non-existent dropped + assert extra_ro_dir in pol.ro_paths + assert (tmp_path / "does-not-exist") not in pol.ro_paths + # env: only allowlisted names pass through; secrets do not + assert pol.env_allowlist.get("PATH") == "/usr/bin:/bin" + assert "MY_SECRET" not in pol.env_allowlist + # network off, caps threaded + assert pol.network is False + assert pol.cpu_seconds == 900 + + +def test_rust_build_policy_offline_sets_cargo_net_offline(tmp_path): + """Default (offline) forces every cargo — incl. the one `crucible run` spawns — + offline via CARGO_NET_OFFLINE; opting out drops it.""" + on = rust_build_policy(tmp_path) + assert on.env_allowlist.get("CARGO_NET_OFFLINE") == "1" + off = rust_build_policy(tmp_path, offline=False) + assert "CARGO_NET_OFFLINE" not in off.env_allowlist + + +def test_config_enabled_policy_is_offline_by_default(tmp_path): + pol = SandboxConfig(provider="launcher").build_policy(tmp_path) + assert pol.env_allowlist.get("CARGO_NET_OFFLINE") == "1" + pol_net = SandboxConfig(provider="launcher", offline=False).build_policy(tmp_path) + assert "CARGO_NET_OFFLINE" not in pol_net.env_allowlist + + +def test_rust_build_policy_includes_system_and_dev_when_present(): + pol = rust_build_policy("/tmp") + if Path("/usr").exists(): + assert Path("/usr") in pol.ro_paths + if Path("/dev/null").exists(): + assert Path("/dev/null") in pol.rw_paths diff --git a/tests/test_sandbox_escape.py b/tests/test_sandbox_escape.py new file mode 100644 index 00000000..93752b4f --- /dev/null +++ b/tests/test_sandbox_escape.py @@ -0,0 +1,143 @@ +"""The escape suite — Part A of the Phase-6 gate (docs/command-sandbox.md §10). + +A *malicious* program (standing in for a harness `setup()` / a program's `build.rs`) +is compiled with `rustc`, then run through the **real** `run-confined` launcher via +`run_local_command` under a Crucible-representative policy (`rust_build_policy`). It +attempts every escape and writes each result into the workdir (allowed); the test +reads them back and asserts *denied* for all. A no-sandbox control runs the same +binary unconfined and confirms the leaks would otherwise happen — proving it is the +sandbox doing the blocking. + +Runnable without the full Crucible stack (std-only program, no crates, no network +needed to compile). Skipped unless `rustc` and a working launcher are present. The +*legitimate* half (a real `solana_vault` build+fuzz under the launcher) is the +expensive Part B in `tests/test_crucible_sandbox_gate.py`. +""" + +import asyncio +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +from composer.sandbox.command import run_local_command +from composer.sandbox.launcher import LauncherProvider +from composer.sandbox.recipes import rust_build_policy + +pytestmark = pytest.mark.asyncio + +_PROVIDER = LauncherProvider() +_needs = pytest.mark.skipif( + shutil.which("rustc") is None or not _PROVIDER.available().ok, + reason="needs rustc + a working run-confined launcher (Linux/Landlock)", +) + +_ENV_CANARY = "ENVCANARY-a1b2c3" +_HOSTFILE_CANARY = "HOSTFILECANARY-d4e5f6" + +# Standing in for hostile code in setup()/build.rs. std-only so it compiles offline. +_MALICIOUS_RS = """ +use std::fs; +use std::net::{SocketAddr, TcpStream}; +use std::time::Duration; + +fn probe(name: &str, result: &str) { + let _ = fs::write(format!("probe_{}.txt", name), result); +} + +fn net(addr: &str) -> String { + let sa: SocketAddr = addr.parse().unwrap(); + match TcpStream::connect_timeout(&sa, Duration::from_secs(2)) { + Ok(_) => "LEAK:connected".to_string(), + Err(_) => "denied".to_string(), + } +} + +fn main() { + let args: Vec = std::env::args().collect(); + let outside = args.get(1).cloned().unwrap_or_default(); + let parent_pid = args.get(2).cloned().unwrap_or_default(); + + probe("env", &match std::env::var("ANTHROPIC_API_KEY") { + Ok(v) => format!("LEAK:{}", v), + Err(_) => "denied".to_string(), + }); + + probe("procenv", &match fs::read_to_string(format!("/proc/{}/environ", parent_pid)) { + Ok(s) if s.contains("ENVCANARY") => "LEAK:found-canary".to_string(), + Ok(_) => "LEAK:proc-readable".to_string(), + Err(_) => "denied".to_string(), + }); + + probe("hostfile", &match fs::read_to_string(&outside) { + Ok(s) => format!("LEAK:{}", s.trim()), + Err(_) => "denied".to_string(), + }); + + probe("net_ext", &net("1.1.1.1:80")); + probe("imds", &net("169.254.169.254:80")); +} +""" + + +def _compile(tmp_path: Path, workdir: Path) -> None: + src = tmp_path / "malicious.rs" + src.write_text(_MALICIOUS_RS) + # Compiled UNSANDBOXED (we're testing runtime confinement, not the build here). + subprocess.run( + ["rustc", "-O", str(src), "-o", str(workdir / "malicious")], + check=True, + capture_output=True, + ) + + +@pytest.fixture +def scenario(tmp_path, monkeypatch): + workdir = tmp_path / "work" + workdir.mkdir() + _compile(tmp_path, workdir) + outside = tmp_path / "host_secret.txt" # OUTSIDE the granted workdir + outside.write_text(_HOSTFILE_CANARY) + # Plant the secret in *this* process's env; run-confined must scrub it, and the + # /proc//environ read (ppid = this pytest process) must be denied. + monkeypatch.setenv("ANTHROPIC_API_KEY", _ENV_CANARY) + return workdir, outside + + +@_needs +async def test_all_escapes_denied(scenario): + workdir, outside = scenario + policy = rust_build_policy(workdir) # grants workdir + toolchains; NOT /proc, NOT `outside` + res = await run_local_command( + "./malicious", [str(outside), str(os.getpid())], {}, + workdir=workdir, provider=_PROVIDER, policy=policy, + ) + assert res.exit_code == 0, res.stderr + + def probe(name: str) -> str: + return (workdir / f"probe_{name}.txt").read_text().strip() + + # every vector denied — and specifically no canary leaked + assert probe("env") == "denied" + assert probe("procenv") == "denied" + assert probe("hostfile") == "denied" + assert probe("net_ext") == "denied" + assert probe("imds") == "denied" + for name in ("env", "procenv", "hostfile", "net_ext", "imds"): + assert "LEAK" not in probe(name) + + +@_needs +async def test_control_unconfined_would_leak(scenario): + """Without the sandbox the same binary reads the secret env + the host file — + confirming the assertions above are enforced by the sandbox, not by accident.""" + workdir, outside = scenario + res = await run_local_command( + "./malicious", [str(outside), str(os.getpid())], {}, + workdir=workdir, # provider=None → unconfined passthrough + ) + assert res.exit_code == 0, res.stderr + assert (workdir / "probe_env.txt").read_text().strip() == f"LEAK:{_ENV_CANARY}" + assert _HOSTFILE_CANARY in (workdir / "probe_hostfile.txt").read_text() diff --git a/tests/test_sandbox_launcher.py b/tests/test_sandbox_launcher.py new file mode 100644 index 00000000..a1e36418 --- /dev/null +++ b/tests/test_sandbox_launcher.py @@ -0,0 +1,106 @@ +"""Tests for the ``run-confined`` launcher provider (Phase 6 step 2). + +The ``wrap`` tests are pure argv construction (no binary, no subprocess) and pin +the exact flag mapping. The ``available`` / ``--probe`` tests exercise the real +binary when it has been built (``cargo build -p run-confined --release``) and skip +otherwise, so the suite stays green on a machine without the Rust build. +""" + +from pathlib import Path + +import pytest + +from composer.sandbox.launcher import LauncherProvider, _resolve_binary +from composer.sandbox.policy import Availability, LaunchSpec, SandboxPolicy, get_provider + +_FAKE_BIN = "/opt/run-confined" + + +def _provider() -> LauncherProvider: + return LauncherProvider(binary=_FAKE_BIN) + + +def test_wrap_minimal_policy(): + """A workdir-only policy maps to the workdir grant + the command after `--`.""" + policy = SandboxPolicy(rw_paths=(Path("/work"),)) + spec = _provider().wrap(policy, "cargo", ["build", "--offline"]) + assert spec == LaunchSpec( + argv=(_FAKE_BIN, "--rw", "/work", "--", "cargo", "build", "--offline"), + env=None, + ) + + +def test_wrap_full_policy_flag_order(): + """ro before rw, then env, network, then rlimits, then `-- program args`.""" + policy = SandboxPolicy( + rw_paths=(Path("/work"), Path("/dev")), + ro_paths=(Path("/usr"), Path("/lib")), + env_allowlist={"PATH": "/usr/bin", "HOME": "/work"}, + network=False, + mem_bytes=4 << 30, + cpu_seconds=900, + nproc=512, + fsize_bytes=1 << 30, + ) + spec = _provider().wrap(policy, "crucible", ["run", "vault", "c_deposit"]) + assert spec.argv == ( + _FAKE_BIN, + "--ro", "/usr", + "--ro", "/lib", + "--rw", "/work", + "--rw", "/dev", + "--allow-env", "PATH=/usr/bin", + "--allow-env", "HOME=/work", + "--rlimit-as", str(4 << 30), + "--rlimit-cpu", "900", + "--rlimit-nproc", "512", + "--rlimit-fsize", str(1 << 30), + "--", "crucible", "run", "vault", "c_deposit", + ) + assert spec.env is None + + +def test_wrap_network_flag(): + policy = SandboxPolicy(rw_paths=(Path("/work"),), network=True) + spec = _provider().wrap(policy, "echo", []) + assert "--allow-network" in spec.argv + # no rlimit flags when caps are unset + assert not any(a.startswith("--rlimit") for a in spec.argv) + + +def test_wrap_uses_binary_name_when_unresolved(): + """With no binary resolved, argv[0] falls back to the bare name (kept runnable + if it is later placed on PATH); wrap never crashes on a missing binary.""" + prov = LauncherProvider(binary=None) + prov._binary = None # force the unresolved case regardless of the dev tree + spec = prov.wrap(SandboxPolicy(rw_paths=(Path("/w"),)), "true", []) + assert spec.argv[0] == "run-confined" + + +def test_available_reports_missing_binary(): + prov = LauncherProvider(binary=None) + prov._binary = None + avail = prov.available() + assert avail.ok is False + assert "run-confined" in avail.reason + + +def test_launcher_registered_in_seam(): + """Importing this module registered the provider under its name.""" + prov = get_provider("launcher") + assert isinstance(prov, LauncherProvider) + assert prov.name == "launcher" + + +# --- tests that need the actual built binary (skip if unbuilt) --- + +_REAL_BIN = _resolve_binary() +_needs_bin = pytest.mark.skipif(_REAL_BIN is None, reason="run-confined not built") + + +@_needs_bin +def test_probe_reports_available_on_this_host(): + """On a Landlock-capable host the real binary's --probe → available().""" + avail = LauncherProvider().available() + assert isinstance(avail, Availability) + assert avail.ok is True, avail.reason diff --git a/tests/test_sandbox_run_confined.py b/tests/test_sandbox_run_confined.py new file mode 100644 index 00000000..f9d1ba05 --- /dev/null +++ b/tests/test_sandbox_run_confined.py @@ -0,0 +1,105 @@ +"""Integration test: `run_local_command` actually confines via the launcher provider. + +This proves the *wiring* (step 3) end-to-end — the runner routes a command through +a real `SandboxProvider` and the confinement takes effect — as opposed to the pure +argv/golden tests elsewhere. Skipped unless the `run-confined` binary is built and +the kernel supports Landlock (so CI without the Rust build stays green); the full +escape gate on the real Crucible build is step 5. +""" + +import os +from pathlib import Path + +import pytest + +from composer.sandbox.command import run_local_command +from composer.sandbox.launcher import LauncherProvider +from composer.sandbox.policy import SandboxPolicy, SandboxUnavailable + +pytestmark = pytest.mark.asyncio + +_PROVIDER = LauncherProvider() +_needs_sandbox = pytest.mark.skipif( + not _PROVIDER.available().ok, reason="run-confined unbuilt or kernel lacks Landlock" +) + + +def _system_policy(workdir: Path) -> SandboxPolicy: + """A minimal policy: workdir + the dev nodes rw, the system dirs ro. Deliberately + does NOT grant /etc, so reading a host file outside the workdir is denied.""" + ro = tuple(p for p in (Path("/usr"), Path("/lib"), Path("/lib64"), Path("/bin")) if p.exists()) + rw = (workdir, *(Path(d) for d in ("/dev/null", "/dev/urandom") if Path(d).exists())) + return SandboxPolicy(rw_paths=rw, ro_paths=ro, env_allowlist={"PATH": os.environ.get("PATH", "/usr/bin:/bin")}) + + +@_needs_sandbox +async def test_confined_command_can_write_workdir(tmp_path): + res = await run_local_command( + "bash", ["-c", "echo hi > w.txt"], {}, workdir=tmp_path, + provider=_PROVIDER, policy=_system_policy(tmp_path), + ) + assert res.exit_code == 0, res.stderr + assert (tmp_path / "w.txt").read_text().strip() == "hi" + + +@_needs_sandbox +async def test_confined_command_cannot_read_outside_workdir(tmp_path): + outside = tmp_path.parent / f"secret-{tmp_path.name}.txt" + outside.write_text("TOPSECRET") + try: + res = await run_local_command( + "bash", ["-c", f"cat {outside} && echo LEAK || echo denied"], {}, workdir=tmp_path, + provider=_PROVIDER, policy=_system_policy(tmp_path), + ) + finally: + outside.unlink(missing_ok=True) + assert "TOPSECRET" not in res.stdout + assert "LEAK" not in res.stdout + assert "denied" in res.stdout + + +@_needs_sandbox +async def test_confined_command_has_no_network(tmp_path): + res = await run_local_command( + "python3", + ["-c", "import socket; socket.socket(socket.AF_INET, socket.SOCK_STREAM); print('LEAK')"], + {}, workdir=tmp_path, provider=_PROVIDER, policy=_system_policy(tmp_path), + ) + assert res.exit_code != 0 + assert "LEAK" not in res.stdout + + +@_needs_sandbox +async def test_none_provider_is_not_confined(tmp_path): + """Control: without a provider the same outside-read succeeds — proving it is the + sandbox, not something else, doing the blocking above.""" + outside = tmp_path.parent / f"plain-{tmp_path.name}.txt" + outside.write_text("readable") + try: + res = await run_local_command( + "bash", ["-c", f"cat {outside}"], {}, workdir=tmp_path, # provider=None (passthrough) + ) + finally: + outside.unlink(missing_ok=True) + assert res.exit_code == 0 + assert "readable" in res.stdout + + +async def test_unavailable_provider_fails_closed(tmp_path): + """A provider that reports unavailable must raise, never run unconfined.""" + + class _Unavailable: + name = "x" + + def available(self): + from composer.sandbox.policy import Availability + + return Availability(ok=False, reason="nope") + + def wrap(self, policy, program, args): # pragma: no cover - must not be called + raise AssertionError("wrap reached despite unavailable") + + with pytest.raises(SandboxUnavailable): + await run_local_command( + "true", [], {}, workdir=tmp_path, provider=_Unavailable(), policy=SandboxPolicy() + ) From ab320154da69f9d2938848555c22fe2946f8c43a Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Tue, 14 Jul 2026 16:02:18 -0700 Subject: [PATCH 02/12] run-confined: probe Landlock via the crate's public API Replace the raw landlock_create_ruleset syscall in --probe with the same BestEffort ruleset negotiation apply_landlock uses, inspecting RulesetStatus. Drops the unsafe block and the hand-rolled LANDLOCK_CREATE_RULESET_VERSION constant; the crate deliberately hides the numeric ABI, so probe now reports the enforcement status instead. Python's available() only checks the exit code and stderr, so the stdout change is safe. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/run-confined/src/main.rs | 45 ++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/rust/run-confined/src/main.rs b/rust/run-confined/src/main.rs index bb296137..b4a169e4 100644 --- a/rust/run-confined/src/main.rs +++ b/rust/run-confined/src/main.rs @@ -40,10 +40,6 @@ const EXIT_SANDBOX_UNAVAILABLE: i32 = 3; /// The confined `execve` itself failed (e.g. program not found on PATH). const EXIT_EXEC_FAILED: i32 = 127; -/// `landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION)` returns the -/// kernel's supported ABI version, or fails if Landlock is unavailable. -const LANDLOCK_CREATE_RULESET_VERSION: libc::c_ulong = 1; - #[derive(Default)] struct Config { rw_paths: Vec, @@ -90,26 +86,31 @@ fn main() { die(EXIT_EXEC_FAILED, &format!("exec {:?} failed: {err}", cfg.program)); } -/// `--probe`: report whether the kernel supports Landlock. Exit 0 + print the ABI -/// if so; exit `EXIT_SANDBOX_UNAVAILABLE` otherwise. Drives Python's fail-closed -/// `available()` check without restricting this (throwaway) process. +/// `--probe`: report whether the kernel supports Landlock. Exit 0 + print the +/// enforcement status if so; exit `EXIT_SANDBOX_UNAVAILABLE` otherwise. Drives +/// Python's fail-closed `available()` check. +/// +/// We probe through the crate's public API rather than the raw +/// `landlock_create_ruleset` syscall — the crate deliberately hides the numeric +/// ABI, and this reuses the exact BestEffort negotiation `apply_landlock` does. +/// It restricts *this* process as a side effect, which is harmless: `--probe` is +/// a throwaway process that exits immediately after reporting. fn probe() -> ! { - let abi = unsafe { - libc::syscall( - libc::SYS_landlock_create_ruleset, - std::ptr::null::(), - 0usize, - LANDLOCK_CREATE_RULESET_VERSION, - ) - }; - if abi > 0 { - println!("landlock abi {abi}"); - std::process::exit(0); + let status = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(AccessFs::from_all(ABI::V5)) + .and_then(|r| r.create()) + .and_then(|r| r.restrict_self()); + match status { + Ok(s) if !matches!(s.ruleset, RulesetStatus::NotEnforced) => { + println!("landlock {:?}", s.ruleset); + std::process::exit(0); + } + _ => die( + EXIT_SANDBOX_UNAVAILABLE, + "kernel does not support Landlock (need Linux >= 5.13); refusing to run unconfined", + ), } - die( - EXIT_SANDBOX_UNAVAILABLE, - "kernel does not support Landlock (need Linux >= 5.13); refusing to run unconfined", - ); } fn parse(argv: &[String]) -> Result { From d79d216c7a9eb630b8dcda7904c31520818adc4b Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Tue, 14 Jul 2026 16:12:20 -0700 Subject: [PATCH 03/12] docs/command-sandbox: correct what --probe reports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The launcher's --probe no longer reports the numeric Landlock ABI (the crate hides it); it builds a best-effort ruleset and reports whether Landlock actually enforces. Update §7 and §9 step 2 to match, following the switch of probe() to the crate's public API. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/command-sandbox.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/command-sandbox.md b/docs/command-sandbox.md index 7376a38a..c33d8a1c 100644 --- a/docs/command-sandbox.md +++ b/docs/command-sandbox.md @@ -337,7 +337,7 @@ are unchanged). `RealEffects` builds the policy from a host-resolved config (too discovered like `resolve_crucible_repo` already does), and `build_program` uses the same. **Fail-closed.** Before running under a real sandbox provider, `provider.available()` is checked -(for the launcher: kernel Landlock ABI present). If it isn't — or the provider cannot apply its +(for the launcher: Landlock is present *and* actually enforcing). If it isn't — or the provider cannot apply its confinement — the command **refuses to run** rather than silently executing unconfined. The failure is a **prominent, actionable message** naming the reason ("the command sandbox requires a Landlock-capable kernel (Linux ≥5.13); this backend cannot run without it — see @@ -378,8 +378,9 @@ the sandbox is unavailable: refuse to run, loudly, rather than run untrusted nat Landlock ruleset (best-effort ABI negotiation, full FS bit set, deny-by-default + §3 grants) via the [`landlock`](https://crates.io/crates/landlock) crate, builds the seccomp filter (deny inet sockets + ptrace/process_vm_*) via [`seccompiler`](https://crates.io/crates/seccompiler), applies - both, then `execve`s the command with an env scrubbed to the allowlist. `--probe` reports the - kernel Landlock ABI and drives `available()` → fail-closed (§7). Enforcement smoke-tested on the + both, then `execve`s the command with an env scrubbed to the allowlist. `--probe` builds a + best-effort ruleset and reports whether Landlock actually *enforces* (not the numeric ABI, which + the crate hides), driving `available()` → fail-closed (§7). Enforcement smoke-tested on the host (write-outside / `/etc/passwd` / `/proc//environ` / inet-socket all denied; workdir write, AF_UNIX, and toolchain `exec` allowed); argv mapping golden-tested. Full escape gate is step 5. From eb76f6c91e842d8d3be8977659c44f4cd83775b6 Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 20 Jul 2026 13:51:42 -0700 Subject: [PATCH 04/12] sandbox: close io_uring network bypass and other boundary gaps Deny io_uring and non-AF_UNIX sockets in seccomp, install Landlock scopes (signals/abstract UDS) and TCP default-deny, grant only shared cargo bin/ (not credentials.toml), and extend the escape suite. --- composer/sandbox/recipes.py | 40 ++++-- docs/command-sandbox.md | 89 ++++++++---- rust/run-confined/src/main.rs | 91 ++++++++---- tests/test_sandbox_config.py | 29 +++- tests/test_sandbox_escape.py | 223 ++++++++++++++++++++++++++--- tests/test_sandbox_run_confined.py | 53 +++++++ 6 files changed, 441 insertions(+), 84 deletions(-) diff --git a/composer/sandbox/recipes.py b/composer/sandbox/recipes.py index edb78701..461cdb84 100644 --- a/composer/sandbox/recipes.py +++ b/composer/sandbox/recipes.py @@ -76,6 +76,27 @@ def sandbox_cargo_home(workdir: str | Path) -> Path: return Path(workdir).resolve() / ".sandbox_cargo" +def shared_cargo_ro_paths(cargo_home: str | Path) -> tuple[Path, ...]: + """RO subtrees of the *shared* cargo home that sandboxed builds may need. + + Never grants the cargo-home **root**: that directory often holds + ``credentials.toml`` / ``credentials`` (crates.io and private-registry tokens). + Landlock PathBeneath is hierarchical, so granting the root would leak those. + + Today only ``bin/`` is granted (the ``cargo`` / ``cargo-*`` shims on ``PATH``). + Offline deps live in the private per-run :func:`sandbox_cargo_home`, so the + shared ``registry/`` and ``git/`` trees are not required. A future shared + read-only cache optimization can add specific cache subtrees here without + re-opening the credentials file. + """ + root = Path(cargo_home) + out: list[Path] = [] + bin_dir = root / "bin" + if bin_dir.is_dir(): + out.append(bin_dir) + return tuple(out) + + def rust_build_policy( workdir: str | Path, *, @@ -91,14 +112,15 @@ def rust_build_policy( """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. + toolchain (``RUSTUP_HOME``), the shared cargo **bin/** only (not the cargo-home + root — see :func:`shared_cargo_ro_paths`), 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). + so the deps must already be warm in the private ``CARGO_HOME`` (see + :func:`warm_cargo_cache`, run *outside* the sandbox first). """ home = Path.home() rustup = Path(os.environ.get("RUSTUP_HOME", home / ".rustup")) @@ -107,7 +129,8 @@ def rust_build_policy( ro_candidates: list[Path] = [Path(p) for p in _SYSTEM_RO] ro_candidates += [ rustup, - cargo, + # Shared cargo: bin/ only — never the home root (credentials.toml). + *shared_cargo_ro_paths(cargo), # cargo-build-sbf's downloaded sBPF platform-tools (layout varies by version). home / ".cache" / "solana", home / ".local" / "share" / "solana", @@ -134,9 +157,10 @@ def rust_build_policy( 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. + # sandbox_cargo_home for the reasoning). The shared ~/.cargo root is *not* + # granted RO; only bin/ is (above). Copy the user's global cargo config into + # the private home so registry mirrors / build settings still apply — that + # copy is trusted-host code, not a Landlock grant of the secrets file. 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")) diff --git a/docs/command-sandbox.md b/docs/command-sandbox.md index c33d8a1c..7bc1f848 100644 --- a/docs/command-sandbox.md +++ b/docs/command-sandbox.md @@ -80,8 +80,10 @@ their real needs: 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). +- **Cargo home** — shared `CARGO_HOME` (default `~/.cargo`): only **`bin/`** is granted read+exec + (the `cargo` / `cargo-*` shims on `PATH`). The home **root is not granted**, so + `credentials.toml` / private-registry tokens stay unreadable. Offline registry contents live in + the private per-run `CARGO_HOME` under the workdir (§11 item 5), 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. @@ -219,23 +221,31 @@ 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) | +| filesystem — read host file outside grants | ✗ `EACCES` | Landlock (no grant); note `/etc` *is* granted for NSS — escape gate uses a planted host file, not `/etc/passwd` | +| filesystem — cargo `credentials.toml` | ✗ `EACCES` | policy grants shared cargo **`bin/` only**, never the home root | | **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 | +| network — `socket(AF_INET)` / netlink / vsock | ✗ `EPERM` | seccomp: deny `socket` when domain **≠ `AF_UNIX`** | +| network — `io_uring_setup` (seccomp bypass) | ✗ `EPERM` | seccomp: deny `io_uring_{setup,enter,register}` | +| network — TCP via Landlock (defense-in-depth) | ✗ deny | Landlock net rules (ABI ≥4), no bind/connect grants | +| same-uid — `kill(parent)` / abstract UDS | ✗ `EPERM` | Landlock **scopes** `Signal` + `AbstractUnixSocket` (ABI ≥6 / Linux ≥6.12) | +| legitimate — write workdir, `exec` toolchain, `AF_UNIX` | ✓ works | Landlock rw grant + r+x on toolchain paths; `AF_UNIX` still allowed | - **[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. + namespace would otherwise have closed for free. On ABI ≥6 it also installs **scopes** (signals + + abstract Unix sockets). On ABI ≥4 with network off it default-denies Landlock TCP bind/connect + (defense-in-depth next to seccomp; UDP is still seccomp-only). - **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`). + `socket` for every domain **except `AF_UNIX`** (so TCP, UDP/DNS, IMDS, netlink, vsock, … are + blocked while cargo's jobserver still works), denies **`io_uring_*`** (the classic way to create + sockets without calling `socket(2)`), and denies the remaining same-uid secret vectors + (`ptrace`, `process_vm_readv`/`writev`). Still a **deny-list** on top of default-allow — not a + full syscall allowlist; residual risk is tracked in §11. - **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). @@ -248,10 +258,21 @@ the linker, the fuzz binary — runs confined. 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. +uid**, so out-of-band vectors must be closed *explicitly*: + +| Vector | Close | Floor | +|---|---|---| +| `/proc//environ` | Landlock: no `/proc` grant | 5.13 | +| `ptrace` / `process_vm_*` | seccomp deny | any seccomp | +| `kill` / signals to parent | Landlock scope `Signal` | **6.12** (ABI 6) | +| abstract Unix sockets to outside | Landlock scope `AbstractUnixSocket` | **6.12** (ABI 6) | +| path-based Unix sockets | Landlock FS (socket inode must be under a grant) | 5.13 | +| readable secrets under toolchain paths | policy: grant shared cargo **`bin/` only**, not `~/.cargo` root (`credentials.toml`) | policy | + +On kernels **below 6.12** the two scopes are BestEffort-dropped: signal and abstract-UDS remain a +**residual same-uid risk** (the child can still be killed by the wall-clock timeout; abstract +listeners are uncommon in the AutoProver container). Target AMI upgrades past 6.12 close them +fully; the escape suite asserts scopes only when the running kernel is ≥6.12. ### The launcher: a custom shim over audited crates (not hand-rolled primitives) @@ -375,15 +396,16 @@ the sandbox is unavailable: refuse to run, loudly, rather than run untrusted nat ([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. + Landlock ruleset (best-effort ABI negotiation, full FS bit set, deny-by-default + §3 grants, + scopes for signals/abstract UDS on ABI ≥6, TCP default-deny on ABI ≥4 when network is off) via + the [`landlock`](https://crates.io/crates/landlock) crate, builds the seccomp filter (deny + non-`AF_UNIX` sockets, `io_uring_*`, and 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 / planted host file / + `/proc//environ` / inet+io_uring+netlink sockets 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)) @@ -450,7 +472,12 @@ A new expensive gate (`tests/test_crucible_sandbox_gate.py`) with two halves: 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). + fail** (socket creation denied). Also: `io_uring_setup` (seccomp bypass), `socket(AF_NETLINK)`, + `socket(AF_VSOCK)` — all denied; `socket(AF_UNIX)` still allowed. +- **Same-uid control plane** (when kernel ≥6.12) — `kill(parent, 0)` and connect to an abstract + Unix socket owned outside the sandbox are denied (Landlock scopes). +- **Cargo credentials** — a planted `credentials.toml` under the shared cargo home is **not** + readable (policy grants `bin/` only). 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*. @@ -474,9 +501,11 @@ Only when both halves are green may the backend run on untrusted input (the §9 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). +2. **AF_UNIX-only socket allow (done for hostile domains).** seccomp now denies `socket` when + domain **≠ `AF_UNIX`** (so netlink/vsock/packet are closed too) and denies `io_uring_*`. Confirm + the toolchain (cargo jobserver, rustc, linker) never needs another domain; if a benign + `AF_NETLINK` use surfaces, decide whether to allow it narrowly. Full syscall **allowlist** + (default-deny) remains a possible hardening step if the deny-list residual risk is unacceptable. 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 @@ -486,11 +515,13 @@ Only when both halves are green may the backend run on untrusted input (the §9 *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 + unsandboxed) fetches *into that same home*, and the shared cargo home is granted **read-only on + `bin/` only** (`shared_cargo_ro_paths`) — never the home root, so `credentials.toml` cannot be + read by untrusted code. 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. + re-download is the deferred optimization (add specific cache subtrees to `shared_cargo_ro_paths`, + still not the home root). 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 diff --git a/rust/run-confined/src/main.rs b/rust/run-confined/src/main.rs index b4a169e4..5aebf3bf 100644 --- a/rust/run-confined/src/main.rs +++ b/rust/run-confined/src/main.rs @@ -6,8 +6,13 @@ //! 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`. +//! On kernels with ABI ≥6, also scopes signals and abstract Unix sockets so the +//! child cannot SIGKILL the parent or talk to abstract UDS outside the sandbox. +//! On kernels with ABI ≥4 and `--allow-network` off, also default-denies Landlock +//! TCP bind/connect (defense-in-depth; UDP still blocked by seccomp). +//! 2. **seccomp** — deny non-`AF_UNIX` `socket()` (blocks TCP, UDP/DNS, IMDS, netlink, +//! vsock, …), deny `io_uring_*` (blocks the classic seccomp network bypass), and +//! deny `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. @@ -25,8 +30,8 @@ use std::path::PathBuf; use std::process::Command; use landlock::{ - Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr, - RulesetCreatedAttr, RulesetStatus, ABI, + Access, AccessFs, AccessNet, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, + RulesetAttr, RulesetCreatedAttr, RulesetStatus, Scope, ABI, }; use seccompiler::{ apply_filter, BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, @@ -99,6 +104,7 @@ fn probe() -> ! { let status = Ruleset::default() .set_compatibility(CompatLevel::BestEffort) .handle_access(AccessFs::from_all(ABI::V5)) + .and_then(|r| r.scope(Scope::from_all(ABI::V6))) .and_then(|r| r.create()) .and_then(|r| r.restrict_self()); match status { @@ -184,28 +190,51 @@ fn set_rlimits(cfg: &Config) { 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) }; + // process; ensures no exec can regain privileges. Fail-closed if it cannot be set. + let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; + if rc != 0 { + die( + EXIT_SANDBOX_UNAVAILABLE, + &format!( + "PR_SET_NO_NEW_PRIVS failed: {}", + std::io::Error::last_os_error() + ), + ); + } } 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; + // + // FS rights: ABI V5 (covers up through IoctlDev; V6/V7 add no new FS bits). + // Scopes (Signal + AbstractUnixSocket): ABI V6 — BestEffort drops them on older + // kernels (residual same-uid risk documented in command-sandbox.md §6). + // Net TCP deny: ABI V4 — BestEffort; defense-in-depth next to seccomp. + let abi_fs = ABI::V5; - let mut created = Ruleset::default() + let mut ruleset = Ruleset::default() .set_compatibility(CompatLevel::BestEffort) - .handle_access(AccessFs::from_all(abi)) + .handle_access(AccessFs::from_all(abi_fs)) .map_err(|e| e.to_string())? - .create() + .scope(Scope::from_all(ABI::V6)) .map_err(|e| e.to_string())?; + if !cfg.allow_network { + // No TCP bind/connect rules → default-deny for Landlock net (when supported). + ruleset = ruleset + .handle_access(AccessNet::from_all(ABI::V4)) + .map_err(|e| e.to_string())?; + } + + let mut created = ruleset.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))) + .add_rule(PathBeneath::new(fd, AccessFs::from_read(abi_fs))) .map_err(|e| e.to_string())?; } Err(e) => eprintln!("run-confined: skipping missing --ro path {p:?}: {e}"), @@ -215,7 +244,7 @@ fn apply_landlock(cfg: &Config) -> Result<(), String> { match PathFd::new(p) { Ok(fd) => { created = created - .add_rule(PathBeneath::new(fd, AccessFs::from_all(abi))) + .add_rule(PathBeneath::new(fd, AccessFs::from_all(abi_fs))) .map_err(|e| e.to_string())?; } Err(e) => return Err(format!("required --rw path {p:?} is unopenable: {e}")), @@ -233,22 +262,28 @@ 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 socket() for every domain *except* AF_UNIX (cargo jobserver, etc.). + // Matching arg0 != AF_UNIX covers AF_INET/INET6 (TCP+UDP/DNS+IMDS), AF_NETLINK, + // AF_PACKET, AF_VSOCK, and any future family — not just the two inet domains. + let non_unix = SeccompRule::new(vec![SeccompCondition::new( + 0, + SeccompCmpArgLen::Dword, + SeccompCmpOp::Ne, + libc::AF_UNIX as u64, + ) + .map_err(|e| e.to_string())?]) + .map_err(|e| e.to_string())?; + rules.insert(libc::SYS_socket as i64, vec![non_unix]); + } + + // io_uring can create sockets and connect without calling socket(2), which is a + // well-known seccomp bypass. Offline builds do not need it — deny unconditionally. + for nr in [ + libc::SYS_io_uring_setup, + libc::SYS_io_uring_enter, + libc::SYS_io_uring_register, + ] { + rules.insert(nr as i64, Vec::new()); } // Deny cross-process memory/ptrace (belt-and-suspenders to Landlock's own diff --git a/tests/test_sandbox_config.py b/tests/test_sandbox_config.py index da7e0c0c..0e21cb26 100644 --- a/tests/test_sandbox_config.py +++ b/tests/test_sandbox_config.py @@ -11,7 +11,7 @@ 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 +from composer.sandbox.recipes import rust_build_policy, shared_cargo_ro_paths def test_config_default_is_none_and_disabled(): @@ -100,3 +100,30 @@ def test_rust_build_policy_includes_system_and_dev_when_present(): assert Path("/usr") in pol.ro_paths if Path("/dev/null").exists(): assert Path("/dev/null") in pol.rw_paths + + +def test_rust_build_policy_grants_cargo_bin_not_home_root(tmp_path, monkeypatch): + """Shared CARGO_HOME root must not be RO-granted (credentials.toml lives there).""" + cargo = tmp_path / "cargo_home" + (cargo / "bin").mkdir(parents=True) + (cargo / "credentials.toml").write_text('token = "secret"\n') + monkeypatch.setenv("CARGO_HOME", str(cargo)) + # Isolate RUSTUP_HOME so a real ~/.rustup does not pollute path assertions. + rustup = tmp_path / "rustup" + rustup.mkdir() + monkeypatch.setenv("RUSTUP_HOME", str(rustup)) + + pol = rust_build_policy(tmp_path / "work") + assert (cargo / "bin").resolve() in pol.ro_paths + assert cargo.resolve() not in pol.ro_paths + + +def test_shared_cargo_ro_paths_excludes_credentials(tmp_path): + """Unit-level: grant bin/ only, never the home root that holds credentials.""" + cargo = tmp_path / "cargo_home" + (cargo / "bin").mkdir(parents=True) + (cargo / "credentials.toml").write_text('token = "secret"\n') + (cargo / "registry").mkdir() + paths = shared_cargo_ro_paths(cargo) + assert paths == (cargo / "bin",) + assert cargo not in paths diff --git a/tests/test_sandbox_escape.py b/tests/test_sandbox_escape.py index 93752b4f..c10d4911 100644 --- a/tests/test_sandbox_escape.py +++ b/tests/test_sandbox_escape.py @@ -8,16 +8,24 @@ binary unconfined and confirms the leaks would otherwise happen — proving it is the sandbox doing the blocking. +Vectors covered: + env scrub, /proc//environ, host file outside workdir, TCP (socket), + IMDS, io_uring socket setup (seccomp bypass), AF_NETLINK/AF_VSOCK, signal to + parent (Landlock scope), abstract Unix socket to outside listener (scope), + cargo credentials.toml under the shared cargo home (narrow RO grant). + 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 re import shutil +import socket import subprocess +import threading from pathlib import Path import pytest @@ -26,6 +34,18 @@ from composer.sandbox.launcher import LauncherProvider from composer.sandbox.recipes import rust_build_policy + +def _kernel_at_least(major: int, minor: int) -> bool: + """Best-effort parse of ``uname -r`` (e.g. ``6.1.119-...``, ``7.0.11-...``).""" + m = re.match(r"(\d+)\.(\d+)", os.uname().release) + if not m: + return False + return (int(m.group(1)), int(m.group(2))) >= (major, minor) + + +# Landlock scopes (Signal + AbstractUnixSocket) need ABI v6 ≈ Linux 6.12. +_SCOPES_AVAILABLE = _kernel_at_least(6, 12) + pytestmark = pytest.mark.asyncio _PROVIDER = LauncherProvider() @@ -36,13 +56,35 @@ _ENV_CANARY = "ENVCANARY-a1b2c3" _HOSTFILE_CANARY = "HOSTFILECANARY-d4e5f6" +_CREDS_CANARY = "crates-io-TOKEN-CANARY-xyz" +_ABSTRACT_PAYLOAD = b"ABSTRACT-UNIX-LEAK-OK" # Standing in for hostile code in setup()/build.rs. std-only so it compiles offline. -_MALICIOUS_RS = """ +# Uses libc via extern for io_uring / multi-domain socket probes. +_MALICIOUS_RS = r""" use std::fs; +use std::io::Read; use std::net::{SocketAddr, TcpStream}; +use std::os::linux::net::SocketAddrExt; +use std::os::unix::net::{SocketAddr as UnixSocketAddr, UnixStream}; use std::time::Duration; +extern "C" { + fn socket(domain: i32, typ: i32, protocol: i32) -> i32; + fn close(fd: i32) -> i32; + fn syscall(n: i64, ...) -> i64; + fn kill(pid: i32, sig: i32) -> i32; +} + +const AF_UNIX: i32 = 1; +const AF_INET: i32 = 2; +const AF_NETLINK: i32 = 16; +const AF_VSOCK: i32 = 40; +const SOCK_STREAM: i32 = 1; +const SOCK_RAW: i32 = 3; +// io_uring_setup is 425 on both x86_64 and aarch64. +const SYS_IO_URING_SETUP: i64 = 425; + fn probe(name: &str, result: &str) { let _ = fs::write(format!("probe_{}.txt", name), result); } @@ -55,10 +97,23 @@ } } +fn sock_domain(domain: i32, typ: i32) -> String { + unsafe { + let fd = socket(domain, typ, 0); + if fd >= 0 { + close(fd); + "LEAK:socket-ok".to_string() + } else { + "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(); + let parent_pid: i32 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(0); + let creds_path = args.get(3).cloned().unwrap_or_default(); probe("env", &match std::env::var("ANTHROPIC_API_KEY") { Ok(v) => format!("LEAK:{}", v), @@ -78,6 +133,59 @@ probe("net_ext", &net("1.1.1.1:80")); probe("imds", &net("169.254.169.254:80")); + + // Classic seccomp bypass: create sockets via io_uring without socket(2). + probe("iouring", &{ + let mut params = [0u8; 256]; + let fd = unsafe { syscall(SYS_IO_URING_SETUP, 8i32, params.as_mut_ptr()) }; + if fd >= 0 { + unsafe { close(fd as i32) }; + "LEAK:io-uring-setup-ok".to_string() + } else { + "denied".to_string() + } + }); + + probe("netlink", &sock_domain(AF_NETLINK, SOCK_RAW)); + probe("vsock", &sock_domain(AF_VSOCK, SOCK_STREAM)); + // AF_UNIX must still work (cargo jobserver); record that separately for the control. + probe("unix_sock", &sock_domain(AF_UNIX, SOCK_STREAM)); + probe("inet_sock", &sock_domain(AF_INET, SOCK_STREAM)); + + // Same-uid signal: kill(pid, 0) checks permission without delivering a fatal signal. + probe("signal", &{ + let rc = unsafe { kill(parent_pid, 0) }; + if rc == 0 { + "LEAK:signal-ok".to_string() + } else { + "denied".to_string() + } + }); + + // Abstract-namespace Unix socket — not covered by Landlock FS rules (needs scope). + // Name is arg 4 without a leading NUL; use from_abstract_name (path connect truncates at \\0). + let abstract_name = args.get(4).cloned().unwrap_or_default(); + probe("abstract_unix", &{ + match UnixSocketAddr::from_abstract_name(abstract_name.as_bytes()) + .and_then(|addr| UnixStream::connect_addr(&addr)) + { + Ok(mut s) => { + let mut buf = [0u8; 64]; + match s.read(&mut buf) { + Ok(n) if n > 0 => format!("LEAK:{}", String::from_utf8_lossy(&buf[..n])), + Ok(_) => "LEAK:connected-empty".to_string(), + Err(_) => "LEAK:connected".to_string(), + } + } + Err(_) => "denied".to_string(), + } + }); + + probe("cargo_creds", &match fs::read_to_string(&creds_path) { + Ok(s) if s.contains("TOKEN-CANARY") => "LEAK:creds".to_string(), + Ok(_) => "LEAK:creds-readable".to_string(), + Err(_) => "denied".to_string(), + }); } """ @@ -100,44 +208,123 @@ def scenario(tmp_path, monkeypatch): _compile(tmp_path, workdir) outside = tmp_path / "host_secret.txt" # OUTSIDE the granted workdir outside.write_text(_HOSTFILE_CANARY) + + # Shared cargo home with credentials at the root + a bin/ shim dir. Policy must + # grant bin/ only — never the root (so credentials.toml stays unreadable). + fake_cargo = tmp_path / "fake_cargo" + (fake_cargo / "bin").mkdir(parents=True) + creds = fake_cargo / "credentials.toml" + creds.write_text(f'token = "{_CREDS_CANARY}"\n') + monkeypatch.setenv("CARGO_HOME", str(fake_cargo)) + # 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 + return workdir, outside, creds + + +def _start_abstract_listener(name: str): + """Serve one connection on abstract UDS ``\\0{name}`` (unique per test).""" + ready = threading.Event() + result = {"status": "not-started"} + abstract = b"\0" + name.encode() + + def _run(): + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + s.bind(abstract) + s.listen(1) + s.settimeout(8) + ready.set() + try: + c, _ = s.accept() + c.sendall(_ABSTRACT_PAYLOAD) + c.close() + result["status"] = "accepted" + except OSError as e: + result["status"] = f"fail:{e}" + finally: + s.close() + + t = threading.Thread(target=_run, daemon=True) + t.start() + assert ready.wait(2), "abstract listener failed to bind" + return t, result @_needs async def test_all_escapes_denied(scenario): - workdir, outside = scenario - policy = rust_build_policy(workdir) # grants workdir + toolchains; NOT /proc, NOT `outside` + workdir, outside, creds = scenario + + # Policy must not grant the shared cargo root (credentials live there). + policy = rust_build_policy(workdir) + assert creds.parent.resolve() not in policy.ro_paths + assert (creds.parent / "bin").resolve() in policy.ro_paths + + abs_name = f"autoprover-escape-{os.getpid()}-confined" + _listener, _ = _start_abstract_listener(abs_name) res = await run_local_command( - "./malicious", [str(outside), str(os.getpid())], {}, - workdir=workdir, provider=_PROVIDER, policy=policy, + "./malicious", + [str(outside), str(os.getpid()), str(creds), abs_name], + {}, + 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) + # Always enforced (seccomp + Landlock FS + env scrub + narrow cargo grant). + denied = ( + "env", + "procenv", + "hostfile", + "net_ext", + "imds", + "iouring", + "netlink", + "vsock", + "inet_sock", + "cargo_creds", + ) + for name in denied: + assert probe(name) == "denied", f"{name}: {probe(name)}" + assert "LEAK" not in probe(name), f"{name}: {probe(name)}" + + # AF_UNIX remains allowed (toolchain jobserver / local IPC on path sockets). + assert probe("unix_sock") == "LEAK:socket-ok" + + # Landlock scopes (ABI ≥6 / Linux ≥6.12): signal + abstract UDS. On older + # kernels BestEffort drops them — residual same-uid risk (command-sandbox.md §6). + if _SCOPES_AVAILABLE: + assert probe("signal") == "denied", probe("signal") + assert probe("abstract_unix") == "denied", probe("abstract_unix") + else: + # Still record the probes so a future kernel bump is visible in artifacts. + assert (workdir / "probe_signal.txt").is_file() + assert (workdir / "probe_abstract_unix.txt").is_file() @_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 + workdir, outside, creds = scenario + abs_name = f"autoprover-escape-{os.getpid()}-control" + _listener, _ = _start_abstract_listener(abs_name) res = await run_local_command( - "./malicious", [str(outside), str(os.getpid())], {}, + "./malicious", + [str(outside), str(os.getpid()), str(creds), abs_name], + {}, 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() + assert "LEAK" in (workdir / "probe_cargo_creds.txt").read_text() + # io_uring and abstract unix should also work unconfined (control for those fixes). + assert "LEAK" in (workdir / "probe_iouring.txt").read_text() + assert "LEAK" in (workdir / "probe_abstract_unix.txt").read_text() + assert "LEAK" in (workdir / "probe_signal.txt").read_text() diff --git a/tests/test_sandbox_run_confined.py b/tests/test_sandbox_run_confined.py index f9d1ba05..ef89de73 100644 --- a/tests/test_sandbox_run_confined.py +++ b/tests/test_sandbox_run_confined.py @@ -69,6 +69,59 @@ async def test_confined_command_has_no_network(tmp_path): assert "LEAK" not in res.stdout +@_needs_sandbox +async def test_confined_command_denies_io_uring(tmp_path): + """io_uring_setup is a known seccomp socket() bypass — must be denied. + + Use /usr/bin/python3 (not a venv shim): the sandbox does not grant the + project .venv, so a PATH-resolved venv python fails before the probe runs. + """ + res = await run_local_command( + "/usr/bin/python3", + [ + "-c", + "import ctypes; c=ctypes.CDLL('libc.so.6',use_errno=True); " + "p=(ctypes.c_char*256)(); " + "fd=c.syscall(425,8,ctypes.byref(p)); " + "print('LEAK' if fd>=0 else 'denied')", + ], + {}, + workdir=tmp_path, + provider=_PROVIDER, + policy=_system_policy(tmp_path), + ) + assert res.exit_code == 0, res.stderr + assert "LEAK" not in res.stdout + assert "denied" in res.stdout + + +@_needs_sandbox +async def test_confined_command_denies_netlink_and_vsock(tmp_path): + res = await run_local_command( + "/usr/bin/python3", + [ + "-c", + "import socket; " + "out=[]\n" + "for fam,name in ((socket.AF_NETLINK,'nl'), (getattr(socket,'AF_VSOCK',40),'vs')):\n" + " try:\n" + " socket.socket(fam, socket.SOCK_STREAM if name=='vs' else socket.SOCK_RAW)\n" + " out.append(name+':LEAK')\n" + " except OSError:\n" + " out.append(name+':denied')\n" + "print(' '.join(out))", + ], + {}, + workdir=tmp_path, + provider=_PROVIDER, + policy=_system_policy(tmp_path), + ) + assert res.exit_code == 0, res.stderr + assert "LEAK" not in res.stdout + assert "nl:denied" in res.stdout + assert "vs:denied" 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 From 9f769e4128a52b2f6a3bbbb5c0933931d20c5e05 Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 20 Jul 2026 14:17:49 -0700 Subject: [PATCH 05/12] sandbox: close x32-ABI seccomp bypass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seccomp deny-list keyed on exact x86_64 syscall numbers was bypassable via the x32 ABI: an x32 call runs under the same AUDIT_ARCH_X86_64 identity (so seccompiler's arch guard passes it), but its syscall number is OR'd with __X32_SYSCALL_BIT (0x4000_0000), so it misses every exact-number rule and hits default-allow — a full bypass of the socket/io_uring/ptrace/process_vm denies. This matters most on kernels < 6.7 (the AL2023 6.1 target), where Landlock does no network filtering and seccomp is the sole network control, so the bypass is unconditional egress to the network + IMDS. Fix: mirror every deny onto its x32-tagged syscall number in apply_seccomp (x86_64 only; aarch64 has no per-syscall compat bit). Deriving the mirror from the already-built rule map means any future deny is covered automatically. Regression test records the errno of an x32 socket() attempt and asserts EPERM (seccomp caught it) rather than ENOSYS (kernel rejected it after the filter let it through) — a real guard even on x32-disabled CI kernels. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/command-sandbox.md | 18 ++++++++++++++++-- rust/run-confined/src/main.rs | 25 ++++++++++++++++++++++++- tests/test_sandbox_escape.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/docs/command-sandbox.md b/docs/command-sandbox.md index 7bc1f848..1cf822f4 100644 --- a/docs/command-sandbox.md +++ b/docs/command-sandbox.md @@ -227,6 +227,7 @@ container. Validated (stock python:3.12-slim, uid 1000, Docker default profile): | **secret** — `ptrace(ATTACH, parent)` | ✗ `EPERM` | **seccomp** (deny `ptrace`, `process_vm_readv`) | | network — `socket(AF_INET)` / netlink / vsock | ✗ `EPERM` | seccomp: deny `socket` when domain **≠ `AF_UNIX`** | | network — `io_uring_setup` (seccomp bypass) | ✗ `EPERM` | seccomp: deny `io_uring_{setup,enter,register}` | +| network — x32-ABI `socket` (seccomp bypass) | ✗ `EPERM` | seccomp: each deny mirrored onto its x32 syscall number (`nr \| 0x4000_0000`) | | network — TCP via Landlock (defense-in-depth) | ✗ deny | Landlock net rules (ABI ≥4), no bind/connect grants | | same-uid — `kill(parent)` / abstract UDS | ✗ `EPERM` | Landlock **scopes** `Signal` + `AbstractUnixSocket` (ABI ≥6 / Linux ≥6.12) | | legitimate — write workdir, `exec` toolchain, `AF_UNIX` | ✓ works | Landlock rw grant + r+x on toolchain paths; `AF_UNIX` still allowed | @@ -244,8 +245,13 @@ container. Validated (stock python:3.12-slim, uid 1000, Docker default profile): `socket` for every domain **except `AF_UNIX`** (so TCP, UDP/DNS, IMDS, netlink, vsock, … are blocked while cargo's jobserver still works), denies **`io_uring_*`** (the classic way to create sockets without calling `socket(2)`), and denies the remaining same-uid secret vectors - (`ptrace`, `process_vm_readv`/`writev`). Still a **deny-list** on top of default-allow — not a - full syscall allowlist; residual risk is tracked in §11. + (`ptrace`, `process_vm_readv`/`writev`). On **x86_64** each deny is **mirrored onto its x32-ABI + syscall number** (`nr | 0x4000_0000`): the x32 calling convention runs under the same + `AUDIT_ARCH_X86_64` identity, so seccompiler's arch guard passes it through, and without the mirror + an x32-tagged `socket`/`io_uring`/`ptrace` would miss the exact-number rules and reach + default-allow — a full bypass (which libseccomp guards against automatically; seccompiler does not). + Still a **deny-list** on top of default-allow — not a full syscall allowlist; residual risk is + tracked in §11. - **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). @@ -506,6 +512,14 @@ Only when both halves are green may the backend run on untrusted input (the §9 the toolchain (cargo jobserver, rustc, linker) never needs another domain; if a benign `AF_NETLINK` use surfaces, decide whether to allow it narrowly. Full syscall **allowlist** (default-deny) remains a possible hardening step if the deny-list residual risk is unacceptable. + **x32-ABI bypass — closed.** A deny-list keyed on exact x86_64 syscall numbers was bypassable via + the x32 ABI (same `AUDIT_ARCH_X86_64`, number OR'd with `0x4000_0000`): the arch guard passes and + the exact-number rules miss, hitting default-allow. Critical because on kernels < 6.7 (e.g. the + AL2023 6.1 target) Landlock provides *no* network filtering, so seccomp is the sole network + control. Fixed by mirroring every deny onto its x32 number (`apply_seccomp`), regression-tested in + `tests/test_sandbox_escape.py` (asserts the x32 `socket` is denied with `EPERM` from seccomp, not + `ENOSYS` from the kernel). This is the deny-list's one arch-level hole; a full allowlist would also + close it structurally. 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 diff --git a/rust/run-confined/src/main.rs b/rust/run-confined/src/main.rs index 5aebf3bf..6e082c95 100644 --- a/rust/run-confined/src/main.rs +++ b/rust/run-confined/src/main.rs @@ -12,7 +12,9 @@ //! TCP bind/connect (defense-in-depth; UDP still blocked by seccomp). //! 2. **seccomp** — deny non-`AF_UNIX` `socket()` (blocks TCP, UDP/DNS, IMDS, netlink, //! vsock, …), deny `io_uring_*` (blocks the classic seccomp network bypass), and -//! deny `ptrace`/`process_vm_readv`/`process_vm_writev`. +//! deny `ptrace`/`process_vm_readv`/`process_vm_writev`. On x86_64 each deny is +//! mirrored onto its x32-ABI syscall number (`nr | 0x4000_0000`) so the x32 +//! calling convention cannot slip a denied syscall past the exact-number rules. //! 3. **env allowlist** — `execve` with only `--allow-env` variables (a scrubbed //! environment). //! 4. **rlimits** — `--rlimit-*` caps on address space / CPU-seconds / pids / file size. @@ -296,6 +298,27 @@ fn apply_seccomp(cfg: &Config) -> Result<(), String> { rules.insert(nr as i64, Vec::new()); } + // Close the x32-ABI bypass. On x86_64, a task can invoke any syscall under the + // *same* AUDIT_ARCH_X86_64 identity but with the number OR'd with + // `__X32_SYSCALL_BIT` (0x4000_0000) — the x32 calling convention. seccompiler's + // architecture guard only checks AUDIT_ARCH (which x32 shares with x86_64), so an + // x32 call sails past it, then misses our exact-number JEQ rules below and lands on + // the default `Allow` — a total bypass of every deny above (x32 `socket`, `ptrace`, + // `io_uring_*`, `process_vm_*`). libseccomp guards against this automatically; + // seccompiler does not. We mirror each deny onto its x32-tagged number so both the + // native and x32 forms are caught (and any deny added above is mirrored for free). + // aarch64 has no such per-syscall compat bit — its AArch32 compat uses a distinct + // AUDIT_ARCH that the arch guard already kills — so this is x86_64-only. + #[cfg(target_arch = "x86_64")] + { + const X32_SYSCALL_BIT: i64 = 0x4000_0000; + let mirrored: Vec<(i64, Vec)> = rules + .iter() + .map(|(nr, chain)| (nr | X32_SYSCALL_BIT, chain.clone())) + .collect(); + rules.extend(mirrored); + } + let filter = SeccompFilter::new( rules, SeccompAction::Allow, // default: allow syscalls we didn't name diff --git a/tests/test_sandbox_escape.py b/tests/test_sandbox_escape.py index c10d4911..8f5c415c 100644 --- a/tests/test_sandbox_escape.py +++ b/tests/test_sandbox_escape.py @@ -109,6 +109,27 @@ def _kernel_at_least(major: int, minor: int) -> bool: } } +// x32-ABI bypass (x86_64): invoke socket(2) via the x32 calling convention — same +// AUDIT_ARCH_X86_64, but the syscall number OR'd with __X32_SYSCALL_BIT (0x4000_0000). +// glibc's syscall() just loads the number into rax, so this issues a genuine x32 call. +// Records the errno so the test can distinguish "seccomp denied it" (EPERM) from "the +// kernel has no x32 support" (ENOSYS) — without the deny-mirror the call reaches the +// kernel (ENOSYS here, a live fd on an x32-enabled kernel); with it, seccomp returns EPERM. +fn sock_inet_x32() -> String { + const X32_BIT: i64 = 0x4000_0000; + const SYS_SOCKET_X86_64: i64 = 41; + unsafe { + let fd = syscall(SYS_SOCKET_X86_64 | X32_BIT, AF_INET as i64, SOCK_STREAM as i64, 0i64); + if fd >= 0 { + close(fd as i32); + "LEAK:socket-ok".to_string() + } else { + let e = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); + format!("denied:errno={}", e) + } + } +} + fn main() { let args: Vec = std::env::args().collect(); let outside = args.get(1).cloned().unwrap_or_default(); @@ -151,6 +172,7 @@ def _kernel_at_least(major: int, minor: int) -> bool: // AF_UNIX must still work (cargo jobserver); record that separately for the control. probe("unix_sock", &sock_domain(AF_UNIX, SOCK_STREAM)); probe("inet_sock", &sock_domain(AF_INET, SOCK_STREAM)); + probe("inet_sock_x32", &sock_inet_x32()); // Same-uid signal: kill(pid, 0) checks permission without delivering a fatal signal. probe("signal", &{ @@ -296,6 +318,19 @@ def probe(name: str) -> str: # AF_UNIX remains allowed (toolchain jobserver / local IPC on path sockets). assert probe("unix_sock") == "LEAK:socket-ok" + # x32-ABI bypass (x86_64 only): the deny-mirror must make seccomp catch the + # x32-tagged socket() *itself*. Asserting the errno is EPERM (seccomp) — not + # ENOSYS (the kernel, reached only because the filter let the call through) — + # is what makes this a real regression test even on an x32-disabled kernel like + # CI's: without the mirror this reads `denied:errno=38`, with it `denied:errno=1` + # (and on an x32-*enabled* kernel, without the mirror it would be a live socket). + import errno as _errno + + if os.uname().machine == "x86_64": + assert probe("inet_sock_x32") == f"denied:errno={_errno.EPERM}", probe( + "inet_sock_x32" + ) + # Landlock scopes (ABI ≥6 / Linux ≥6.12): signal + abstract UDS. On older # kernels BestEffort drops them — residual same-uid risk (command-sandbox.md §6). if _SCOPES_AVAILABLE: From f6af1aecb100726d609dd420d2a79e599bc82d61 Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 20 Jul 2026 14:28:48 -0700 Subject: [PATCH 06/12] Update doc and comment --- docs/command-sandbox.md | 7 +++---- scripts/Dockerfile | 2 -- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/command-sandbox.md b/docs/command-sandbox.md index 1cf822f4..3927563c 100644 --- a/docs/command-sandbox.md +++ b/docs/command-sandbox.md @@ -29,8 +29,7 @@ AutoProver's own secrets, network access, and filesystem from code running *insi 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. +ambient authority the AutoProver process has. 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. @@ -44,7 +43,7 @@ input (the gate scenario). This is the definition of done. | | | |---|---| -| **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. | +| **Asset** | AutoProver's ambient secrets, 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. | @@ -52,7 +51,7 @@ input (the gate scenario). This is the definition of done. **Explicit guarantees the sandbox must provide:** -1. **No network** — no egress at all, including DNS and `169.254.169.254`. +1. **No network** — no egress at all, including DNS. 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). diff --git a/scripts/Dockerfile b/scripts/Dockerfile index 57d98ca6..fe613775 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -95,8 +95,6 @@ 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 \ From 0860e34f4b301cf234cd70c86fe9cbe454d15bd1 Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 20 Jul 2026 14:52:40 -0700 Subject: [PATCH 07/12] ci: escape suite on the production kernel (AL2023 6.1) via QEMU Landlock + seccomp are kernel-mediated and a container shares the host kernel, so ubuntu-latest cannot validate run-confined on the 6.1 kernel we ship on. This job boots the real Amazon Linux 2023 kernel-6.1 KVM cloud image under QEMU/KVM (SHA256-verified, UEFI/OVMF, cloud-init SSH seed) and runs tests/test_sandbox_escape.py inside it. On 6.1 the suite exercises the 6.1 contract specifically: Landlock FS enforced, network seccomp-only (no Landlock net < 6.7), scopes absent (< 6.12, so the signal / abstract-UDS vectors self-skip), and the x32 deny-mirror asserted regardless. The guest is kept dependency-light: the suite is stdlib + pytest only, so we install pytest via uv on PYTHONPATH and pass --noconftest to skip tests/conftest.py's heavy imports (its fixtures are all in-module). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/scripts/sandbox_vm_provision.sh | 82 +++++++++++ .github/workflows/sandbox-escape-6.1.yml | 175 +++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100755 .github/scripts/sandbox_vm_provision.sh create mode 100644 .github/workflows/sandbox-escape-6.1.yml diff --git a/.github/scripts/sandbox_vm_provision.sh b/.github/scripts/sandbox_vm_provision.sh new file mode 100755 index 00000000..463faf53 --- /dev/null +++ b/.github/scripts/sandbox_vm_provision.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# +# Runs INSIDE the QEMU Amazon Linux 2023 (kernel 6.1) guest — see +# .github/workflows/sandbox-escape-6.1.yml. Provisions a minimal toolchain and +# runs the sandbox escape suite against the *production* kernel version. +# +# Why the guest at all: Landlock + seccomp are kernel-mediated, and a container +# shares the host kernel — so the only faithful way to test run-confined on the +# prod 6.1 kernel is to boot that kernel (docs/command-sandbox.md §8). On 6.1 the +# suite exercises the 6.1 contract specifically: Landlock FS enforced, network is +# seccomp-only (no Landlock net rules < 6.7), and scopes are absent (< 6.12, so +# the signal / abstract-UDS asserts self-skip). The x32 deny-mirror is asserted +# regardless (§11 item 2). +# +# Kept dependency-light on purpose: the escape suite imports only stdlib + +# composer.sandbox.* (all stdlib) + pytest, so we install just pytest via uv and +# put the repo on PYTHONPATH — no project build, no numpy/psycopg/langchain. We +# pass --noconftest so tests/conftest.py (which imports those heavy deps) is not +# collected; the suite's fixtures are all in-module. +set -euo pipefail + +REPO="${1:?usage: sandbox_vm_provision.sh }" +JUNIT="${REPO}/sandbox-escape-junit.xml" + +section() { printf '\n=== %s ===\n' "$*"; } + +section "Guest kernel — this is what the sandbox is actually tested against" +uname -srm +# The behavior of run-confined is version-gated; record it so a CI artifact shows +# exactly what protected the run (not just pass/skip). +KREL="$(uname -r)" +if [ -r "/boot/config-${KREL}" ]; then + X32="$(grep -E '^CONFIG_X86_X32_ABI' "/boot/config-${KREL}" || echo 'CONFIG_X86_X32_ABI not set')" +elif [ -r /proc/config.gz ]; then + X32="$(zcat /proc/config.gz | grep -E '^CONFIG_X86_X32_ABI' || echo 'CONFIG_X86_X32_ABI not set')" +else + X32="kernel config not available in guest" +fi +echo "x32 ABI: ${X32}" +echo "(The x32 deny-mirror is asserted by the suite regardless — this only records" +echo " whether the bypass was ever live on this kernel build.)" + +section "Toolchain: gcc (linker + rustc's cc), git" +sudo dnf -y -q install gcc git tar >/dev/null + +section "Rust (rustup, minimal profile) — builds run-confined + compiles the malicious probe" +if ! command -v rustc >/dev/null 2>&1; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs \ + | sh -s -- -y --profile minimal --default-toolchain stable +fi +# shellcheck disable=SC1091 +source "${HOME}/.cargo/env" +rustc --version + +section "uv (isolated pytest env on Python 3.12 — AL2023 ships 3.11)" +if ! command -v uv >/dev/null 2>&1; then + curl -LsSf https://astral.sh/uv/install.sh | sh +fi +export PATH="${HOME}/.local/bin:${PATH}" +uv --version + +section "Build run-confined (release)" +cargo build -p run-confined --release --manifest-path "${REPO}/rust/Cargo.toml" +export RUN_CONFINED_BIN="${REPO}/rust/target/release/run-confined" + +section "Landlock probe on the 6.1 kernel (drives fail-closed available())" +# Non-fatal: the suite's own skip-guard depends on this, but we want the output. +"${RUN_CONFINED_BIN}" --probe || { + echo "!! run-confined --probe reported Landlock NOT enforcing on this kernel." + echo "!! On a real 6.1 kernel this should succeed (Landlock floor is 5.13)." + exit 3 +} + +section "Escape suite against kernel ${KREL}" +export PYTHONPATH="${REPO}" +# --no-project: don't build AutoProver; --with: ephemeral pytest env. +# --noconftest: skip tests/conftest.py's heavy imports (fixtures here are in-module). +uv run --no-project --python 3.12 \ + --with 'pytest>=9.0' --with 'pytest-asyncio>=1.3' \ + pytest --noconftest -v \ + --junitxml="${JUNIT}" \ + "${REPO}/tests/test_sandbox_escape.py" diff --git a/.github/workflows/sandbox-escape-6.1.yml b/.github/workflows/sandbox-escape-6.1.yml new file mode 100644 index 00000000..bcc9a3bf --- /dev/null +++ b/.github/workflows/sandbox-escape-6.1.yml @@ -0,0 +1,175 @@ +name: sandbox-escape (kernel 6.1) + +# Validates the RunCommand sandbox (rust/run-confined) on the PRODUCTION kernel +# version — Amazon Linux 2023, kernel 6.1 (docs/command-sandbox.md §8). +# +# Landlock + seccomp are kernel-mediated and a container shares the host kernel, +# so GitHub's ubuntu-latest runner (a newer, unpinned kernel) cannot test the 6.1 +# contract. We therefore boot the real AL2023 6.1 cloud image under QEMU/KVM and +# run tests/test_sandbox_escape.py inside it. On 6.1 the suite asserts the FS, +# env, network (seccomp-only — no Landlock net < 6.7) and x32 vectors are denied, +# and self-skips the scope vectors (Landlock scopes need 6.12). + +on: + pull_request: + paths: + - "rust/run-confined/**" + - "rust/Cargo.toml" + - "rust/Cargo.lock" + - "composer/sandbox/**" + - "tests/test_sandbox_escape.py" + - ".github/workflows/sandbox-escape-6.1.yml" + - ".github/scripts/sandbox_vm_provision.sh" + push: + branches: [master] + paths: + - "rust/run-confined/**" + - "composer/sandbox/**" + - "tests/test_sandbox_escape.py" + - ".github/workflows/sandbox-escape-6.1.yml" + - ".github/scripts/sandbox_vm_provision.sh" + # Weekly: catch AL2023 dropping the 6.1 image or drift in the latest 6.1 build. + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +concurrency: + group: sandbox-escape-6.1-${{ github.ref }} + cancel-in-progress: true + +jobs: + escape-suite-kernel-6_1: + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + # Override to pin an exact build for reproducibility; default resolves the + # latest published AL2023 *6.1* KVM image (job fails loudly if 6.1 is gone). + AL2023_KVM_DIR: "https://cdn.amazonlinux.com/al2023/os-images/latest/kvm/" + SSH_PORT: "2222" + SSH: "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -i vmkey -p 2222 ec2-user@127.0.0.1" + steps: + - name: Check out repo + uses: actions/checkout@v4 + # graphcore submodule not needed: the escape suite is stdlib + composer.sandbox. + with: + submodules: false + + - name: Enable KVM on the runner + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + test -e /dev/kvm && echo "KVM available" + + - name: Install QEMU + cloud-image tooling + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq \ + qemu-system-x86 qemu-utils ovmf cloud-image-utils genisoimage openssh-client + + - name: Download + verify the AL2023 6.1 KVM image + run: | + set -euo pipefail + base="${AL2023_KVM_DIR%/}/" + img="$(curl -fsSL "$base" \ + | grep -oE 'al2023-kvm-[^"]*kernel-6\.1-x86_64\.xfs\.gpt\.qcow2' \ + | head -1)" + if [ -z "$img" ]; then + echo "::error::No kernel-6.1 AL2023 KVM image found under $base" + echo "AL2023 may have dropped 6.1 as a published image. Pin AL2023_KVM_DIR" + echo "to a versioned dir that still ships kernel-6.1, or move the prod floor." + exit 1 + fi + echo "Image: $img" + curl -fsSL -o al2023.qcow2 "${base}${img}" + curl -fsSL -o SHA256SUMS "${base}SHA256SUMS" + # SHA256SUMS lists " "; verify our file matches. + awk -v f="$img" '$2==f {print $1" al2023.qcow2"}' SHA256SUMS | sha256sum -c - + # Room for rustup + build artifacts (cloud-init grows the fs on boot). + qemu-img resize al2023.qcow2 +15G + + - name: Build cloud-init seed (SSH key injection) + run: | + set -euo pipefail + ssh-keygen -t ed25519 -N "" -f vmkey -q + cat > user-data < meta-data </dev/null; then + echo "SSH up after ~$((i*5))s" + $SSH 'sudo cloud-init status --wait || true' + exit 0 + fi + sleep 5 + done + echo "::error::VM did not become reachable over SSH in time" + echo "----- serial.log (tail) -----"; tail -n 100 serial.log || true + exit 1 + + - name: Copy repo into the VM + run: | + set -euo pipefail + tar czf repo.tgz \ + --exclude=.git --exclude=.venv --exclude=node_modules \ + --exclude='rust/target' --exclude=graphcore \ + . + scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -i vmkey -P "$SSH_PORT" repo.tgz ec2-user@127.0.0.1:/home/ec2-user/ + $SSH 'mkdir -p /home/ec2-user/autoprover && tar xzf repo.tgz -C /home/ec2-user/autoprover' + + - name: Run the escape suite on kernel 6.1 + run: | + set -euo pipefail + $SSH 'bash /home/ec2-user/autoprover/.github/scripts/sandbox_vm_provision.sh /home/ec2-user/autoprover' + + - name: Collect results + if: always() + run: | + scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -i vmkey -P "$SSH_PORT" \ + ec2-user@127.0.0.1:/home/ec2-user/autoprover/sandbox-escape-junit.xml . 2>/dev/null || true + + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: sandbox-escape-6.1 + path: | + sandbox-escape-junit.xml + serial.log + if-no-files-found: ignore From 9d90a9043c7abf824a32aa7ff73a296def6fe32a Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 20 Jul 2026 14:57:55 -0700 Subject: [PATCH 08/12] ci: snapshot repo with git archive, not tar of the workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "copy repo into the VM" step tar'd the whole workspace, which by that point holds the running VM's al2023.qcow2 (QEMU actively writing it) plus seed.iso / serial.log / repo.tgz — tar aborts with "file changed as we read it". Use git archive HEAD instead: a clean snapshot of tracked source, which is all the escape suite needs and structurally cannot include the runtime artifacts. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/sandbox-escape-6.1.yml | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sandbox-escape-6.1.yml b/.github/workflows/sandbox-escape-6.1.yml index bcc9a3bf..a1e63f62 100644 --- a/.github/workflows/sandbox-escape-6.1.yml +++ b/.github/workflows/sandbox-escape-6.1.yml @@ -144,10 +144,12 @@ jobs: - name: Copy repo into the VM run: | set -euo pipefail - tar czf repo.tgz \ - --exclude=.git --exclude=.venv --exclude=node_modules \ - --exclude='rust/target' --exclude=graphcore \ - . + # git archive => a clean snapshot of tracked source only. Do NOT tar the + # workspace: by this step it holds the *running* VM's al2023.qcow2 (QEMU is + # actively writing it) plus seed.iso / serial.log / repo.tgz, which trips + # "file changed as we read it". Tracked files are all the suite needs + # (composer/, tests/, rust/, pyproject.toml, uv.lock, .github/scripts). + git archive --format=tar.gz -o repo.tgz HEAD scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -i vmkey -P "$SSH_PORT" repo.tgz ec2-user@127.0.0.1:/home/ec2-user/ $SSH 'mkdir -p /home/ec2-user/autoprover && tar xzf repo.tgz -C /home/ec2-user/autoprover' From bbfe84f597a144299da0f764a78afc0d63db74c3 Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 20 Jul 2026 15:05:34 -0700 Subject: [PATCH 09/12] ci: bump checkout/upload-artifact to v5 (node24) actions/checkout@v4 and upload-artifact@v4 run on the deprecated node20 runtime, which emits a deprecation warning now that the runner defaults to node24. Bump both to v5 (first major on node24) to clear it; our usage (plain checkout, path-list upload) is unaffected by the majors' breaking changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/sandbox-escape-6.1.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sandbox-escape-6.1.yml b/.github/workflows/sandbox-escape-6.1.yml index a1e63f62..1dd31705 100644 --- a/.github/workflows/sandbox-escape-6.1.yml +++ b/.github/workflows/sandbox-escape-6.1.yml @@ -49,7 +49,7 @@ jobs: SSH: "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=5 -i vmkey -p 2222 ec2-user@127.0.0.1" steps: - name: Check out repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 # graphcore submodule not needed: the escape suite is stdlib + composer.sandbox. with: submodules: false @@ -168,7 +168,7 @@ jobs: - name: Upload artifacts if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: sandbox-escape-6.1 path: | From 6f965c123bcd25f2000ca45bad6fe69afe312dc7 Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 20 Jul 2026 15:08:00 -0700 Subject: [PATCH 10/12] TEMP (DO NOT MERGE): intentional sandbox bug to verify CI catches regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Negative control for the sandbox-escape (kernel 6.1) workflow. Disables the seccomp socket() network block so socket(AF_INET/AF_NETLINK/AF_VSOCK,…) succeeds, breaking the "No network" guarantee. The escape suite must go RED (inet_sock / netlink / vsock / net_ext / imds leak) — proving the CI fails when the sandbox is broken, not just when it works. REVERT THIS COMMIT before merging. Verified locally: run-confined builds and tests/test_sandbox_escape.py fails on the network probes. Co-Authored-By: Claude Opus 4.8 (1M context) --- rust/run-confined/src/main.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/rust/run-confined/src/main.rs b/rust/run-confined/src/main.rs index 6e082c95..d69c3f4e 100644 --- a/rust/run-confined/src/main.rs +++ b/rust/run-confined/src/main.rs @@ -264,10 +264,16 @@ fn apply_seccomp(cfg: &Config) -> Result<(), String> { let mut rules: BTreeMap> = BTreeMap::new(); if !cfg.allow_network { + // !!! INTENTIONAL SECURITY BUG — DO NOT MERGE; REVERT THIS COMMIT. !!! + // Negative control to prove the escape-suite CI (sandbox-escape kernel 6.1) + // fails when the sandbox is broken. The socket() network block below is + // disabled, so socket(AF_INET,…) succeeds and the "No network" guarantee is + // gone. tests/test_sandbox_escape.py must go RED (inet_sock/net_ext/imds leak). + // // Deny socket() for every domain *except* AF_UNIX (cargo jobserver, etc.). // Matching arg0 != AF_UNIX covers AF_INET/INET6 (TCP+UDP/DNS+IMDS), AF_NETLINK, // AF_PACKET, AF_VSOCK, and any future family — not just the two inet domains. - let non_unix = SeccompRule::new(vec![SeccompCondition::new( + let _non_unix = SeccompRule::new(vec![SeccompCondition::new( 0, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, @@ -275,7 +281,7 @@ fn apply_seccomp(cfg: &Config) -> Result<(), String> { ) .map_err(|e| e.to_string())?]) .map_err(|e| e.to_string())?; - rules.insert(libc::SYS_socket as i64, vec![non_unix]); + // BUG: rule intentionally NOT inserted (was: rules.insert(SYS_socket, [non_unix])). } // io_uring can create sockets and connect without calling socket(2), which is a From 16d8a0fbd301e5bcd2d6fcf1e37db3b7aad055d9 Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 20 Jul 2026 15:15:18 -0700 Subject: [PATCH 11/12] Revert "TEMP (DO NOT MERGE): intentional sandbox bug to verify CI catches regressions" This reverts commit 6f965c123bcd25f2000ca45bad6fe69afe312dc7. --- rust/run-confined/src/main.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/rust/run-confined/src/main.rs b/rust/run-confined/src/main.rs index d69c3f4e..6e082c95 100644 --- a/rust/run-confined/src/main.rs +++ b/rust/run-confined/src/main.rs @@ -264,16 +264,10 @@ fn apply_seccomp(cfg: &Config) -> Result<(), String> { let mut rules: BTreeMap> = BTreeMap::new(); if !cfg.allow_network { - // !!! INTENTIONAL SECURITY BUG — DO NOT MERGE; REVERT THIS COMMIT. !!! - // Negative control to prove the escape-suite CI (sandbox-escape kernel 6.1) - // fails when the sandbox is broken. The socket() network block below is - // disabled, so socket(AF_INET,…) succeeds and the "No network" guarantee is - // gone. tests/test_sandbox_escape.py must go RED (inet_sock/net_ext/imds leak). - // // Deny socket() for every domain *except* AF_UNIX (cargo jobserver, etc.). // Matching arg0 != AF_UNIX covers AF_INET/INET6 (TCP+UDP/DNS+IMDS), AF_NETLINK, // AF_PACKET, AF_VSOCK, and any future family — not just the two inet domains. - let _non_unix = SeccompRule::new(vec![SeccompCondition::new( + let non_unix = SeccompRule::new(vec![SeccompCondition::new( 0, SeccompCmpArgLen::Dword, SeccompCmpOp::Ne, @@ -281,7 +275,7 @@ fn apply_seccomp(cfg: &Config) -> Result<(), String> { ) .map_err(|e| e.to_string())?]) .map_err(|e| e.to_string())?; - // BUG: rule intentionally NOT inserted (was: rules.insert(SYS_socket, [non_unix])). + rules.insert(libc::SYS_socket as i64, vec![non_unix]); } // io_uring can create sockets and connect without calling socket(2), which is a From 638a6ff52ea7737e85d56ceb4a57c12ce406900d Mon Sep 17 00:00:00 2001 From: Eric Eilebrecht Date: Mon, 20 Jul 2026 15:48:14 -0700 Subject: [PATCH 12/12] sandbox: move run-confined launcher into an opt-in compose overlay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base image baked the run-confined command-sandbox launcher onto PATH unconditionally (Dockerfile stage 1b + a COPY into /usr/local/bin). Not every AutoProver container needs the sandbox, so pull it out of the base image and provide it as a runtime-mounted, opt-in building block instead. - scripts/Dockerfile: drop stage 1b and the run-confined COPY; leave a pointer to the overlay. - scripts/Dockerfile.sandbox: builds run-confined in a throwaway Rust stage and publishes the binary into a mounted volume via a minimal bookworm-slim image. - scripts/docker-compose.sandbox.yml: overlay with a one-shot run-confined-build service that populates a run_confined named volume, plus the wiring onto the autoprove service (read-only mount + RUN_CONFINED_BIN, gated behind the builder via service_completed_successfully). Consumers that don't add this overlay get a sandbox-free image. - docs/command-sandbox.md: update the §9 note — the launcher is resolved via $RUN_CONFINED_BIN (overlay-mounted) rather than baked into the image. The launcher provider already resolves $RUN_CONFINED_BIN ahead of PATH (composer/sandbox/launcher.py), so no code change is needed. Verified end-to-end: the publisher lands a 0755 ELF in the volume and `run-confined --probe` reports `landlock FullyEnforced` from a read-only mount. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/command-sandbox.md | 6 ++-- scripts/Dockerfile | 17 +++------- scripts/Dockerfile.sandbox | 34 ++++++++++++++++++++ scripts/docker-compose.sandbox.yml | 51 ++++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 scripts/Dockerfile.sandbox create mode 100644 scripts/docker-compose.sandbox.yml diff --git a/docs/command-sandbox.md b/docs/command-sandbox.md index 3927563c..21fc92d6 100644 --- a/docs/command-sandbox.md +++ b/docs/command-sandbox.md @@ -455,8 +455,10 @@ the sandbox is unavailable: refuse to run, loudly, rather than run untrusted nat 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 +flip:** `run-confined` must be resolvable — `$RUN_CONFINED_BIN`, then PATH, then the dev build +(`cargo build -p run-confined --release`). Containers opt in via the `scripts/docker-compose.sandbox.yml` +overlay, which builds the launcher (`scripts/Dockerfile.sandbox`) and mounts it read-only at +`$RUN_CONFINED_BIN`. 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. diff --git a/scripts/Dockerfile b/scripts/Dockerfile index fe613775..f4a89200 100644 --- a/scripts/Dockerfile +++ b/scripts/Dockerfile @@ -59,16 +59,6 @@ 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 @@ -159,9 +149,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 +# The `run-confined` command-sandbox launcher is intentionally NOT baked in here +# (not every AutoProver image needs it). Services that select the `launcher` +# provider opt in via the scripts/docker-compose.sandbox.yml overlay, which +# mounts the binary at $RUN_CONFINED_BIN at runtime — see docs/command-sandbox.md. # Pre-download the sentence-transformers embedding model so first runs are # offline-fast. nomic-embed-text-v1.5 ships custom modeling code, hence diff --git a/scripts/Dockerfile.sandbox b/scripts/Dockerfile.sandbox new file mode 100644 index 00000000..61c09469 --- /dev/null +++ b/scripts/Dockerfile.sandbox @@ -0,0 +1,34 @@ +# syntax=docker/dockerfile:1.7 +# +# Builds the `run-confined` command-sandbox launcher (Landlock + seccomp; see +# docs/command-sandbox.md) and publishes it onto a shared volume so app +# containers can opt into the sandbox WITHOUT baking it into their own image. +# +# Not every AutoProver image needs the sandbox, so this is deliberately kept out +# of scripts/Dockerfile. It is wired in per-service via the compose overlay +# scripts/docker-compose.sandbox.yml, which runs this as a one-shot "publish the +# binary into a named volume" service and then mounts that volume read-only into +# the consuming service at $RUN_CONFINED_BIN. +# +# Build context is the repo root (same as scripts/Dockerfile). + +# ---- Build the launcher in a throwaway Rust stage ---- +# NOTE: the --platform=linux/amd64 pin means this Rust build runs under QEMU +# emulation on Apple Silicon; cached after the first build. Bookworm-based, to +# match the python:3.12-slim runtime the binary is mounted into. +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 + +# ---- Minimal publisher image ---- +# Holds just the binary; its CMD copies it into the mounted /out volume. Kept on +# bookworm-slim so the glibc the binary was linked against is present if anything +# ever execs it here (consumers run it in their own bookworm-based image). +FROM --platform=linux/amd64 debian:bookworm-slim AS final +COPY --from=sandbox-builder /build/rust/target/release/run-confined /run-confined +# The overlay mounts a named volume at /out; publish the launcher into it (0755 +# so a non-root host-UID runtime user in the consumer can exec it). Idempotent: +# safe to re-run on every `up`. +CMD ["sh", "-c", "install -m 0755 /run-confined /out/run-confined && echo 'run-confined published to /out/run-confined'"] diff --git a/scripts/docker-compose.sandbox.yml b/scripts/docker-compose.sandbox.yml new file mode 100644 index 00000000..d14fbb6a --- /dev/null +++ b/scripts/docker-compose.sandbox.yml @@ -0,0 +1,51 @@ +# docker-compose.sandbox.yml — OVERLAY that gives a service the `run-confined` +# command-sandbox launcher (Landlock + seccomp; docs/command-sandbox.md). +# +# Layered on top of scripts/docker-compose.yml, never used alone. Not every AutoProver +# container needs it, so this overlay supplies it at runtime instead: +# +# 1. `run-confined-build` builds scripts/Dockerfile.sandbox and, on start, +# copies the launcher into the shared `run_confined` named volume, then exits. +# 2. Consuming services mount that volume read-only and point +# RUN_CONFINED_BIN at it. The launcher provider resolves $RUN_CONFINED_BIN +# first (composer/sandbox/launcher.py), so it need not be on PATH. +# +# Usage (add -f for this overlay to any base invocation): +# docker compose -f scripts/docker-compose.yml -f scripts/docker-compose.sandbox.yml \ +# --profile autoprove build +# docker compose -f scripts/docker-compose.yml -f scripts/docker-compose.sandbox.yml \ +# --profile autoprove run --rm autoprove console-autoprove --cloud /work/ ... +# +# Adding the sandbox to a FUTURE service: mirror the three keys added to +# `autoprove` below (the `depends_on` completion gate, the `run_confined` mount, +# and RUN_CONFINED_BIN). `depends_on` pulls the builder in even when its profile +# is not explicitly enabled, so the one-shot publish always runs first. + +services: + run-confined-build: + # Gated so a bare `docker compose up` never starts it; consumers pull it in + # via depends_on regardless of which profile they run under. + profiles: ["sandbox"] + image: autoprover-run-confined:latest + build: + context: .. + dockerfile: scripts/Dockerfile.sandbox + volumes: + - run_confined:/out + # One-shot: the image's CMD publishes the binary and exits 0. + restart: "no" + + autoprove: + depends_on: + # Base file already declares postgres here; compose merges the two entries. + run-confined-build: + condition: service_completed_successfully + environment: + # Absolute path to the mounted launcher; the provider prefers this over PATH. + RUN_CONFINED_BIN: /sandbox/run-confined + volumes: + # Read-only: only run-confined-build (rw, above) writes the binary. + - run_confined:/sandbox:ro + +volumes: + run_confined: