From e90fb85050dc0ec7779d4d0b4f8a339fc36448ed Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:05:34 +0100 Subject: [PATCH] fix: bind the interpreter into the probe sandbox The sandbox bound only /usr, /lib and /bin. On the CI runner setup-python puts Python under /opt/hostedtoolcache, so the worker could not start and every core_probe and campaign run crashed (7 tests, plus their replay without jsonschema). A venv, pyenv or uv do the same. The interpreter's prefix and every link on the way to it are now bound read-only, each at its own path. Reproduced with uv's Python 3.13 outside /usr, 92 tests pass under it and under the system Python. --- scripts/core_probe.py | 32 +++++++++++++++++++++++++++++--- tests/test_core_probe.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/scripts/core_probe.py b/scripts/core_probe.py index 1582459d0..c43181e93 100644 --- a/scripts/core_probe.py +++ b/scripts/core_probe.py @@ -682,6 +682,30 @@ def _bwrap_base(bwrap: str, network: bool) -> list[str]: return argv +def interpreter_paths() -> list[Path]: + """The trees the worker's Python lives in, when the sandbox's system + binds do not hold them: CI's setup-python under /opt/hostedtoolcache, a + venv, pyenv or uv. Each link on the way to the interpreter is a path too: + a venv's python points at uv's unversioned name, itself a link to the + versioned tree. Without them the worker could not start in the sandbox, + and every run crashed.""" + system = ("/usr/", "/bin/", "/lib") + found: list[Path] = [] + candidates = [Path(sys.prefix), Path(sys.base_prefix)] + hop = Path(sys.executable) + for _ in range(16): + candidates.append(hop.parent.parent) + if not hop.is_symlink(): + break + target = Path(os.readlink(hop)) + hop = target if target.is_absolute() else hop.parent / target + for path in candidates: + path = Path(os.path.abspath(path)) + if not str(path).startswith(system) and path not in found and path.exists(): + found.append(path) + return found + + def sandbox_argv(writable: Sequence[Path], readonly: Sequence[Path] = (), network: bool = False) -> tuple[list[str], str]: """The bubblewrap prefix a third-party program runs under, and its name. @@ -704,8 +728,10 @@ def sandbox_argv(writable: Sequence[Path], readonly: Sequence[Path] = (), return [], "environment only: bwrap cannot create a sandbox here" argv = _bwrap_base(bwrap, network) for path in readonly: - resolved = Path(path).resolve() - argv += ["--ro-bind", str(resolved), str(resolved)] + # Mounted at the path as the caller names it: a link's own path stays + # usable inside, its target's bytes behind it. + resolved, named = Path(path).resolve(), Path(os.path.abspath(path)) + argv += ["--ro-bind", str(resolved), str(named)] for path in writable: resolved = Path(path).resolve() resolved.mkdir(parents=True, exist_ok=True) @@ -756,7 +782,7 @@ def run_once(args: argparse.Namespace, system_dir: Path, scratch: Path, state_ou if strace: command = [strace, "-f", "-qq", "-s", "4096", "-e", "trace=openat,open", "-o", str(trace_path)] + command writable = [scratch] + [p for p in (png_dir, state_out.parent if state_out else None) if p] - readonly = [Path(__file__), Path(args.core), system_dir] + readonly = [Path(__file__), Path(args.core), system_dir, *interpreter_paths()] if args.content: readonly.append(Path(args.content).resolve().parent) readonly += [Path(value) for _, value in args.env if Path(value).is_absolute() and Path(value).exists()] diff --git a/tests/test_core_probe.py b/tests/test_core_probe.py index 0d3c7d358..9d2f37522 100644 --- a/tests/test_core_probe.py +++ b/tests/test_core_probe.py @@ -418,5 +418,45 @@ class Sandbox(unittest.TestCase): self.assertIn("--require-sandbox: environment only: bwrap not found", problems) + +class TheInterpreterIsInTheSandbox(unittest.TestCase): + """CI's setup-python, a venv and uv put Python outside /usr. The sandbox + bound only the system trees, so the worker could not start and every run + crashed on the runner.""" + + def test_each_link_to_the_interpreter_is_bound(self): + from unittest import mock # noqa: PLC0415 + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + real = root / "uv" / "cpython-3.13.12" / "bin" + real.mkdir(parents=True) + (real / "python3.13").write_text("") + alias = root / "uv" / "cpython-3.13" + alias.symlink_to(root / "uv" / "cpython-3.13.12") + venv = root / "venv" / "bin" + venv.mkdir(parents=True) + (venv / "python").symlink_to(alias / "bin" / "python3.13") + with mock.patch.object(core_probe.sys, "executable", str(venv / "python")), \ + mock.patch.object(core_probe.sys, "prefix", str(root / "venv")), \ + mock.patch.object(core_probe.sys, "base_prefix", str(root / "uv" / "cpython-3.13.12")): + paths = core_probe.interpreter_paths() + self.assertIn(root / "venv", paths) + self.assertIn(alias, paths) + self.assertIn(root / "uv" / "cpython-3.13.12", paths) + + def test_a_link_is_mounted_at_its_own_path(self): + with tempfile.TemporaryDirectory() as tmp: + target = Path(tmp) / "real" + target.mkdir() + link = Path(tmp) / "link" + link.symlink_to(target) + prefix, isolation = core_probe.sandbox_argv([], [link]) + if isolation != core_probe.SANDBOX: + self.skipTest(f"no sandbox here ({isolation})") + self.assertIn(["--ro-bind", str(target.resolve()), str(link)], + [prefix[i:i + 3] for i in range(len(prefix) - 2)]) + + if __name__ == "__main__": unittest.main()