zett_agent.sandbox.base 源代码

"""The sandbox interface and the process runner every backend shares."""

from __future__ import annotations

import asyncio
import os
import signal
import time
from abc import ABC, abstractmethod
from collections.abc import Callable, Mapping, Sequence
from contextlib import suppress
from dataclasses import dataclass, replace
from pathlib import Path
from typing import ClassVar, cast

from .errors import SandboxError, SandboxPolicyError
from .policy import ResourceLimits, SandboxPolicy, build_environment, command_arguments
from .resources import (
    CgroupUsage,
    MemoryStrategy,
    ResourcePlan,
    ResourceReport,
    descendant_pids,
    plan_resources,
    process_tree_rss_bytes,
    watchdog_available,
)

__all__ = [
    "DEFAULT_MAX_OUTPUT_BYTES",
    "DEFAULT_TIMEOUT_SECONDS",
    "MemoryStrategy",
    "Sandbox",
    "SandboxResult",
    "SandboxedCommand",
]

#: Signals the kernel uses to report a limit that was reached, with the limit that
#: raises each and the field of ``ResourceLimits`` that has to have asked for it.
_LIMIT_SIGNALS = {
    getattr(signal, "SIGXCPU", -1000): ("cpu", "cpu_seconds"),
    getattr(signal, "SIGXFSZ", -1001): ("file_size", "file_size_bytes"),
}

#: Wall-clock limit of one run unless the caller passes another (or None for none).
DEFAULT_TIMEOUT_SECONDS = 60.0
#: Bytes of each output stream a run keeps. The rest is read and discarded, so a
#: process that prints without end can neither fill memory nor block on a full pipe.
DEFAULT_MAX_OUTPUT_BYTES = 1_048_576
#: Seconds the runner waits for the output streams after the process has exited.
_DRAIN_GRACE_SECONDS = 1.0
_CHUNK_BYTES = 65_536
#: Whether process groups exist. Windows has none, so only the process itself is stopped there.
_POSIX = os.name == "posix"


[文档] @dataclass(frozen=True, slots=True) class SandboxedCommand: """A command rewritten to run inside a sandbox, ready for a process API. :meth:`Sandbox.wrap` returns one without running anything, so a host that owns its process handling (streaming output, a pty, its own timeout) can spawn ``argv`` itself with ``env`` and ``cwd``. Start it in its own process group (``start_new_session=True`` on POSIX) so the whole tree can be stopped. Attributes: argv: The program and its arguments, including the sandbox launcher. env: The complete environment to start it with. cwd: The working directory to start it in. limits: The resource limits the policy asked for. A host that spawns the command itself applies the kernel limits with ``preexec``; the ones only :meth:`Sandbox.run` can enforce are named in ``warnings``. preexec: Apply the kernel resource limits in the child before ``exec``, for ``subprocess``' ``preexec_fn``. None when there is nothing to apply. warnings: Parts of the policy or the limits this backend could not enforce for this command. A caller that must not run with a weaker boundary checks it. """ argv: tuple[str, ...] env: Mapping[str, str] cwd: Path limits: ResourceLimits = ResourceLimits() preexec: Callable[[], None] | None = None warnings: tuple[str, ...] = ()
[文档] @dataclass(frozen=True, slots=True) class SandboxResult: """What one sandboxed run produced. Attributes: exit_code: The process's exit status. A negative number is the signal that ended it, which is what a timeout produces. Bubblewrap runs the command under its own init, which reports a signal as ``128`` plus its number (137 for ``SIGKILL``), as a shell does. stdout: Standard output, decoded as UTF-8 with undecodable bytes replaced. stderr: Standard error, decoded the same way. timed_out: Whether the run exceeded its time limit and was killed. stdout_truncated: Whether standard output exceeded the byte limit. stderr_truncated: Whether standard error exceeded the byte limit. duration: Seconds the run took. sandbox: Name of the backend that ran it. isolated: Whether that backend enforces a boundary at all. warnings: Parts of the policy the backend could not enforce. Resource warnings are on ``resources`` instead. resources: How each limit was enforced, what the run measured, and the limits it could not enforce here. """ exit_code: int | None stdout: str stderr: str timed_out: bool = False stdout_truncated: bool = False stderr_truncated: bool = False duration: float = 0.0 sandbox: str = "" isolated: bool = False warnings: tuple[str, ...] = () resources: ResourceReport = ResourceReport() @property def succeeded(self) -> bool: """Whether the process exited with status 0 within its time limit.""" return self.exit_code == 0 and not self.timed_out
class _Capture: """Keep the first bytes of a stream and discard the rest while still reading it.""" def __init__(self, limit: int) -> None: self.limit = limit self.data = bytearray() self.truncated = False def feed(self, chunk: bytes) -> None: room = self.limit - len(self.data) if room > 0: self.data += chunk[:room] if len(chunk) > max(room, 0): self.truncated = True def text(self) -> str: return bytes(self.data).decode("utf-8", errors="replace") class _Run(asyncio.SubprocessProtocol): """Collect output and report the process's exit separately from its pipes closing. ``asyncio.subprocess.Process.wait`` returns only once the pipes are closed, so a background child that still holds standard output would keep a finished command waiting. The protocol's ``process_exited`` fires when the process itself ends, which is the moment the runner stops the rest of its group. """ def __init__(self, limit: int) -> None: loop = asyncio.get_running_loop() self.out, self.err = _Capture(limit), _Capture(limit) self.exited: asyncio.Future[None] = loop.create_future() self.drained: asyncio.Future[None] = loop.create_future() self._open = {1, 2} def pipe_data_received(self, fd: int, data: bytes) -> None: (self.out if fd == 1 else self.err).feed(data) def pipe_connection_lost(self, fd: int, exc: Exception | None) -> None: self._open.discard(fd) if not self._open and not self.drained.done(): self.drained.set_result(None) def process_exited(self) -> None: if not self.exited.done(): self.exited.set_result(None) def _kill(pid: int, transport: asyncio.SubprocessTransport, *, tree: bool = False) -> None: """Stop a process and, on POSIX, everything it started in its group. With ``tree`` the descendants are found first and killed too, which reaches a child that started a session of its own and so left the group. That costs a ``ps`` call, so it is used when a command is being stopped (a timeout, a cancellation, a memory limit) and not after one that finished by itself, where the parent links it would follow are already gone. """ if _POSIX and tree: for process in descendant_pids(pid): with suppress(ProcessLookupError, PermissionError): os.kill(process, signal.SIGKILL) with suppress(ProcessLookupError, PermissionError): if _POSIX: os.killpg(pid, signal.SIGKILL) else: transport.kill() @dataclass class _MemoryWatchdog: """Sample the memory of a process tree, and remember the highest value seen. The parent runs this, outside the sandbox, so it sees the whole tree. It is the last resort for a memory limit, used where the kernel cannot enforce one; between two samples the group can go over the limit. """ pid: int limit_bytes: int sample_seconds: float peak_bytes: int | None = None exceeded: bool = False unreadable: bool = False async def watch(self, exited: asyncio.Future[None]) -> None: """Sample until the process exits or the limit is passed.""" while not exited.done(): measured = await asyncio.to_thread(process_tree_rss_bytes, self.pid) if measured is None: # Nothing was measured, so nothing is enforced. It is reported # rather than passed off as a run within its limit. self.unreadable = True else: self.peak_bytes = measured if self.peak_bytes is None else max(self.peak_bytes, measured) if measured > self.limit_bytes: self.exceeded = True return await asyncio.sleep(self.sample_seconds) def _limit_for_exit(exit_code: int | None, requested: tuple[str, ...]) -> str | None: """Name the limit whose signal ended a process, from its exit status. A process killed by a signal reports ``-N`` when it is run directly. Bubblewrap runs the command under its own init and reports ``128 + N`` instead, as a shell does, so both are read. The limit has to have been asked for: a status that merely looks like a signal is not attributed to a limit nobody set. """ if exit_code is None: return None for number, (limit, field) in _LIMIT_SIGNALS.items(): if field in requested and exit_code in (-number, 128 + number): return limit return None def _measurements( usage: CgroupUsage | None, watchdog: _MemoryWatchdog | None, exit_code: int | None, requested: tuple[str, ...] = (), ) -> tuple[int | None, tuple[str, ...], tuple[str, ...]]: """Collect what the run measured: peak memory, and which limits it reached. Peak memory comes from wherever the limit was enforced: the kernel's own accounting in a cgroup, which is read before the group is released, or the parent's sampling. A limit the kernel reports by killing the process is read from the signal that ended it. """ peak = watchdog.peak_bytes if watchdog is not None else None hit: list[str] = [] if watchdog is not None and watchdog.exceeded: hit.append("memory") if usage is not None: if usage.peak_memory_bytes is not None: peak = usage.peak_memory_bytes if usage.oom_kills or usage.memory_events_max: hit.append("memory") limit = _limit_for_exit(exit_code, requested) if limit is not None: hit.append(limit) warnings: list[str] = [] if watchdog is not None and watchdog.unreadable: warnings.append( "memory_bytes: the watchdog could not read the command's memory on this machine, " "so nothing enforced the limit" ) return peak, tuple(dict.fromkeys(hit)), tuple(warnings)
[文档] class Sandbox(ABC): """Run a command inside a boundary described by a :class:`SandboxPolicy`. A backend implements two things: :meth:`check`, which says whether it can run on this machine, and :meth:`_wrap`, which rewrites a command so the operating system enforces the policy on it. Everything else, including the environment, the time limit, the output cap, stopping the whole process tree and cancellation, is shared, so every backend behaves the same except in what it is able to confine. The interface is deliberately split in two. :meth:`wrap` returns the rewritten command without running it, for hosts that manage processes themselves; :meth:`run` runs it and collects the result. Args: policy: The boundary to enforce, including its resource limits. Defaults to :meth:`SandboxPolicy.read_only`. memory_strategy: How to enforce ``memory_bytes``; see :class:`~zett_agent.sandbox.resources.MemoryStrategy`. The default takes the strongest mechanism the machine has. Examples: Run a program that may write one directory:: sandbox = create_sandbox(SandboxPolicy.workspace_write("/work/project")) result = await sandbox.run(["python3", "build.py"], cwd="/work/project") if not result.succeeded: print(result.stderr) Run a shell pipeline:: result = await sandbox.run("grep -r TODO . | wc -l", cwd="/work/project") .. note:: The sandbox confines a process and everything it starts. It does not decide whether a command should run at all; asking a person first is a separate control, for example :class:`~zett_agent.extensions.shell_approval.ShellApprovalExtension`. .. warning:: Several runs share one policy but no state: each starts a fresh process in a fresh boundary. Files a run writes inside a writable path stay there. """ #: Short stable name of the backend, reported in results. name: ClassVar[str] #: Whether this backend enforces a boundary. False only for the backend that #: runs commands as they are. isolated: ClassVar[bool] def __init__( self, policy: SandboxPolicy | None = None, *, memory_strategy: MemoryStrategy | str = MemoryStrategy.AUTO, ) -> None: self.policy = policy if policy is not None else SandboxPolicy.read_only() if not isinstance(self.policy, SandboxPolicy): raise SandboxPolicyError("policy must be a SandboxPolicy") try: self.memory_strategy = MemoryStrategy(memory_strategy) except ValueError: raise SandboxPolicyError(f"unknown memory strategy: {memory_strategy!r}") from None if self.memory_strategy is MemoryStrategy.WATCHDOG and not watchdog_available(): raise SandboxPolicyError("MemoryStrategy.WATCHDOG needs a POSIX system with a ps command")
[文档] @abstractmethod def check(self) -> None: """Raise :class:`SandboxUnavailableError` unless this backend can run here. The error says what is missing and how to fix it. Calling it is cheap after the first time. """
[文档] def capabilities(self) -> ResourceReport: """Return how each limit of this sandbox's policy is enforced on this machine. Nothing is created or changed, so it is safe to ask before running anything: a host can refuse a policy that this machine cannot enforce as written instead of discovering it from a run. The mechanisms named here are the ones a run will use. Examples: Refuse to run unless the machine enforces memory exactly:: report = sandbox.capabilities() if report.memory not in ("cgroup", "rlimit_as"): raise RuntimeError(report.warnings) .. note:: A report is about this machine, not about the policy alone: the same policy reports ``cgroup`` on a Linux host with delegated controllers and ``watchdog`` on macOS. """ return self._plan(create_cgroup=False).report
def _plan(self, *, create_cgroup: bool = True) -> ResourcePlan: """Plan the resource limits for this policy, optionally creating the cgroup to use.""" return plan_resources( self.policy.limits, strategy=self.memory_strategy, name=f"{self.name}-sandbox", create_cgroup=create_cgroup, ) @abstractmethod def _wrap(self, argv: tuple[str, ...], cwd: Path, env: dict[str, str]) -> SandboxedCommand: """Rewrite one argument vector so the platform enforces the policy on it."""
[文档] def wrap( self, command: str | Sequence[str], *, cwd: str | os.PathLike[str] | None = None, env: Mapping[str, str] | None = None, ) -> SandboxedCommand: """Return ``command`` rewritten to run inside this sandbox, without running it. Args: command: A shell string, run through ``/bin/sh -c``, or the program followed by its arguments, run directly. cwd: Working directory; the current directory by default. env: Variables added to the policy's inherited environment. Raises: SandboxUnavailableError: If the backend cannot run here. SandboxPolicyError: If the command, directory or policy is invalid. """ self.check() arguments = command_arguments(command) directory = Path.cwd() if cwd is None else Path(cwd).expanduser() try: directory = directory.resolve(strict=True) except OSError as error: raise SandboxPolicyError(f"working directory does not exist: {directory}") from error if not directory.is_dir(): raise SandboxPolicyError(f"working directory is not a directory: {directory}") environment = build_environment(self.policy.inherit_environment, env) wrapped = self._wrap(arguments, directory, environment) # The kernel limits travel with the command: a host that spawns it applies # them through `preexec`. What needs the parent (a cgroup to create, or # memory to sample) only happens in `run`, and saying so is the point. plan = self._plan(create_cgroup=False) parent_side = [ name for name, mechanism in plan.report.mechanisms.items() if mechanism in ("watchdog", "cgroup") ] warnings = list(plan.warnings) if parent_side: warnings.append( f"{', '.join(sorted(parent_side))}: only run() enforces this, because it needs the parent process; " "spawn the command with preexec_fn=command.preexec for the rest" ) return replace( wrapped, limits=self.policy.limits, preexec=plan.preexec(), warnings=(*wrapped.warnings, *warnings), )
[文档] async def run( self, command: str | Sequence[str], *, cwd: str | os.PathLike[str] | None = None, env: Mapping[str, str] | None = None, stdin: str | bytes | None = None, timeout: float | None = DEFAULT_TIMEOUT_SECONDS, max_output_bytes: int = DEFAULT_MAX_OUTPUT_BYTES, ) -> SandboxResult: """Run ``command`` inside the sandbox and collect what it produced. The process runs in its own process group. A timeout, a cancellation of the awaiting task, and the end of the run each stop the whole group, so no helper process outlives its command. Args: command: A shell string or the program followed by its arguments. cwd: Working directory; the current directory by default. env: Variables added to the policy's inherited environment. stdin: Input to feed the process; it gets no input otherwise. timeout: Seconds before the run is killed; None waits without limit. max_output_bytes: Bytes kept of each of standard output and error. Raises: SandboxUnavailableError: If the backend cannot run here. SandboxPolicyError: If an argument or the policy is invalid. SandboxError: If the process could not be started. asyncio.CancelledError: When the caller is cancelled, after the process group has been stopped. """ if isinstance(max_output_bytes, bool) or not isinstance(max_output_bytes, int) or max_output_bytes < 1: raise SandboxPolicyError("max_output_bytes must be a positive integer") if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0): raise SandboxPolicyError("timeout must be a positive number of seconds or None") wrapped = self.wrap(command, cwd=cwd, env=env) plan = self._plan() data = stdin.encode("utf-8") if isinstance(stdin, str) else stdin started = time.monotonic() loop = asyncio.get_running_loop() try: transport, run = await loop.subprocess_exec( lambda: _Run(max_output_bytes), *wrapped.argv, cwd=wrapped.cwd, env=dict(wrapped.env), stdin=asyncio.subprocess.PIPE if data is not None else asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, start_new_session=_POSIX, # The child joins the cgroup and takes its own limits on before exec. preexec_fn=plan.preexec(), ) except OSError as error: plan.cleanup() raise SandboxError(f"could not start {wrapped.argv[0]!r}: {error}") from error pid = transport.get_pid() timed_out = False usage: CgroupUsage | None = None watchdog = ( _MemoryWatchdog( pid=pid, limit_bytes=self.policy.limits.memory_bytes, # type: ignore[arg-type] sample_seconds=plan.sample_seconds, ) if plan.watchdog else None ) watcher = asyncio.create_task(watchdog.watch(run.exited)) if watchdog is not None else None try: if data is not None: # stdin was opened as a pipe above because there is data to send, so this is a writable one. stdin_pipe = cast("asyncio.WriteTransport", transport.get_pipe_transport(0)) stdin_pipe.write(data) stdin_pipe.write_eof() # Three ways to stop waiting: the command ends, the memory watchdog # finds the group over its limit, or the time limit passes. waiters: list[asyncio.Future[None]] = [run.exited] if watcher is not None: waiters.append(watcher) done, _ = await asyncio.wait(waiters, timeout=timeout, return_when=asyncio.FIRST_COMPLETED) timed_out = not done stopped = timed_out or (watchdog is not None and watchdog.exceeded) # However it ended, stop what is left of the group: a background child # must not outlive the run, and one that still holds a pipe would # otherwise keep the output open. _kill(pid, transport, tree=stopped) if not run.exited.done(): await run.exited with suppress(TimeoutError, asyncio.TimeoutError): await asyncio.wait_for(asyncio.shield(run.drained), _DRAIN_GRACE_SECONDS) if plan.cgroup is not None: # Read while the group still exists: cleanup removes it. usage = plan.cgroup.usage() except asyncio.CancelledError: _kill(pid, transport, tree=True) raise finally: if watcher is not None and not watcher.done(): watcher.cancel() if watcher is not None: await asyncio.gather(watcher, return_exceptions=True) plan.cleanup() transport.close() exit_code = transport.get_returncode() out, err = run.out, run.err peak, limits_hit, runs_warnings = _measurements(usage, watchdog, exit_code, self.policy.limits.requested()) return SandboxResult( exit_code=exit_code, stdout=out.text(), stderr=err.text(), timed_out=timed_out, stdout_truncated=out.truncated, stderr_truncated=err.truncated, duration=time.monotonic() - started, sandbox=self.name, isolated=self.isolated, warnings=wrapped.warnings, resources=plan.measured(peak_memory_bytes=peak, limits_hit=limits_hit, warnings=runs_warnings), )