diff --git a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py index ceb79b621b..c03f6adb0a 100644 --- a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py +++ b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py @@ -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 diff --git a/backend/tests/test_sandbox_search_contract.py b/backend/tests/test_sandbox_search_contract.py index 80b668ad90..a46146709b 100644 --- a/backend/tests/test_sandbox_search_contract.py +++ b/backend/tests/test_sandbox_search_contract.py @@ -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)]