Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
62 changes: 62 additions & 0 deletions composer/sandbox/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
156 changes: 156 additions & 0 deletions composer/sandbox/command.py
Original file line number Diff line number Diff line change
@@ -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()
98 changes: 98 additions & 0 deletions composer/sandbox/config.py
Original file line number Diff line number Diff line change
@@ -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,
}
Loading
Loading