mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 02:46:02 +00:00
* fix read_file range validation and passthrough * chore: format test_aio_sandbox.py * test: cover e2b sandbox read_file line-range behavior * fix(boxlite): support ranged file reads * fix(sandbox): reject non-positive start lines --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
183 lines
6.8 KiB
Python
183 lines
6.8 KiB
Python
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
|
|
|
|
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
|
|
|
|
@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.
|
|
"""
|
|
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."""
|
|
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."""
|
|
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
|