fix(e2b): grep literal patterns verbatim instead of re-escaped (#6952)

E2BSandbox.grep() applied re.escape() to the caller's pattern and then
handed the escaped text to grep -F. Fixed-string grep treats the added
backslashes as part of the text to find, so an exact literal such as
'alpha.beta' never matched. Align with the boxlite/tenki/opensandbox
seams: grep receives the RAW pattern (-F matches it literally, -E as a
regex), and only a non-literal pattern is compile-validated locally.

A shared-contract regression covers dot/space/bracket/backslash/plus
literals through every shell-backed provider.
This commit is contained in:
theater 2026-10-16 08:31:55 +08:00 committed by GitHub
parent 4f1c848744
commit c3ac4b981f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 35 additions and 3 deletions

View File

@ -462,8 +462,14 @@ class E2BSandbox(Sandbox):
case_sensitive: bool = False,
max_results: int = 100,
) -> tuple[list[GrepMatch], bool]:
regex_source = re.escape(pattern) if literal else pattern
re.compile(regex_source, 0 if case_sensitive else re.IGNORECASE)
# Sanity-check a regex pattern as a Python regex at the boundary (grep uses
# POSIX ERE, but this catches gross errors); a literal needs no validation.
# grep receives the RAW pattern: -F matches it literally, -E as a regex.
# Escaping it first would double-escape -- fixed-string grep would then
# search for the added backslashes as text (``alpha\.beta``) and never
# match the exact line (#5626).
if not literal:
re.compile(pattern, 0 if case_sensitive else re.IGNORECASE)
resolved = self._resolve_path(path)
# Build a portable ``grep`` invocation:
@ -492,7 +498,7 @@ class E2BSandbox(Sandbox):
total_cap = max(max_results * 4, max_results + 50)
flags.append(f"-m{per_file_cap}")
search = "grep " + " ".join(flags) + f" -- {shlex.quote(regex_source)} {shlex.quote(resolved)} 2>/dev/null"
search = "grep " + " ".join(flags) + f" -- {shlex.quote(pattern)} {shlex.quote(resolved)} 2>/dev/null"
with self._lock:
client = self._client

View File

@ -235,6 +235,32 @@ def test_grep_case_insensitive_and_file_scope(provider, tree):
assert truncated is False
@pytest.mark.parametrize(
("pattern", "line"),
[
("alpha.beta", "alpha.beta"),
("hello world", "hello world"),
("x[0]", "x[0]"),
("path\\file", "path\\file"),
("a+b", "a+b"),
],
ids=["dot", "space", "brackets", "backslash", "plus"],
)
def test_grep_literal_matches_regex_metacharacters(provider, tmp_path, pattern, line):
# A literal pattern is matched verbatim: the sandbox must hand the RAW
# pattern to ``grep -F``. Re-escaping it first (``alpha\.beta``) makes
# fixed-string grep search for the backslash as part of the text, so an
# exact line never matches (#5626).
root = tmp_path / "literal"
root.mkdir()
(root / "sample.txt").write_text(f"header\n{line}\ntrailer\n", encoding="utf-8")
matches, truncated = provider.sandbox.grep(str(root), pattern, literal=True)
assert [(m.line_number, m.line) for m in matches] == [(2, line)]
assert truncated is False
def test_grep_glob_filter_is_root_relative(provider, tree):
matches, truncated = provider.sandbox.grep(str(tree), "needle", glob="nested/*.txt")
assert [(m.path, m.line_number) for m in matches] == [(str(tree / "nested" / "result.txt"), 1)]