Source code for daklib.sandbox

# SPDX-License-Identifier: GPL-2.0-or-later
# © 2026, Ansgar 🙀 <ansgar@debian.org>

"""Sandbox external commands via `systemd-run --user`"""

import logging
import os
import subprocess
from collections.abc import Sequence
from dataclasses import dataclass
from functools import cache

from daklib.config import Config

_logger = logging.getLogger(__name__)


[docs] @dataclass class Sandbox: private_devices = True private_ipc = True private_network = True inaccessible_paths: Sequence[str] | None = None extra_inaccessible_paths: Sequence[str] = () temporary_file_systems: Sequence[str] | None = None extra_temporary_file_systems: Sequence[str] = () read_only_paths: Sequence[str] | None = None extra_read_only_paths: Sequence[str] = () read_write_paths: Sequence[str] | None = None extra_read_write_paths: Sequence[str] = () bind_read_only_paths: Sequence[str] | None = None extra_bind_read_only_paths: Sequence[str] = () bind_read_write_paths: Sequence[str] | None = None extra_bind_read_write_paths: Sequence[str] = () restrict_address_families: Sequence[str] | None = () system_call_filter: Sequence[str] | None = ("@system-service",)
[docs] @cache def _default_sandbox() -> Sandbox: return Sandbox( read_only_paths=("/",), )
[docs] def _effective( paths: Sequence[str] | None, extra_paths: Sequence[str], default_paths: Sequence[str] | None, ) -> Sequence[str]: if paths is None: paths = default_paths or () return (*paths, *extra_paths)
[docs] def build_sandbox_command(sandbox: Sandbox, original_cmd: Sequence[str]) -> list[str]: default = _default_sandbox() cmd: list[str] = [ "systemd-run", "--user", "--pipe", "--wait", "--quiet", "--collect", "--service-type=exec", "--expand-environment=no", "--same-dir", # Capabilities "-pCapabilityBoundingSet=", # Security "-pNoNewPrivileges=yes", "-pLockPersonality=yes", # System call filtering "-pSystemCallArchitectures=native", ] if "TMPDIR" in os.environ: cmd.append("-ETMPDIR") if sandbox.private_devices: cmd.append("-pPrivateDevices=yes") if sandbox.private_ipc: cmd.append("-pPrivateIPC=yes") if sandbox.private_network: cmd.append("-pPrivateNetwork=yes") if effective_inaccessible_paths := _effective( sandbox.inaccessible_paths, sandbox.extra_inaccessible_paths, default.inaccessible_paths, ): cmd.append(f"-pInaccessiblePaths={" ".join(effective_inaccessible_paths)}") if temporary_file_systems := _effective( sandbox.temporary_file_systems, sandbox.extra_temporary_file_systems, default.temporary_file_systems, ): cmd.append(f"-pTemporaryFileSystem={" ".join(temporary_file_systems)}") if read_only_paths := _effective( sandbox.read_only_paths, sandbox.extra_read_only_paths, default.read_only_paths ): cmd.append(f"-pReadOnlyPaths={" ".join(read_only_paths)}") if read_write_paths := _effective( sandbox.read_write_paths, sandbox.extra_read_write_paths, default.read_write_paths, ): cmd.append(f"-pReadWritePaths={" ".join(read_write_paths)}") if bind_read_only_paths := _effective( sandbox.bind_read_only_paths, sandbox.extra_bind_read_only_paths, default.bind_read_only_paths, ): cmd.append(f"-pBindReadOnlyPaths={" ".join(bind_read_only_paths)}") if bind_read_write_paths := _effective( sandbox.bind_read_write_paths, sandbox.extra_bind_read_write_paths, default.bind_read_write_paths, ): cmd.append(f"-pBindReadWritePaths={" ".join(bind_read_write_paths)}") if (restrict_address_families := sandbox.restrict_address_families) is not None: cmd.append(f"-pRestrictAddressFamilies={" ".join(restrict_address_families)}") if (system_call_filter := sandbox.system_call_filter) is not None: cmd.append(f"-pSystemCallFilter={" ".join(system_call_filter)}") cmd.append("--") cmd.extend(original_cmd) return cmd
[docs] def _run_sandboxed( sandbox: Sandbox, cmd: Sequence[str], **kwargs ) -> subprocess.CompletedProcess: sandboxed_cmd = build_sandbox_command(sandbox, cmd) return subprocess.run(sandboxed_cmd, **kwargs)
[docs] @cache def _sandbox_enabled() -> bool: config = Config() if not config.find_b("Dinstall::Sandbox::Enable", True): return False # Check for working sandbox. try: _run_sandboxed(_default_sandbox(), ["/usr/bin/true"], check=True) except Exception as e: raise Exception("Sandbox is not available.") from e return True
[docs] def run( cmd: Sequence[str], *, sandbox: Sandbox, **kwargs ) -> subprocess.CompletedProcess: if not _sandbox_enabled(): return subprocess.run(cmd, **kwargs) return _run_sandboxed(sandbox, cmd, **kwargs)