diff --git a/README.md b/README.md index a6f7820b08..b4def05531 100644 --- a/README.md +++ b/README.md @@ -2073,6 +2073,7 @@ Each task gets its own execution environment with a full filesystem view — ski The read-before-write gate ties each read mark to that `read_file` call's result, including custom tools returning multi-message `Command` updates. An unrelated result cannot authorize a write after a failed read or hide a successful read. The built-in `grep` tool searches either one text file or all matching text files below a directory, so an agent can search an uploaded document directly without first broadening the request to the entire uploads directory. +E2B's `glob` filter preserves spaces, quotes, and dollar signs in filename patterns, while wildcard matching and root-relative directory scoping remain unchanged. Remote `ls` excludes ignored descendants before applying its 500-entry listing limit, so dependency and build trees do not crowd out visible files. Explicitly listing an ignored directory still lists its contents; normal depth and output limits remain in effect. diff --git a/backend/packages/harness/deerflow/community/e2b_sandbox/AGENTS.md b/backend/packages/harness/deerflow/community/e2b_sandbox/AGENTS.md index 870860ea47..a1cf2be2e6 100644 --- a/backend/packages/harness/deerflow/community/e2b_sandbox/AGENTS.md +++ b/backend/packages/harness/deerflow/community/e2b_sandbox/AGENTS.md @@ -36,3 +36,11 @@ That fence covers cached sync/async acquisition too: recheck after serializer waits and immediately before exposing a reused client. Signal-triggered shutdown may report deferred cleanup, but it must still forward the process's original SIGTERM/SIGINT/SIGHUP action while retaining the pending E2B state. + +## File search + +Quote the complete `grep --include=` argument with `shlex.quote` before +passing it to the shell. The shell must not split or expand filename patterns; +grep owns basename globbing and `path_matches` owns root-relative directory +scoping. `tests/test_sandbox_search_contract.py` pins this across providers using +real temporary files and offline shell transports. 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 e12024f952..426ef2bae1 100644 --- a/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py +++ b/backend/packages/harness/deerflow/community/e2b_sandbox/e2b_sandbox.py @@ -493,7 +493,7 @@ class E2BSandbox(Sandbox): # pattern) and enforce the real directory scope below via # ``path_matches``, the same helper ``glob()`` uses. include_pattern = glob.split("/")[-1] or glob - flags.append(f"--include={include_pattern}") + flags.append(shlex.quote(f"--include={include_pattern}")) per_file_cap = max(max_results + 1, 50) total_cap = max(max_results * 4, max_results + 50) diff --git a/backend/tests/test_sandbox_search_contract.py b/backend/tests/test_sandbox_search_contract.py index 52324701b1..80b668ad90 100644 --- a/backend/tests/test_sandbox_search_contract.py +++ b/backend/tests/test_sandbox_search_contract.py @@ -241,6 +241,32 @@ def test_grep_glob_filter_is_root_relative(provider, tree): assert truncated is False +@pytest.mark.parametrize( + ("filename", "glob"), + [ + ("release notes.txt", "release *.txt"), + ("author's-notes.txt", "author's-*.txt"), + ('release"notes.txt', 'release"*.txt'), + ("price$USD.txt", "price$USD.txt"), + ("report1.txt", "report[0-9].txt"), + ("plain.txt", "*.txt"), + ], + ids=["space", "single-quote", "double-quote", "dollar", "character-range", "wildcard"], +) +def test_grep_glob_preserves_filename_characters(provider, tmp_path, filename, glob): + root = tmp_path / "workspace" + for directory in (root / "docs", root / "other", root / "docs" / "nested"): + directory.mkdir(parents=True) + (directory / filename).write_text("header\nneedle\n", encoding="utf-8") + (root / "docs" / "excluded.md").write_text("needle\n", encoding="utf-8") + provider.transport.env["USD"] = "expanded" + + matches, truncated = provider.sandbox.grep(str(root), "needle", glob=f"docs/{glob}") + + assert [(match.path, match.line_number, match.line) for match in matches] == [(str(root / "docs" / filename), 2, "needle")] + assert truncated is False + + def test_no_match_is_empty_and_complete(provider, tree): assert provider.sandbox.glob(str(tree), "**/*.missing") == ([], False) assert provider.sandbox.grep(str(tree), "absentneedle") == ([], False)