From fd449fdae36a2f93b6554ee2f2b2e133b6c08b56 Mon Sep 17 00:00:00 2001 From: Abdessamad Derraz <3028866+Abdess@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:53:35 +0100 Subject: [PATCH] refactor: refuse unsandboxed campaign runs Without a working bubblewrap the pass is refused and nothing third-party runs, probes require the sandbox and builds are refused, unless --allow-unsandboxed is given. A job report holding an alert raises an autorun alert. --- schemas/campaign-run.schema.json | 4 ++-- scripts/autorun.py | 5 ++++ scripts/campaign.py | 36 ++++++++++++++++++++++++---- tests/test_autorun.py | 13 ++++++++++ tests/test_campaign.py | 41 ++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 6 deletions(-) diff --git a/schemas/campaign-run.schema.json b/schemas/campaign-run.schema.json index 325cf67b1..9f13b28bd 100644 --- a/schemas/campaign-run.schema.json +++ b/schemas/campaign-run.schema.json @@ -13,8 +13,8 @@ "started_at": { "type": "string", "format": "date-time" }, "ended_at": { "type": "string", "format": "date-time" }, "result": { - "enum": ["complete", "budget", "paused", "stopped"], - "description": "complete: nothing runnable was left. budget: the time budget ended the pass. paused: a busy host or a full disk ended it, the next pass resumes. stopped: interrupted." + "enum": ["complete", "budget", "paused", "stopped", "refused"], + "description": "complete: nothing runnable was left. budget: the time budget ended the pass. paused: a busy host or a full disk ended it, the next pass resumes. stopped: interrupted. refused: no sandbox for third-party code, nothing ran." }, "detail": { "type": "string" }, "counts": { diff --git a/scripts/autorun.py b/scripts/autorun.py index 5921e46ae..dfc4fecdf 100644 --- a/scripts/autorun.py +++ b/scripts/autorun.py @@ -1006,6 +1006,11 @@ def run_job(ctx: Context, name: str) -> tuple[str, str]: else previous.get("consecutive_failures", 0) + 1, } state["jobs"][name] = record + # A job's report can raise an alert of its own: campaign.py refusing to + # run third-party code outside its sandbox. + alert = runner.report.get("alert") if isinstance(runner.report, dict) else None + if result != DEFERRED: + set_alert(state, f"job:{name}", bool(alert), str(alert or ""), now_iso()) prune_logs(ctx.home / "logs") save_state(ctx.state_path, state) return result, detail diff --git a/scripts/campaign.py b/scripts/campaign.py index 908065527..081dc1339 100644 --- a/scripts/campaign.py +++ b/scripts/campaign.py @@ -91,6 +91,7 @@ PROBE_TIMEOUT = 900 BUILD_TIMEOUT = 3600 LOG_DAYS = 30 ROMFETCH_TRANSIENT = 3 +EXIT_REFUSED = 3 class Pause(RuntimeError): @@ -233,6 +234,9 @@ class Settings: network: bool = True delays: tuple[float, ...] = NETWORK_DELAYS echo: bool = True + # Third-party code (a nightly core, a project's Makefile) runs only under + # bubblewrap unless this is set. + allow_unsandboxed: bool = False @dataclass @@ -656,7 +660,10 @@ class Cores: makefile = "Makefile.libretro" if (src / "Makefile.libretro").is_file() else "Makefile" jobs = str(max(1, (os.cpu_count() or 2) // 2)) command = ["make", "-f", makefile, f"-j{jobs}", "platform=unix"] - prefix, isolation = core_probe.sandbox_argv([src], private_tmp=True) + prefix, isolation = core_probe.sandbox_argv([src]) + if isolation != core_probe.SANDBOX and not self.c.settings.allow_unsandboxed: + raise Unavailable(f"refused: no sandbox to build {source} in ({isolation}), " + f"--allow-unsandboxed overrides") step(prefix + command, cwd=src, timeout=BUILD_TIMEOUT) built = sorted(src.rglob("*_libretro.so"), key=lambda p: p.stat().st_mtime) if not built: @@ -842,6 +849,8 @@ class Campaign: "--emulator", emulator, "--subject", subject, "--max-load", str(self.host.max_load), "--timeout", str(self.settings.probe_timeout), "--work-dir", str(self.paths.root / "tmp" / "core_probe")] + if not self.settings.allow_unsandboxed: + argv.append("--require-sandbox") if core.artifact: argv += ["--core-artifact", core.artifact] if core.repo: @@ -1232,6 +1241,16 @@ class Campaign: if dry_run: self.describe(units, system) return current + isolation = core_probe.sandbox_argv([])[1] + if isolation != core_probe.SANDBOX and not self.settings.allow_unsandboxed: + # Fail closed: no core and no build runs outside the sandbox. + current.result = "refused" + current.detail = (f"no sandbox for third-party code ({isolation}). Install bubblewrap, " + f"or pass --allow-unsandboxed") + self.log(f"pass refused: {current.detail}") + self.write_run_line(current) + save_state(self.paths.state, self.state) + return current for unit in units: for content in self.unit_contents(unit, system): self.store.hold(content, unit.emulator) @@ -1500,6 +1519,8 @@ def main(argv: Sequence[str] | None = None) -> int: run_p.add_argument("--romfetch", default="", help=f"romfetch command (default: ${ROMFETCH_ENV}, else romfetch on PATH)") run_p.add_argument("--offline", action="store_true", help="no buildbot, no romfetch, no build") + run_p.add_argument("--allow-unsandboxed", action="store_true", + help="run cores and builds without bubblewrap when it is missing (refused by default)") run_p.add_argument("--dry-run", action="store_true", help="print the plan, run nothing") run_p.add_argument("--commit", action="store_true", help="commit records, findings and each changed profile behind the gate") @@ -1535,7 +1556,8 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command == "run": settings = Settings(budget=args.budget, max_load_wait=args.max_load_wait, min_free_gb=args.min_free_gb, cache_cap_gb=args.cache_cap_gb, - romfetch=args.romfetch, network=not args.offline) + romfetch=args.romfetch, network=not args.offline, + allow_unsandboxed=args.allow_unsandboxed) campaign = Campaign(paths, settings) if args.command == "status": report = status_report(campaign) @@ -1559,8 +1581,14 @@ def main(argv: Sequence[str] | None = None) -> int: print(f"campaign: deferred, {exc}", file=sys.stderr) return 75 counts, _, _ = campaign.counts() - print(json.dumps({"result": current.result, "detail": current.detail, "counts": counts, - "records": len(current.records), "findings": current.findings})) + report = {"result": current.result, "detail": current.detail, "counts": counts, + "records": len(current.records), "findings": current.findings} + if current.result == "refused": + # autorun raises an alert from this key of a job's report. + report["alert"] = current.detail + print(json.dumps(report)) + if current.result == "refused": + return EXIT_REFUSED return 75 if current.result == "paused" else 0 diff --git a/tests/test_autorun.py b/tests/test_autorun.py index b65b3a260..7e62b0a01 100644 --- a/tests/test_autorun.py +++ b/tests/test_autorun.py @@ -236,6 +236,19 @@ class StepEnvironment(Scratch): self.assertEqual(given["GITHUB_TOKEN"], "t1") +class ReportAlert(Scratch): + def test_a_report_alert_is_raised_then_cleared(self): + write = "import json, sys; json.dump({'alert': sys.argv[1]} if sys.argv[1] else {}, open('r.json', 'w'))" + for message, expected in (("no sandbox", True), ("", False)): + ctx = self.context({"x": {"worktree": False, "min_free_gb": 0, "report": "r.json", + "steps": [[PY, "-c", write, message]]}}) + ar.run_job(ctx, "x") + alerts = self.state(ctx).get("alerts", {}) + self.assertEqual("job:x" in alerts, expected) + if expected: + self.assertEqual(alerts["job:x"]["detail"], "no sandbox") + + class Retry(Scratch): def _runs(self, *outcomes: ar.Outcome): queue = list(outcomes) diff --git a/tests/test_campaign.py b/tests/test_campaign.py index 359931812..bb31cddc3 100644 --- a/tests/test_campaign.py +++ b/tests/test_campaign.py @@ -429,6 +429,45 @@ class ChildEnvironment(unittest.TestCase): self.assertNotIn("GITHUB_TOKEN", seen[0]) +class FailClosed(Workspace): + """No core and no build runs outside the sandbox unless asked.""" + + def test_a_pass_without_a_sandbox_is_refused(self) -> None: + host = FakeHost() + host.results = [subprocess.CompletedProcess([], 0, "{}", "")] + c = self.make(host) + with unittest.mock.patch.object(campaign.core_probe.shutil, "which", return_value=None): + current = c.run(set(), None) + self.assertEqual(current.result, "refused") + self.assertIn("bwrap not found", current.detail) + self.assertEqual(host.calls, []) + self.assertEqual(mr.read_campaign(self.root, mr.CAMPAIGN_RUNS)[-1]["result"], "refused") + + def test_the_override_lets_it_run(self) -> None: + c = self.make(allow_unsandboxed=True) + c.unit_cores = lambda *a, **k: [] + with unittest.mock.patch.object(campaign.core_probe.shutil, "which", return_value=None): + self.assertEqual(c.run(set(), None).result, "complete") + + def test_every_probe_requires_the_sandbox(self) -> None: + core = campaign.Core("fake", self.tmp / "x.so", "a" * 64) + self.assertIn("--require-sandbox", self.make().probe_args("run", core, "fake", "s", None, None)) + self.assertNotIn("--require-sandbox", self.make(allow_unsandboxed=True).probe_args( + "run", core, "fake", "s", None, None)) + + def test_a_build_without_a_sandbox_is_refused(self) -> None: + c = self.make() + src = c.paths.home / "src" / campaign.slug("https://example.invalid/x") + (src / ".git").mkdir(parents=True) + (src / "Makefile").write_text("all:\n") + host = c.host + host.results = [subprocess.CompletedProcess([], 0, "", "")] + with unittest.mock.patch.object(campaign.core_probe.shutil, "which", return_value=None), \ + self.assertRaisesRegex(campaign.Unavailable, "refused: no sandbox"): + c.cores.compile("https://example.invalid/x", "a" * 40, self.tmp / "out") + self.assertFalse(any(call[0] == "make" for call in host.calls)) + + class ProbeRuns(Workspace): def completed(self, code: int, stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess: return subprocess.CompletedProcess([], code, stdout, stderr) @@ -754,6 +793,8 @@ class FakeCorePasses(Workspace): current = c.run(set(), None) self.assertEqual(current.result, "complete") records = [s.record for s in mr.read_records(self.root)] + isolations = {rep["isolation"] for r in records for v in r["variants"] for rep in v["repetitions"]} + self.assertEqual(isolations, {campaign.core_probe.SANDBOX}) kinds = sorted(r["subject"].split("-")[0] for r in records) self.assertEqual(kinds, ["alias", "bench", "boot", "files"]) boot = next(r for r in records if r["subject"].startswith("boot"))