417 lines
14 KiB
Python
417 lines
14 KiB
Python
# -*- coding: utf-8 -*-
|
|
# pylint: disable=unused-argument
|
|
"""Unit tests for Linux Landlock sandbox and probe_sandbox_support."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from unittest.mock import MagicMock, mock_open, patch
|
|
|
|
import pytest
|
|
|
|
from pineagents.sandbox import (
|
|
MountSpec,
|
|
SandboxCapability,
|
|
SandboxConfig,
|
|
SandboxMode,
|
|
create_sandbox,
|
|
probe_sandbox_support,
|
|
)
|
|
from pineagents.sandbox.config import (
|
|
_probe_linux_landlock,
|
|
_probe_macos_seatbelt,
|
|
detect_platform_mode,
|
|
)
|
|
|
|
# os.uname is Linux/macOS only; skip entirely on Windows.
|
|
pytestmark = pytest.mark.skipif(
|
|
sys.platform == "win32",
|
|
reason=(
|
|
"Linux sandbox tests require os.uname which is unavailable on Windows"
|
|
),
|
|
)
|
|
|
|
# ============================================================================
|
|
# probe_sandbox_support() — platform routing
|
|
# ============================================================================
|
|
|
|
|
|
class TestProbeSandboxSupport:
|
|
"""Test probe_sandbox_support() for each platform."""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _clear_probe_cache(self):
|
|
"""Clear lru_cache so each test starts fresh."""
|
|
probe_sandbox_support.cache_clear()
|
|
yield
|
|
probe_sandbox_support.cache_clear()
|
|
|
|
@patch("sys.platform", "darwin")
|
|
@patch("shutil.which", return_value="/usr/bin/sandbox-exec")
|
|
def test_darwin_delegates_to_seatbelt(self, mock_which):
|
|
result = probe_sandbox_support()
|
|
assert result.supported is True
|
|
assert result.mode == SandboxMode.SEATBELT
|
|
|
|
@patch("sys.platform", "linux")
|
|
@patch("shutil.which", return_value=None) # bwrap not found
|
|
@patch("os.uname")
|
|
def test_linux_delegates_to_landlock(self, mock_uname, mock_which):
|
|
# bwrap not found → falls through to Landlock → kernel too old
|
|
mock_uname.return_value = MagicMock(release="4.0.0")
|
|
result = probe_sandbox_support()
|
|
# Should go through _probe_linux_landlock and fail on kernel version
|
|
assert result.mode == SandboxMode.NONE
|
|
assert "4.0" in result.reason
|
|
|
|
@patch("sys.platform", "win32")
|
|
@patch("pineagents.sandbox.config._probe_windows")
|
|
def test_windows_calls_windows_probe(self, mock_probe):
|
|
mock_probe.return_value = SandboxCapability(
|
|
supported=False,
|
|
mode=SandboxMode.NONE,
|
|
reason="Not running on Windows",
|
|
)
|
|
result = probe_sandbox_support()
|
|
mock_probe.assert_called_once()
|
|
assert result.supported is False
|
|
|
|
@patch("sys.platform", "freebsd13")
|
|
def test_unknown_platform_returns_unsupported(self):
|
|
result = probe_sandbox_support()
|
|
assert result.supported is False
|
|
assert result.mode == SandboxMode.NONE
|
|
|
|
|
|
# ============================================================================
|
|
# _probe_linux_landlock() — detailed Linux detection
|
|
# ============================================================================
|
|
|
|
|
|
class TestProbeLinuxLandlock:
|
|
"""Test Linux Landlock probe logic with various kernel/LSM scenarios."""
|
|
|
|
@patch("os.uname")
|
|
def test_kernel_too_old(self, mock_uname):
|
|
mock_uname.return_value = MagicMock(release="5.10.112-generic")
|
|
result = _probe_linux_landlock()
|
|
assert result.supported is False
|
|
assert "5.10" in result.reason
|
|
assert "< 5.13" in result.reason
|
|
assert result.mode == SandboxMode.NONE
|
|
|
|
@patch("os.uname")
|
|
def test_kernel_version_parse_error(self, mock_uname):
|
|
mock_uname.return_value = MagicMock(release="invalid-kernel")
|
|
result = _probe_linux_landlock()
|
|
assert result.supported is False
|
|
assert "Cannot parse" in result.reason
|
|
|
|
@patch("builtins.open", mock_open(read_data="capability,yama,apparmor"))
|
|
@patch("os.uname")
|
|
def test_landlock_not_in_lsm(self, mock_uname):
|
|
mock_uname.return_value = MagicMock(release="5.15.0-125-generic")
|
|
result = _probe_linux_landlock()
|
|
assert result.supported is False
|
|
assert "not in LSM list" in result.reason
|
|
|
|
@patch("builtins.open", side_effect=OSError("Permission denied"))
|
|
@patch("os.uname")
|
|
def test_lsm_file_unreadable(self, mock_uname, mock_file):
|
|
mock_uname.return_value = MagicMock(release="5.15.0-125-generic")
|
|
result = _probe_linux_landlock()
|
|
assert result.supported is False
|
|
assert "Cannot read" in result.reason
|
|
|
|
@patch("platform.machine", return_value="x86_64")
|
|
@patch(
|
|
"builtins.open",
|
|
mock_open(read_data="lockdown,capability,landlock,yama,apparmor"),
|
|
)
|
|
@patch("os.uname")
|
|
def test_landlock_supported_abi_v4(self, mock_uname, mock_machine):
|
|
mock_uname.return_value = MagicMock(release="6.7.0-generic")
|
|
|
|
# Mock ctypes.CDLL to return a fake libc with syscall returning 4
|
|
mock_libc = MagicMock()
|
|
mock_libc.syscall.return_value = 4 # ABI v4
|
|
|
|
with (
|
|
patch("ctypes.CDLL", return_value=mock_libc),
|
|
patch(
|
|
"ctypes.util.find_library",
|
|
return_value="libc.so.6",
|
|
),
|
|
):
|
|
result = _probe_linux_landlock()
|
|
|
|
assert result.supported is True
|
|
assert result.mode == SandboxMode.LANDLOCK
|
|
assert result.landlock_abi_version == 4
|
|
assert "ABI v4" in result.reason
|
|
|
|
@patch("platform.machine", return_value="x86_64")
|
|
@patch("builtins.open", mock_open(read_data="landlock,capability"))
|
|
@patch("os.uname")
|
|
def test_landlock_syscall_fails(self, mock_uname, mock_machine):
|
|
mock_uname.return_value = MagicMock(release="5.15.0-generic")
|
|
|
|
mock_libc = MagicMock()
|
|
mock_libc.syscall.return_value = -1 # failure
|
|
|
|
with (
|
|
patch("ctypes.CDLL", return_value=mock_libc),
|
|
patch(
|
|
"ctypes.util.find_library",
|
|
return_value="libc.so.6",
|
|
),
|
|
patch("ctypes.get_errno", return_value=38),
|
|
):
|
|
result = _probe_linux_landlock()
|
|
|
|
assert result.supported is False
|
|
assert "syscall failed" in result.reason
|
|
|
|
|
|
# ============================================================================
|
|
# _probe_macos_seatbelt()
|
|
# ============================================================================
|
|
|
|
|
|
class TestProbeMacosSeatbelt:
|
|
"""Test macOS Seatbelt probe."""
|
|
|
|
@patch("shutil.which", return_value="/usr/bin/sandbox-exec")
|
|
def test_sandbox_exec_available(self, mock_which):
|
|
result = _probe_macos_seatbelt()
|
|
assert result.supported is True
|
|
assert result.mode == SandboxMode.SEATBELT
|
|
|
|
@patch("shutil.which", return_value=None)
|
|
def test_sandbox_exec_not_found(self, mock_which):
|
|
result = _probe_macos_seatbelt()
|
|
assert result.supported is False
|
|
assert result.mode == SandboxMode.NONE
|
|
|
|
|
|
# ============================================================================
|
|
# detect_platform_mode() — integration with probe
|
|
# ============================================================================
|
|
|
|
|
|
class TestDetectPlatformMode:
|
|
"""Test that detect_platform_mode uses probe results."""
|
|
|
|
@patch("pineagents.sandbox.config.probe_sandbox_support")
|
|
def test_returns_probe_mode(self, mock_probe):
|
|
mock_probe.return_value = SandboxCapability(
|
|
supported=True,
|
|
mode=SandboxMode.LANDLOCK,
|
|
reason="ok",
|
|
landlock_abi_version=3,
|
|
)
|
|
assert detect_platform_mode() == SandboxMode.LANDLOCK
|
|
|
|
@patch("pineagents.sandbox.config.probe_sandbox_support")
|
|
def test_returns_none_when_unsupported(self, mock_probe):
|
|
mock_probe.return_value = SandboxCapability(
|
|
supported=False,
|
|
mode=SandboxMode.NONE,
|
|
reason="too old",
|
|
)
|
|
assert detect_platform_mode() == SandboxMode.NONE
|
|
|
|
|
|
# ============================================================================
|
|
# LinuxSandbox._generate_sandbox_script — rule compilation
|
|
# ============================================================================
|
|
|
|
|
|
class TestLinuxSandboxRuleCompilation:
|
|
"""Test that Landlock rules are correctly generated from SandboxConfig."""
|
|
|
|
def test_basic_workspace_mount(self):
|
|
from pineagents.sandbox.linux_sandbox import _generate_sandbox_script
|
|
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.LANDLOCK,
|
|
workspace_dir="/home/user/project",
|
|
mounts=[MountSpec(path="/home/user/project", writable=True)],
|
|
)
|
|
script = _generate_sandbox_script(
|
|
config,
|
|
"echo hello",
|
|
"/home/user/project",
|
|
1,
|
|
)
|
|
|
|
# Should contain the workspace path with write access
|
|
assert "/home/user/project" in script
|
|
assert "add_path" in script
|
|
assert "exec" in script.lower()
|
|
|
|
def test_readonly_mount(self):
|
|
from pineagents.sandbox.linux_sandbox import (
|
|
_FS_READ_ACCESS,
|
|
_FS_WRITE_ACCESS,
|
|
_generate_sandbox_script,
|
|
)
|
|
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.LANDLOCK,
|
|
workspace_dir="/home/user/project",
|
|
mounts=[
|
|
MountSpec(path="/home/user/project", writable=True),
|
|
MountSpec(path="/opt/data", writable=False),
|
|
],
|
|
)
|
|
with patch("os.path.exists", return_value=True):
|
|
script = _generate_sandbox_script(
|
|
config,
|
|
"ls",
|
|
"/home/user/project",
|
|
1,
|
|
)
|
|
|
|
# /opt/data should be in the script with read-only access
|
|
assert "/opt/data" in script
|
|
|
|
def test_deny_paths_excluded(self):
|
|
from pineagents.sandbox.linux_sandbox import _generate_sandbox_script
|
|
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.LANDLOCK,
|
|
workspace_dir="/home/user/project",
|
|
mounts=[MountSpec(path="/home/user/project", writable=True)],
|
|
allow_read_all=False,
|
|
deny_paths=["/home/user/.ssh"],
|
|
)
|
|
script = _generate_sandbox_script(
|
|
config,
|
|
"ls",
|
|
"/home/user/project",
|
|
1,
|
|
)
|
|
|
|
# deny_paths should NOT appear as an add_path call
|
|
# (the path should not be granted access)
|
|
lines_with_ssh = [
|
|
line
|
|
for line in script.split("\n")
|
|
if ".ssh" in line and "add_path" in line
|
|
]
|
|
assert len(lines_with_ssh) == 0
|
|
|
|
def test_executable_false(self):
|
|
from pineagents.sandbox.linux_sandbox import (
|
|
_FS_EXEC_ACCESS,
|
|
_generate_sandbox_script,
|
|
)
|
|
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.LANDLOCK,
|
|
workspace_dir="/home/user/project",
|
|
mounts=[
|
|
MountSpec(
|
|
path="/home/user/project",
|
|
writable=True,
|
|
executable=True,
|
|
),
|
|
MountSpec(
|
|
path="/tmp/untrusted",
|
|
writable=True,
|
|
executable=False,
|
|
),
|
|
],
|
|
)
|
|
script = _generate_sandbox_script(
|
|
config,
|
|
"ls",
|
|
"/home/user/project",
|
|
1,
|
|
)
|
|
|
|
# /tmp/untrusted should NOT have EXEC access bit
|
|
# Find the line with /tmp/untrusted
|
|
for line in script.split("\n"):
|
|
if "/tmp/untrusted" in line and "add_path" in line:
|
|
# The hex access mask should NOT include EXEC (0x1)
|
|
assert (
|
|
f"0x{_FS_EXEC_ACCESS:x}" not in line
|
|
or "0x1" not in line.split(",")[1]
|
|
)
|
|
break
|
|
|
|
|
|
# ============================================================================
|
|
# Governance: sandbox_available=False when the platform cannot sandbox
|
|
# ============================================================================
|
|
|
|
|
|
class TestGovernanceSandboxUnavailable:
|
|
"""Probe degradation: sandbox_available is False when unsupported.
|
|
|
|
(A shell SANDBOX_FALLBACK then runs unsandboxed via ALLOW in
|
|
``assert_policy``; that behavior is covered in test_policy.py.)
|
|
"""
|
|
|
|
def test_sandbox_fallback_becomes_ask(self):
|
|
"""When the platform cannot sandbox, ``sandbox_available`` is False."""
|
|
cap = SandboxCapability(
|
|
supported=False,
|
|
mode=SandboxMode.NONE,
|
|
reason="Kernel 5.10 < 5.13, Landlock unavailable",
|
|
)
|
|
|
|
from pineagents.governance.resource_governor import ResourceGovernor
|
|
|
|
governor = ResourceGovernor(workspace_dir="/tmp/test_ws")
|
|
|
|
# Mock policy loading to avoid filesystem operations
|
|
with (
|
|
patch(
|
|
"pineagents.governance.resource_governor.load_governance_policy",
|
|
) as mock_load,
|
|
patch("pathlib.Path.mkdir"),
|
|
patch(
|
|
"pineagents.governance.resource_governor.probe_sandbox_support",
|
|
return_value=cap,
|
|
),
|
|
):
|
|
mock_policy = MagicMock()
|
|
mock_load.return_value = mock_policy
|
|
governor.start()
|
|
|
|
assert governor.sandbox_available is False
|
|
assert (
|
|
governor.sandbox_capability.reason
|
|
== "Kernel 5.10 < 5.13, Landlock unavailable"
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# Platform compatibility guard — cross-platform downgrade
|
|
# ============================================================================
|
|
|
|
|
|
class TestCreateSandboxLandlockDowngrade:
|
|
"""Test that LANDLOCK mode downgrades on non-linux platforms."""
|
|
|
|
@patch("pineagents.sandbox.config.sys")
|
|
@patch(
|
|
"pineagents.sandbox.config.detect_platform_mode",
|
|
return_value=SandboxMode.NONE,
|
|
)
|
|
def test_landlock_mode_on_darwin_downgrades(self, mock_detect, mock_sys):
|
|
"""LANDLOCK on macOS downgrades to platform default."""
|
|
from pineagents.sandbox.local_sandbox import NoneSandbox
|
|
|
|
mock_sys.platform = "darwin"
|
|
config = SandboxConfig(
|
|
mode=SandboxMode.LANDLOCK,
|
|
workspace_dir="/tmp/ws",
|
|
)
|
|
sb = create_sandbox(config)
|
|
assert isinstance(sb, NoneSandbox)
|
|
mock_detect.assert_called_once()
|