Hyeonsang Cho ed986a10ef
fix(sandbox): report truncated remote glob and grep results (#5427)
* fix(sandbox): report truncated remote glob and grep results

BoxLite, Tenki, E2B, and OpenSandbox run find/grep in the sandbox, cap
the raw output with `| head`, and then filter those lines in Python:
ignored directories such as node_modules are dropped and grep's glob
scope is applied. They reported truncated only when max_results matches
survived the filter. When the capped lines were mostly filtered out, a
search with real matches past the cap came back short or empty with
truncated=False, and glob_tool/grep_tool rendered it as "No files
matched" / "No matches found". With the default max_results=200 and
1,200 files under node_modules, glob("**/*.py") reported no matches for
a workspace that has src/app.py.

remote_search_command now lets one line past its limit through, and
parse_remote_search_output(..., limit=) returns RemoteSearchOutput(text,
truncated): the first `limit` lines and whether the extra line arrived.
Exactly `limit` lines stays a complete result. Each provider passes the
cap it already computed to both calls and returns that truncated from
glob and grep when fewer than max_results results survive filtering.

The glob and grep tools now describe an empty truncated result as
incomplete instead of reporting no matches, which also covers AIO grep's
forwarded truncated flag. Sandbox.glob/grep document truncated as "the
matches may be incomplete".

* docs(changelog): reference #5427 in the remote search truncation entry

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-09-14 15:38:34 +08:00

238 lines
9.3 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: ``max_results`` was reached, or the search
stopped at an output cap before filtering.
"""
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