mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-20 19:46:16 +00:00
* fix(sandbox): report an exactly-full AIO glob result as complete AioSandbox.glob's include_dirs branch returned as soon as it had collected max_results matches, without looking at the rest of the listing. A listing that held exactly that many matches and nothing more was therefore reported as truncated, and the glob tool told the model the result was incomplete — prompting a re-search or distrust of a complete answer. The same line returned one match for max_results=0, one past the caller's cap. Look one match past the cap before deciding, which is what the include_dirs=False branch in the same function already does and what #5427 moved parse_remote_search_output to for BoxLite, Tenki, E2B and OpenSandbox. * review: filtered-tail cases, the glob contract docstring, and the cap wording Addresses the three items from the review on #5449. - Two regression cases over a tail of ignored / out-of-root / pattern-miss entries: an exactly-full result stays complete when only filtered entries follow, and a third eligible match after that tail still reports truncation. Both fail against the previous return-on-the-max-th-match behaviour. - 'Sandbox.glob' promised the conservative flag ('``max_results`` was reached') that this change deliberately stops producing on the AIO branch. The contract now reads as 'may be incomplete' and records that providers differ in how precisely they can decide it. - The changelog no longer lumps 'parse_remote_search_output' in with the filtered-match cap: its raw-output cap is a separate limit with its own one-line-past accounting, and the other providers' filtered-match cap is unchanged. Also corrects the docstring on the existing test, which still described the removed early return in the present tense. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
244 lines
9.6 KiB
Python
244 lines
9.6 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
|
|
|
|
#: 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
|