# 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)