diff --git a/pkg-py/src/commons/_execution/_runtime/__init__.py b/pkg-py/src/commons/_execution/_runtime/__init__.py new file mode 100644 index 00000000..26fbb388 --- /dev/null +++ b/pkg-py/src/commons/_execution/_runtime/__init__.py @@ -0,0 +1,9 @@ +"""Code that runs inside the worker process. + +Each module here changes the process that runs it: lowering resource +limits, engaging a sandbox, taking over stdout, and so on. The worker runs +them against itself. The parent never imports them, because that would affect +the parent's resources. None of these modules imports ``commons``: the +worker loads them by absolute path, on an interpreter that will not have +commons installed. +""" diff --git a/pkg-py/src/commons/_execution/_runtime/_limits.py b/pkg-py/src/commons/_execution/_runtime/_limits.py new file mode 100644 index 00000000..a4e0f22c --- /dev/null +++ b/pkg-py/src/commons/_execution/_runtime/_limits.py @@ -0,0 +1,43 @@ +"""Capping the worker's address space, before model code runs. + +This runs in the child, before the sandbox engages and any model-written code +is loaded. It guards against a runaway allocation taking the host down +with it; the security boundary is the sandbox. +""" + +from __future__ import annotations + +import errno +import resource + +__all__ = ["DEFAULT_ADDRESS_SPACE", "apply_address_space_limit"] + +# Ample headroom for a data frame or two and the libraries that read them. A +# smaller limit inherited from a container still wins +DEFAULT_ADDRESS_SPACE = 8 * 1024**3 + + +def apply_address_space_limit(limit: int = DEFAULT_ADDRESS_SPACE) -> int | None: + """Cap this process's address space, and report what stuck. + + Returns the limit in force afterwards: ``limit``, or a smaller one + already inherited. Returns ``None`` where the kernel has no + ``RLIMIT_AS`` to set, as on recent macOS; a missing backstop is no + reason to refuse a working sandbox. Any other failure raises, since a + refused call is indistinguishable from a missing limit on such a host. + """ + soft, hard = resource.getrlimit(resource.RLIMIT_AS) + for inherited in (soft, hard): + if inherited != resource.RLIM_INFINITY and inherited < limit: + limit = inherited + try: + resource.setrlimit(resource.RLIMIT_AS, (limit, limit)) + except ValueError: + # CPython raises ValueError for the kernel's EINVAL, which is how a + # host that has no RLIMIT_AS to set answers. + return None + except OSError as exc: + if exc.errno != errno.EINVAL: + raise + return None + return limit diff --git a/pkg-py/src/commons/_execution/_sandbox.py b/pkg-py/src/commons/_execution/_sandbox.py new file mode 100644 index 00000000..054b5a26 --- /dev/null +++ b/pkg-py/src/commons/_execution/_sandbox.py @@ -0,0 +1,130 @@ +"""Whether this host can sandbox the worker, decided before anything runs. + +The worker sandboxes itself, in the child, before any model-written code is +loaded. This module is the parent's half: a probe of what the host offers +and the gate that turns that into a refusal, so a host commons cannot protect +is reported when the agent is constructed rather than when a model first asks +to run code. + +The same decision is made by ``run_r_protection_mode()`` in +``pkg-r/R/sandbox.R``. +""" + +from __future__ import annotations + +import os +import platform +from dataclasses import dataclass +from typing import Literal + +__all__ = [ + "ALLOW_UNSAFE_FALLBACK", + "ProtectionMode", + "SandboxCapabilities", + "protection_mode", + "sandbox_capabilities", +] + +# "sandbox" means the worker restricts itself with a kernel mechanism, so +# model-written code is genuinely contained. "guardrails" is the weaker +# fallback reachable only by opting in through ALLOW_UNSAFE_FALLBACK: it +# provides no security boundary. +ProtectionMode = Literal["sandbox", "guardrails"] + +# Accepting weaker protection should take a deliberate act outside the code, +# so this opt-in lives in an environment variable: a constructor keyword is +# too easy for a caller to pass without noticing what it gives up. +ALLOW_UNSAFE_FALLBACK = "COMMONS_ALLOW_UNSAFE_FALLBACK" + +_AFFIRMATIVE = frozenset({"1", "true", "yes", "on"}) + +_OPT_IN = ( + f"For local development only, set {ALLOW_UNSAFE_FALLBACK}=1 to accept " + "best-effort guardrails; guardrails provide no security boundary!" +) + + +@dataclass(frozen=True, kw_only=True) +class SandboxCapabilities: + """What the running kernel offers the worker. + + ``landlock_abi`` is the Landlock ABI version, ``0`` where the syscall + exists but reports no usable version and ``-1`` where it does not exist. + """ + + landlock_abi: int + seccomp: bool + seatbelt: bool + userns: bool + + +def sandbox_capabilities() -> SandboxCapabilities: + """Probe this host for each mechanism the worker can restrict itself with. + + Each field is filled in by the ctypes module that implements its + mechanism, none of which exist yet. Until they do this reports every + mechanism unavailable, so ``protection_mode()`` refuses every host. + """ + return SandboxCapabilities( + landlock_abi=-1, seccomp=False, seatbelt=False, userns=False + ) + + +def _guardrails_allowed() -> bool: + return os.environ.get(ALLOW_UNSAFE_FALLBACK, "").strip().lower() in _AFFIRMATIVE + + +def protection_mode( + capabilities: SandboxCapabilities | None = None, + *, + sysname: str | None = None, +) -> ProtectionMode: + """How much the worker can be protected on this host. + + Returns ``"sandbox"`` when the host offers a real sandbox. Otherwise + raises, unless guardrails have been opted into, in which case it returns + ``"guardrails"``. Guardrails are a way to keep working on a host commons + cannot protect; they provide no sandbox. + + ``capabilities`` and ``sysname`` default to this host; passing them is + how the decision table is exercised from any machine, so leave both at + their defaults outside the tests. There is deliberately no argument for + the opt-in: the only way to accept guardrails is to set the environment + variable defined in ``ALLOW_UNSAFE_FALLBACK``. + """ + if capabilities is None: + capabilities = sandbox_capabilities() + if sysname is None: + sysname = platform.system() + + if sysname == "Linux": + available = capabilities.seccomp and ( + capabilities.landlock_abi >= 1 or capabilities.userns + ) + elif sysname == "Darwin": + available = capabilities.seatbelt + else: + available = False + + if available: + return "sandbox" + if _guardrails_allowed(): + return "guardrails" + + if sysname == "Linux" and not capabilities.seccomp: + raise RuntimeError( + "commons cannot sandbox the code execution worker because this " + f"Linux host does not support seccomp. {_OPT_IN}" + ) + if sysname == "Linux": + raise RuntimeError( + "commons cannot sandbox the code execution worker because this " + "Linux host offers neither Landlock nor unprivileged user " + "namespaces. Use a kernel with Landlock, or enable user " + "namespaces with `sysctl user.max_user_namespaces` (some " + "container seccomp profiles block them) and accept the larger " + f"kernel attack surface. {_OPT_IN}" + ) + raise RuntimeError( + f"commons cannot sandbox the code execution worker on {sysname}. {_OPT_IN}" + ) diff --git a/pkg-py/tests/test_execution_limits.py b/pkg-py/tests/test_execution_limits.py new file mode 100644 index 00000000..b52e940c --- /dev/null +++ b/pkg-py/tests/test_execution_limits.py @@ -0,0 +1,187 @@ +"""The address-space limit the worker puts on itself before it runs any code. + +These run real interpreters. A limit is only worth having if the kernel +actually enforces it, which an assertion about the arguments passed to +``setrlimit`` cannot show. +""" + +from __future__ import annotations + +import errno +import json +import os +import pathlib +import resource +import subprocess +import sys + +import pytest + +from commons._execution import _runtime + +RUNTIME_DIR = str(pathlib.Path(_runtime.__file__).parent) + +GIB = 1024**3 + +# Reporting the limit from inside the child is the only way to see it: the +# parent's own limit is untouched, by design. +REPORT = """ +import json, resource, sys +{pre} +import _limits +applied = _limits.apply_address_space_limit({request}) +soft, hard = resource.getrlimit(resource.RLIMIT_AS) +json.dump({{"applied": applied, "soft": soft, "hard": hard}}, sys.stdout) +""" + +REPORT_INHERITED = ( + "import json, resource, sys;" + " json.dump(resource.getrlimit(resource.RLIMIT_AS), sys.stdout)" +) + + +def apply_in_child(request: str = "", pre: str = "") -> dict[str, int | None]: + """Apply the limit in a fresh interpreter and report what stuck. + + ``pre`` runs before the limit is applied, which is how a host that has + already capped the worker is simulated. + """ + completed = subprocess.run( + [sys.executable, "-c", REPORT.format(pre=pre, request=request)], + env={**os.environ, "PYTHONPATH": RUNTIME_DIR}, + capture_output=True, + text=True, + check=True, + ) + return json.loads(completed.stdout) + + +def cap_the_child(limit: int) -> str: + """Child code that clamps address space before the limit is applied.""" + return f"resource.setrlimit(resource.RLIMIT_AS, ({limit}, {limit}))" + + +@pytest.fixture(scope="module") +def ceiling() -> int | None: + """The tightest limit a fresh child already has, or ``None`` for no limit. + + Everything below is expressed relative to this: a host that already caps + address space caps these tests too, and a case that assumed a fixed value + would assert the host's configuration when it means to assert the code's. + """ + reported = json.loads( + subprocess.run( + [sys.executable, "-c", REPORT_INHERITED], + capture_output=True, + text=True, + check=True, + ).stdout + ) + finite = [value for value in reported if value != resource.RLIM_INFINITY] + return min(finite) if finite else None + + +@pytest.fixture(scope="module") +def lower(ceiling: int | None) -> int: + """The limit the clamp cases pre-set on the child, chosen to be one the + child can actually reach.""" + return 2 * GIB if ceiling is None else min(2 * GIB, ceiling // 2) + + +@pytest.fixture(scope="module") +def refusal_errno(lower: int) -> int | None: + """The errno a fresh child gets from capping its address space, or + ``None`` when the call succeeds. + + macOS accepts the call on some releases and rejects it with ``EINVAL`` on + others, so this is a question about the running kernel; the platform name + cannot answer it. A child refused for any other reason (a seccomp profile + that blocks ``setrlimit``, say) may still enforce ``RLIMIT_AS``, and only + an ``EINVAL`` refusal lets the no-cap test below conclude the kernel has + no such limit. + """ + probe = ( + "import errno, json, resource, sys\n" + "try:\n" + f" resource.setrlimit(resource.RLIMIT_AS, ({lower}, {lower}))\n" + "except ValueError:\n" + " result = errno.EINVAL\n" + "except OSError as exc:\n" + " result = exc.errno\n" + "else:\n" + " result = 0\n" + "json.dump(result, sys.stdout)\n" + ) + completed = subprocess.run( + [sys.executable, "-c", probe], + capture_output=True, + text=True, + check=True, + ) + result = json.loads(completed.stdout) + return result if result != 0 else None + + +def test_the_default_limit_is_eight_gibibytes_and_is_the_one_in_force( + ceiling: int | None, refusal_errno: int | None +) -> None: + if refusal_errno is not None: + pytest.skip("this kernel does not enforce RLIMIT_AS") + if ceiling is not None and ceiling < 8 * GIB: + pytest.skip("this host already caps address space below the default") + reported = apply_in_child() + assert reported["applied"] == 8 * GIB + assert reported["soft"] == 8 * GIB + assert reported["hard"] == 8 * GIB + + +def test_a_lower_inherited_limit_is_not_raised( + lower: int, refusal_errno: int | None +) -> None: + if refusal_errno is not None: + pytest.skip("this kernel does not enforce RLIMIT_AS") + reported = apply_in_child(pre=cap_the_child(lower)) + assert reported["applied"] == lower + assert reported["soft"] == lower + + +def test_a_request_below_the_inherited_limit_still_applies( + lower: int, refusal_errno: int | None +) -> None: + if refusal_errno is not None: + pytest.skip("this kernel does not enforce RLIMIT_AS") + reported = apply_in_child(request=str(lower // 2), pre=cap_the_child(lower)) + assert reported["applied"] == lower // 2 + + +def test_a_kernel_that_refuses_the_limit_does_not_stop_the_worker( + refusal_errno: int | None, +) -> None: + """The worker runs on without the cap and still starts. + + The cap guards against a runaway allocation; the security boundary is the + sandbox. A host without the cap is worth reporting and still worth running + on. ``apply_in_child`` requires a clean exit, so reaching an answer at all + is half of what this asserts. + """ + if refusal_errno is None: + pytest.skip("this kernel enforces RLIMIT_AS") + if refusal_errno != errno.EINVAL: + pytest.skip("this host refuses the call for a reason of its own") + assert apply_in_child()["applied"] is None + + +def test_the_limit_module_does_not_import_commons() -> None: + """It runs inside the worker, which has no ``commons`` on its path.""" + completed = subprocess.run( + [ + sys.executable, + "-c", + "import _limits, sys; sys.exit('commons' in sys.modules)", + ], + env={**os.environ, "PYTHONPATH": RUNTIME_DIR}, + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr diff --git a/pkg-py/tests/test_execution_sandbox.py b/pkg-py/tests/test_execution_sandbox.py new file mode 100644 index 00000000..a607746d --- /dev/null +++ b/pkg-py/tests/test_execution_sandbox.py @@ -0,0 +1,125 @@ +"""The gate that decides whether the worker can be sandboxed on this host. + +The decision table is driven with constructed capability values, independent +of whatever the test machine happens to support, so every branch is reachable +from any host. The cases match pkg-r/tests/testthat/test-sandbox.R. +""" + +from __future__ import annotations + +import pytest + +from commons._execution._sandbox import ( + SandboxCapabilities, + protection_mode, + sandbox_capabilities, +) + +NOTHING = SandboxCapabilities( + landlock_abi=0, seccomp=False, seatbelt=False, userns=False +) + + +@pytest.fixture(autouse=True) +def _no_opt_in_from_the_environment(monkeypatch) -> None: + """A developer or CI host with the opt-in set should run the same suite.""" + monkeypatch.delenv("COMMONS_ALLOW_UNSAFE_FALLBACK", raising=False) + + +def test_the_host_probe_reports_every_mechanism() -> None: + capabilities = sandbox_capabilities() + assert isinstance(capabilities.landlock_abi, int) + assert isinstance(capabilities.seccomp, bool) + assert isinstance(capabilities.seatbelt, bool) + assert isinstance(capabilities.userns, bool) + + +@pytest.mark.parametrize("landlock_abi, userns", [(1, False), (0, True)]) +def test_linux_accepts_either_filesystem_sandbox(landlock_abi, userns) -> None: + capabilities = SandboxCapabilities( + landlock_abi=landlock_abi, seccomp=True, seatbelt=False, userns=userns + ) + assert protection_mode(capabilities, sysname="Linux") == "sandbox" + + +def test_linux_without_seccomp_is_refused() -> None: + capabilities = SandboxCapabilities( + landlock_abi=1, seccomp=False, seatbelt=False, userns=True + ) + with pytest.raises(RuntimeError, match="does not support seccomp") as caught: + protection_mode(capabilities, sysname="Linux") + assert "COMMONS_ALLOW_UNSAFE_FALLBACK" in str(caught.value) + + +def test_linux_without_a_filesystem_sandbox_is_refused() -> None: + capabilities = SandboxCapabilities( + landlock_abi=0, seccomp=True, seatbelt=False, userns=False + ) + with pytest.raises( + RuntimeError, match="neither Landlock nor unprivileged user namespaces" + ): + protection_mode(capabilities, sysname="Linux") + + +def test_a_refusal_names_the_opt_in_and_what_to_check() -> None: + capabilities = SandboxCapabilities( + landlock_abi=0, seccomp=True, seatbelt=False, userns=False + ) + with pytest.raises(RuntimeError) as caught: + protection_mode(capabilities, sysname="Linux") + message = str(caught.value) + assert "COMMONS_ALLOW_UNSAFE_FALLBACK" in message + assert "user.max_user_namespaces" in message + + +def test_macos_needs_seatbelt() -> None: + seatbelt = SandboxCapabilities( + landlock_abi=-1, seccomp=False, seatbelt=True, userns=False + ) + assert protection_mode(seatbelt, sysname="Darwin") == "sandbox" + with pytest.raises(RuntimeError, match="on Darwin") as caught: + protection_mode(NOTHING, sysname="Darwin") + assert "COMMONS_ALLOW_UNSAFE_FALLBACK" in str(caught.value) + + +def test_an_unknown_operating_system_is_refused_whatever_it_supports() -> None: + everything = SandboxCapabilities( + landlock_abi=5, seccomp=True, seatbelt=True, userns=True + ) + with pytest.raises(RuntimeError, match="on Windows") as caught: + protection_mode(everything, sysname="Windows") + assert "COMMONS_ALLOW_UNSAFE_FALLBACK" in str(caught.value) + + +@pytest.mark.parametrize("value", ["1", "true", "TRUE", " 1 ", "yes", "on"]) +def test_an_affirmative_opt_in_downgrades_a_refusal_to_guardrails( + monkeypatch, value +) -> None: + monkeypatch.setenv("COMMONS_ALLOW_UNSAFE_FALLBACK", value) + assert protection_mode(NOTHING, sysname="Windows") == "guardrails" + + +def test_the_gate_reads_this_host_when_given_nothing() -> None: + # The capability probe reports every mechanism unavailable until the + # ctypes modules exist, so the gate refuses every host it reads for + # itself: the safe direction to be wrong in while the sandbox is being + # built. + with pytest.raises(RuntimeError): + protection_mode() + + +def test_the_opt_in_does_not_downgrade_a_host_that_can_be_sandboxed( + monkeypatch, +) -> None: + monkeypatch.setenv("COMMONS_ALLOW_UNSAFE_FALLBACK", "1") + seatbelt = SandboxCapabilities( + landlock_abi=-1, seccomp=False, seatbelt=True, userns=False + ) + assert protection_mode(seatbelt, sysname="Darwin") == "sandbox" + + +@pytest.mark.parametrize("value", ["", "0", "false", "no", "off", "maybe"]) +def test_only_an_affirmative_opt_in_counts(monkeypatch, value) -> None: + monkeypatch.setenv("COMMONS_ALLOW_UNSAFE_FALLBACK", value) + with pytest.raises(RuntimeError): + protection_mode(NOTHING, sysname="Windows")