Skip to content

Commit 9227eca

Browse files
feat: add bwrap runtime capability smoke test to hardened preflight
1 parent 3dccfc1 commit 9227eca

2 files changed

Lines changed: 218 additions & 3 deletions

File tree

csc_runner/sandbox.py

Lines changed: 100 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
Builds a hardened launcher argv using Linux-native tools:
44
- bubblewrap (bwrap): mount/pid/network namespace isolation
5-
- setpriv: --no-new-privs always; privilege drop when configured
5+
- setpriv: --no-new-privs (always), privilege drop (when configured)
66
- prlimit: resource limits (CPU, address space, processes, file size)
77
88
No Python preexec_fn. The security boundary is the kernel, not
@@ -49,6 +49,7 @@
4949
import platform
5050
import shutil
5151
import socket
52+
import subprocess
5253
from dataclasses import dataclass, field
5354

5455
_GLOB_META = frozenset("*?[")
@@ -300,6 +301,12 @@ def _resolve_writable_roots(write_bind_prefixes: list[str]) -> list[str]:
300301

301302
_REQUIRED_TOOLS: tuple[str, ...] = ("bwrap", "setpriv", "prlimit")
302303

304+
# Candidate probe executables, checked in order against bound paths.
305+
_PROBE_EXECUTABLES: tuple[tuple[str, str], ...] = (
306+
("/bin", "/bin/true"),
307+
("/usr", "/usr/bin/true"),
308+
)
309+
303310

304311
def verify_platform() -> None:
305312
"""Verify the platform is Linux. Raises SandboxError otherwise."""
@@ -343,21 +350,112 @@ def verify_network_disabled() -> None:
343350
raise SandboxError(f"network not disabled: found non-loopback interfaces: {', '.join(non_loopback)}")
344351

345352

353+
def _verify_bwrap_capabilities(config: SandboxConfig) -> None:
354+
"""Smoke test: verify bwrap can create the namespaces hardened mode needs.
355+
356+
Runs a representative bwrap probe using the same readonly system
357+
paths as the real hardened launcher. Exercises namespace creation
358+
(mount, network, pid). If the runtime blocks namespace operations
359+
(common on AppArmor-restricted Ubuntu or confined containers), this
360+
fails early with an actionable error instead of failing later during
361+
contract execution.
362+
363+
The probe executable is selected from the configured readonly bind
364+
paths to avoid false failures when the config does not include /bin
365+
but does include /usr.
366+
367+
Timeout: 5 seconds. This is a preflight check, not a performance test.
368+
"""
369+
# Build probe argv using the same readonly binds as real execution.
370+
probe_argv = ["bwrap"]
371+
372+
bound_paths: set[str] = set()
373+
for path in config.readonly_bind_paths:
374+
if os.path.exists(path):
375+
probe_argv.extend(["--ro-bind", path, path])
376+
bound_paths.add(path)
377+
378+
probe_argv.extend(
379+
[
380+
"--proc",
381+
"/proc",
382+
"--dev",
383+
"/dev",
384+
"--unshare-net",
385+
"--unshare-pid",
386+
"--new-session",
387+
"--die-with-parent",
388+
"--",
389+
]
390+
)
391+
392+
# Select probe executable from bound paths.
393+
probe_exe = None
394+
for parent_path, exe_path in _PROBE_EXECUTABLES:
395+
if parent_path in bound_paths and os.path.exists(exe_path):
396+
probe_exe = exe_path
397+
break
398+
399+
if probe_exe is None:
400+
raise SandboxError(
401+
"hardened mode runtime check failed: cannot select a probe "
402+
"executable from the configured readonly_bind_paths. "
403+
"Ensure at least /bin or /usr is in readonly_bind_paths and "
404+
"contains 'true'. "
405+
"See docs/deployment-modes.md for runtime prerequisites."
406+
)
407+
408+
probe_argv.append(probe_exe)
409+
410+
try:
411+
result = subprocess.run(
412+
probe_argv,
413+
capture_output=True,
414+
text=True,
415+
timeout=5,
416+
)
417+
except subprocess.TimeoutExpired:
418+
raise SandboxError(
419+
"hardened mode runtime check failed: bubblewrap capability "
420+
"probe timed out (5s). "
421+
"See docs/deployment-modes.md for runtime prerequisites."
422+
)
423+
except FileNotFoundError:
424+
raise SandboxError(
425+
"hardened mode runtime check failed: bwrap not found "
426+
"during capability probe (passed tool check but not executable). "
427+
"See docs/deployment-modes.md for runtime prerequisites."
428+
)
429+
430+
if result.returncode != 0:
431+
stderr = result.stderr.strip() or "<no stderr>"
432+
raise SandboxError(
433+
"hardened mode runtime check failed: bubblewrap could not "
434+
"create the required namespace sandbox. "
435+
"Common causes: AppArmor-restricted Ubuntu, container runtime "
436+
"confinement, disabled user namespaces, restrictive seccomp. "
437+
f"bwrap stderr: {stderr}. "
438+
"See docs/deployment-modes.md for runtime prerequisites."
439+
)
440+
441+
346442
def verify_hardened_runtime(config: SandboxConfig) -> None:
347443
"""Run all pre-flight checks for hardened mode.
348444
349445
Checks:
350446
1. Config values are valid.
351447
2. Platform is Linux.
352448
3. Required tools are on PATH (bwrap, setpriv, prlimit).
353-
4. Network sanity check (if config.require_network_disabled).
449+
4. bwrap can create namespaces in this runtime.
450+
5. Network sanity check (if config.require_network_disabled).
354451
355452
Call once at startup before spawning any sandbox subprocess.
356453
Raises SandboxError on any failure.
357454
"""
358455
validate_config(config)
359456
verify_platform()
360457
verify_tools()
458+
_verify_bwrap_capabilities(config)
361459

362460
if config.require_network_disabled:
363461
verify_network_disabled()

tests/test_sandbox.py

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import os
1010
import shutil
11+
import subprocess
1112

1213
import pytest
1314

@@ -17,6 +18,7 @@
1718
_is_under_any,
1819
_resolve_writable_roots,
1920
_validate_bind_prefix,
21+
_verify_bwrap_capabilities,
2022
build_hardened_command,
2123
check_command_allowed,
2224
default_hardened_config,
@@ -32,6 +34,21 @@ def _config(**overrides) -> SandboxConfig:
3234
return SandboxConfig(**overrides)
3335

3436

37+
def _mock_bwrap_success(*args, **kwargs):
38+
"""Monkeypatch target for subprocess.run that simulates bwrap success."""
39+
return subprocess.CompletedProcess(args=args[0], returncode=0, stdout="", stderr="")
40+
41+
42+
def _mock_bwrap_failure(*args, **kwargs):
43+
"""Monkeypatch target for subprocess.run that simulates bwrap failure."""
44+
return subprocess.CompletedProcess(
45+
args=args[0],
46+
returncode=1,
47+
stdout="",
48+
stderr="bwrap: loopback: Failed RTM_NEWADDR: Operation not permitted",
49+
)
50+
51+
3552
# ---------------------------------------------------------------------------
3653
# Config validation
3754
# ---------------------------------------------------------------------------
@@ -235,10 +252,108 @@ def _boom():
235252
verify_network_disabled()
236253

237254

255+
# ---------------------------------------------------------------------------
256+
# bwrap capability smoke test
257+
# ---------------------------------------------------------------------------
258+
259+
260+
class TestVerifyBwrapCapabilities:
261+
def test_bwrap_smoke_success(self, monkeypatch):
262+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", lambda p: True)
263+
monkeypatch.setattr("csc_runner.sandbox.subprocess.run", _mock_bwrap_success)
264+
config = _config()
265+
_verify_bwrap_capabilities(config)
266+
267+
def test_bwrap_smoke_failure_gives_clear_error(self, monkeypatch):
268+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", lambda p: True)
269+
monkeypatch.setattr("csc_runner.sandbox.subprocess.run", _mock_bwrap_failure)
270+
config = _config()
271+
with pytest.raises(SandboxError, match="runtime check failed"):
272+
_verify_bwrap_capabilities(config)
273+
274+
def test_bwrap_smoke_failure_mentions_docs(self, monkeypatch):
275+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", lambda p: True)
276+
monkeypatch.setattr("csc_runner.sandbox.subprocess.run", _mock_bwrap_failure)
277+
config = _config()
278+
with pytest.raises(SandboxError, match="deployment-modes"):
279+
_verify_bwrap_capabilities(config)
280+
281+
def test_bwrap_smoke_failure_includes_stderr(self, monkeypatch):
282+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", lambda p: True)
283+
monkeypatch.setattr("csc_runner.sandbox.subprocess.run", _mock_bwrap_failure)
284+
config = _config()
285+
with pytest.raises(SandboxError, match="RTM_NEWADDR"):
286+
_verify_bwrap_capabilities(config)
287+
288+
def test_bwrap_smoke_empty_stderr_handled(self, monkeypatch):
289+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", lambda p: True)
290+
291+
def _fail_no_stderr(*args, **kwargs):
292+
return subprocess.CompletedProcess(args=args[0], returncode=1, stdout="", stderr="")
293+
294+
monkeypatch.setattr("csc_runner.sandbox.subprocess.run", _fail_no_stderr)
295+
config = _config()
296+
with pytest.raises(SandboxError, match="<no stderr>"):
297+
_verify_bwrap_capabilities(config)
298+
299+
def test_bwrap_probe_uses_usr_bin_true_when_no_bin(self, monkeypatch):
300+
"""Config with /usr but not /bin should select /usr/bin/true."""
301+
302+
def _selective_exists(path):
303+
if path in ("/usr", "/usr/bin/true"):
304+
return True
305+
if path == "/bin":
306+
return False
307+
return True
308+
309+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", _selective_exists)
310+
311+
captured_argv = []
312+
313+
def _capture_run(argv, **kwargs):
314+
captured_argv.extend(argv)
315+
return subprocess.CompletedProcess(args=argv, returncode=0, stdout="", stderr="")
316+
317+
monkeypatch.setattr("csc_runner.sandbox.subprocess.run", _capture_run)
318+
319+
config = _config(readonly_bind_paths=("/usr",))
320+
_verify_bwrap_capabilities(config)
321+
322+
assert "/usr/bin/true" in captured_argv
323+
assert "/bin/true" not in captured_argv
324+
325+
def test_bwrap_probe_no_executable_found(self, monkeypatch):
326+
"""Config with no usable readonly paths should fail clearly."""
327+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", lambda p: False)
328+
329+
config = _config(readonly_bind_paths=())
330+
with pytest.raises(SandboxError, match="cannot select a probe executable"):
331+
_verify_bwrap_capabilities(config)
332+
333+
def test_bwrap_probe_timeout_handled(self, monkeypatch):
334+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", lambda p: True)
335+
336+
def _timeout(*args, **kwargs):
337+
raise subprocess.TimeoutExpired(cmd=args[0], timeout=5)
338+
339+
monkeypatch.setattr("csc_runner.sandbox.subprocess.run", _timeout)
340+
341+
config = _config()
342+
with pytest.raises(SandboxError, match="timed out"):
343+
_verify_bwrap_capabilities(config)
344+
345+
346+
# ---------------------------------------------------------------------------
347+
# verify_hardened_runtime (full preflight)
348+
# ---------------------------------------------------------------------------
349+
350+
238351
class TestVerifyHardenedRuntime:
239352
def test_full_preflight(self, monkeypatch):
240353
monkeypatch.setattr("csc_runner.sandbox.platform.system", lambda: "Linux")
241354
monkeypatch.setattr("csc_runner.sandbox.shutil.which", lambda name: f"/usr/bin/{name}")
355+
monkeypatch.setattr("csc_runner.sandbox.subprocess.run", _mock_bwrap_success)
356+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", lambda p: True)
242357
monkeypatch.setattr(
243358
"csc_runner.sandbox.socket.if_nameindex",
244359
lambda: [(1, "lo")],
@@ -249,6 +364,8 @@ def test_full_preflight(self, monkeypatch):
249364
def test_network_check_skippable(self, monkeypatch):
250365
monkeypatch.setattr("csc_runner.sandbox.platform.system", lambda: "Linux")
251366
monkeypatch.setattr("csc_runner.sandbox.shutil.which", lambda name: f"/usr/bin/{name}")
367+
monkeypatch.setattr("csc_runner.sandbox.subprocess.run", _mock_bwrap_success)
368+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", lambda p: True)
252369
config = _config(require_network_disabled=False)
253370
verify_hardened_runtime(config)
254371

@@ -479,7 +596,7 @@ def test_relative_read_prefix_rejected(self, tmp_path):
479596
self._build(tmp_path, read_bind_prefixes=["relative/path"])
480597

481598
def test_system_paths_readonly(self, tmp_path, monkeypatch):
482-
monkeypatch.setattr("os.path.exists", lambda p: True)
599+
monkeypatch.setattr("csc_runner.sandbox.os.path.exists", lambda p: True)
483600
cwd = str(tmp_path / "workspace")
484601
os.makedirs(cwd, exist_ok=True)
485602
config = _config(readonly_bind_paths=("/usr", "/lib"))

0 commit comments

Comments
 (0)