Skip to content
Merged
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
9 changes: 9 additions & 0 deletions pkg-py/src/commons/_execution/_runtime/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
43 changes: 43 additions & 0 deletions pkg-py/src/commons/_execution/_runtime/_limits.py
Original file line number Diff line number Diff line change
@@ -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
130 changes: 130 additions & 0 deletions pkg-py/src/commons/_execution/_sandbox.py
Original file line number Diff line number Diff line change
@@ -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}"
)
187 changes: 187 additions & 0 deletions pkg-py/tests/test_execution_limits.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading