import re from abc import ABC, abstractmethod from deerflow.sandbox.search import GrepMatch # POSIX env-var name rule: letter or underscore, then letters/digits/underscores. # Used to validate ``env`` keys before they reach a sandbox implementation. # No current implementation splices a key into a shell string — the local # sandbox passes the dict to ``subprocess.run(env=...)`` (no shell), the AIO # sandbox forwards it via the ``bash.exec`` structured ``env`` field, and e2b # forwards it as the SDK's ``envs``. The check is defense-in-depth for the # contract: a future shell-splicing implementation must not have to re-derive # its own rule. _ENV_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") def _validate_extra_env(extra_env: dict[str, str] | None) -> None: """Reject ``env`` keys that are not valid POSIX env-var names. The :meth:`Sandbox.execute_command` contract accepts arbitrary ``str`` keys. Today no implementation splices a key into a shell string — the local sandbox passes the dict to ``subprocess.run(env=...)`` (no shell), the AIO sandbox forwards it via the ``bash.exec`` structured ``env`` field (no command-string splice), and e2b forwards it as the SDK's ``envs``. Enforcing the POSIX env-name rule in the abstract layer is defense-in-depth for the contract: a future implementation that does route a key through a shell must not have to re-derive its own validation rule, and a caller passing a key derived from config / payload / user input fails fast with ``ValueError`` instead of silently producing an exploit should a future implementation regress to splicing. Raises: ValueError: When ``extra_env`` is not None and any key does not match ``^[A-Za-z_][A-Za-z0-9_]*$``. ``None`` and empty dicts pass through unchanged. """ if not extra_env: return for key in extra_env: if not isinstance(key, str) or not _ENV_NAME_PATTERN.fullmatch(key): raise ValueError(f"extra_env key {key!r} is not a valid POSIX environment variable name (must match ^[A-Za-z_][A-Za-z0-9_]*$). This protects shell-using sandbox implementations from command injection via the key.") class Sandbox(ABC): """Abstract base class for sandbox environments""" _id: str #: Whether ``execute_command`` reuses one persistent shell session across #: calls (shell state — exports, cwd, functions — survives from one call #: into the next). When True, a recorded command's environment cannot be #: proven clean from the command text alone, so evidence consumers (the #: acceptance checklist's ``tests_passed`` matcher) must treat recorded #: bash evidence as untrusted and degrade to UNVERIFIED. #: #: Tri-state, failing closed: ``None`` (the default) means the #: implementation has NOT declared its session semantics — custom #: providers are loaded by class path and may reuse a persistent #: session, so silence cannot be read as fresh-shell. Consumers must #: trust only an explicit ``False`` and degrade to UNVERIFIED on #: ``None`` exactly as on ``True``. Every shipped implementation #: declares explicitly (AIO: ``True``; the per-call exec providers: #: ``False``). persistent_shell_sessions: bool | None = None def __init__(self, id: str): self._id = id @property def id(self) -> str: return self._id @abstractmethod def execute_command( self, command: str, env: dict[str, str] | None = None, timeout: float | None = None, ) -> str: """Execute bash command in sandbox. Args: command: The command to execute. env: Optional per-call environment variables to inject into the command's process. Used to pass request-scoped secrets (e.g. a short-lived end-user token for skill scripts, issue #3861, or a GitHub App installation token for ``git push`` / ``gh``) without placing them in the prompt, tool arguments, or the command string. When ``None`` the sandbox uses its default environment. Keys must be valid POSIX environment-variable names (``^[A-Za-z_][A-Za-z0-9_]*$``); implementations validate via :func:`_validate_extra_env` before use. Values are arbitrary strings — shell-using implementations ``shlex.quote`` them on splice. timeout: Optional per-call wall-clock timeout in seconds. Local sandboxes use this to bound host bash commands so long-lived foreground processes cannot hang a turn indefinitely. Remote/AIO implementations may ignore it when their backend does not expose an equivalent command-timeout control separate from its own API timeouts. Returns: The standard or error output of the command. Raises: ValueError: when an ``env`` key is not a valid env-var name. """ pass def execute_command_in_scope( self, command: str, env: dict[str, str] | None = None, timeout: float | None = None, *, scope_id: str | None = None, ) -> str: """Execute a command in an optional agent execution scope. Providers without server-side shell sessions inherit the ordinary command behavior. Session-aware providers may isolate concurrent agent executions while preserving serialization inside one scope. """ del scope_id return self.execute_command(command, env=env, timeout=timeout) def release_command_scope(self, scope_id: str) -> None: """Release provider-specific command state for one execution scope.""" del scope_id @abstractmethod def read_file( self, path: str, start_line: int | None = None, end_line: int | None = None, ) -> str: """Read the content of a file. Args: path: The absolute path of the file to read. start_line: Optional starting line number (1-indexed, inclusive). end_line: Optional ending line number (1-indexed, inclusive). Returns: The content of the file. """ pass @abstractmethod def download_file(self, path: str) -> bytes: """Download the binary content of a file. Args: path: The absolute path of the file to download. Returns: Raw file bytes. Raises: PermissionError: If path traversal is detected or the path is outside the allowed virtual prefix. OSError: If the file cannot be read or does not exist. Both local and remote implementations must raise ``OSError`` so callers have a single exception type to handle. """ pass @abstractmethod def list_dir(self, path: str, max_depth=2) -> list[str]: """List the contents of a directory. Args: path: The absolute path of the directory to list. max_depth: The maximum depth to traverse. Default is 2. Returns: The contents of the directory. An existing empty directory may return an empty list. A missing path must not. Raises: FileNotFoundError: If ``path`` does not exist or is not a directory. OSError: If the listing cannot be performed (command/client failure). Both local and remote implementations must raise rather than return ``[]`` for failure or a missing path: ``ls_tool`` renders an empty list as ``(empty)``. """ pass @abstractmethod def write_file(self, path: str, content: str, append: bool = False) -> None: """Write content to a file. Args: path: The absolute path of the file to write to. content: The text content to write to the file. append: Whether to append the content to the file. If False, the file will be created or overwritten. """ pass @abstractmethod def glob(self, path: str, pattern: str, *, include_dirs: bool = False, max_results: int = 200) -> tuple[list[str], bool]: """Find paths that match a glob pattern under a root directory. Returns the matches and ``truncated``, which is true whenever the matches may be incomplete: the search stopped at an output cap before filtering, or an eligible match beyond ``max_results`` was dropped. Providers differ in how precisely they can decide the second case. One that holds the whole listing can tell an exactly-full result from a cut-off one and reports the former as complete; one reading a capped stream cannot, and reports it as truncated. Treat the flag as "may be incomplete", never as a count. """ pass @abstractmethod def grep( self, path: str, pattern: str, *, glob: str | None = None, literal: bool = False, case_sensitive: bool = False, max_results: int = 100, ) -> tuple[list[GrepMatch], bool]: """Search for matches inside a text file or files under a directory. Returns the matches and ``truncated``, with the same meaning as in :meth:`glob`. """ pass @abstractmethod def update_file(self, path: str, content: bytes) -> None: """Update a file with binary content. Args: path: The absolute path of the file to update. content: The binary content to write to the file. """ pass