mirror of
https://github.com/langchain-ai/deepagents.git
synced 2026-07-22 01:35:28 -04:00
fix(sdk): anchor ripgrep glob to search root (#3454)
Fixes #2732. `FilesystemBackend._ripgrep_search` passed an absolute search root to ripgrep, but `--glob` patterns are resolved against the process cwd. Globs with a directory component (e.g. `docs/*.md`) silently matched nothing when the agent's cwd differed from the search root. Run rg with `cwd=base_full` and `.` as the search path so globs anchor correctly, and reanchor emitted relative paths back to absolute so virtual-mode handling is unchanged. --------- Co-authored-by: Mason Daugherty <github@mdrxy.com>
This commit is contained in:
@@ -110,6 +110,11 @@ jobs:
|
||||
shell: bash
|
||||
run: uv sync --group test
|
||||
|
||||
- name: "🔍 Install ripgrep"
|
||||
if: runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: sudo apt-get update && sudo apt-get install -y ripgrep
|
||||
|
||||
- name: "🧪 Run Unit Tests"
|
||||
if: runner.os != 'Windows'
|
||||
shell: bash
|
||||
|
||||
@@ -555,7 +555,7 @@ class FilesystemBackend(BackendProtocol):
|
||||
matches.append({"path": fpath, "line": int(line_num), "text": line_text})
|
||||
return GrepResult(matches=matches)
|
||||
|
||||
def _ripgrep_search(self, pattern: str, base_full: Path, include_glob: str | None) -> dict[str, list[tuple[int, str]]] | None: # noqa: C901 # Split except clauses for logging
|
||||
def _ripgrep_search(self, pattern: str, base_full: Path, include_glob: str | None) -> dict[str, list[tuple[int, str]]] | None: # noqa: C901, PLR0912 # C901: split except clauses for per-clause logging; PLR0912: dir/file cwd branch + containment check
|
||||
"""Search using ripgrep with fixed-string (literal) mode.
|
||||
|
||||
Args:
|
||||
@@ -566,11 +566,25 @@ class FilesystemBackend(BackendProtocol):
|
||||
Returns:
|
||||
Dict mapping file paths to list of `(line_number, line_text)` tuples.
|
||||
Returns `None` if ripgrep is unavailable or times out.
|
||||
Results whose resolved path lies outside `base_full` are silently
|
||||
filtered regardless of `virtual_mode`.
|
||||
"""
|
||||
cmd = ["rg", "--json", "-F"] # -F enables fixed-string (literal) mode
|
||||
if include_glob:
|
||||
cmd.extend(["--glob", include_glob])
|
||||
cmd.extend(["--", pattern, str(base_full)])
|
||||
# When rg is given an absolute search path, directory-component
|
||||
# globs (e.g. "docs/*.md") silently match nothing if the process cwd
|
||||
# != search root (#2732). For a directory, set `cwd=base_full` and
|
||||
# use `.` as the search path so `--glob` resolves correctly. For a
|
||||
# single file, leave `cwd` unset and keep the absolute path —
|
||||
# `subprocess.run` would raise `NotADirectoryError` if passed a file
|
||||
# path as `cwd`, and globs are irrelevant for single-file searches.
|
||||
rg_cwd: str | None = None
|
||||
if base_full.is_dir():
|
||||
cmd.extend(["--", pattern, "."])
|
||||
rg_cwd = str(base_full)
|
||||
else:
|
||||
cmd.extend(["--", pattern, str(base_full)])
|
||||
|
||||
try:
|
||||
proc = subprocess.run( # noqa: S603
|
||||
@@ -579,11 +593,13 @@ class FilesystemBackend(BackendProtocol):
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
cwd=rg_cwd,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError):
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, PermissionError, NotADirectoryError):
|
||||
return None
|
||||
|
||||
results: dict[str, list[tuple[int, str]]] = {}
|
||||
base_resolved = base_full.resolve()
|
||||
for line in proc.stdout.splitlines():
|
||||
try:
|
||||
data = json.loads(line)
|
||||
@@ -595,7 +611,25 @@ class FilesystemBackend(BackendProtocol):
|
||||
ftext = pdata.get("path", {}).get("text")
|
||||
if not ftext:
|
||||
continue
|
||||
p = Path(ftext)
|
||||
# When rg ran from cwd=base_full it emits paths relative to that
|
||||
# cwd; join (don't `.resolve()`) so symlink form is preserved for
|
||||
# callers. When rg searched a single file it emits the absolute
|
||||
# path we passed in.
|
||||
raw = Path(ftext)
|
||||
p = raw if raw.is_absolute() else (base_full / raw)
|
||||
# Defensive containment check: resolve both sides only for the
|
||||
# comparison so symlinks that resolve to paths outside `base_full`
|
||||
# can't leak results, while `p` itself keeps its original shape.
|
||||
# OSError guards against unresolvable symlink targets.
|
||||
try:
|
||||
p.resolve().relative_to(base_resolved)
|
||||
except (ValueError, OSError):
|
||||
logger.warning(
|
||||
"Skipping ripgrep result outside search root: path=%s root=%s",
|
||||
p,
|
||||
base_full,
|
||||
)
|
||||
continue
|
||||
if self.virtual_mode:
|
||||
try:
|
||||
virt = self._to_virtual_path(p)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import shutil
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
|
||||
@@ -559,6 +560,145 @@ def test_grep_literal_search_with_special_chars(tmp_path: Path, pattern: str, ex
|
||||
assert any(expected_file in m["path"] for m in matches), f"Pattern '{pattern}' not found in {expected_file}"
|
||||
|
||||
|
||||
def test_grep_ripgrep_glob_with_directory_component(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Regression test for #2732.
|
||||
|
||||
ripgrep `--glob` patterns with a directory component (e.g. `docs/*.md`)
|
||||
must still match when the process cwd differs from the search root.
|
||||
"""
|
||||
if shutil.which("rg") is None:
|
||||
pytest.skip("ripgrep not installed")
|
||||
|
||||
root = tmp_path / "project"
|
||||
(root / "docs").mkdir(parents=True)
|
||||
(root / "docs" / "guide.md").write_text("hello world\n")
|
||||
(root / "notes.md").write_text("hello world\n")
|
||||
|
||||
other_cwd = tmp_path / "elsewhere"
|
||||
other_cwd.mkdir()
|
||||
monkeypatch.chdir(other_cwd)
|
||||
|
||||
be = FilesystemBackend(root_dir=str(root), virtual_mode=False)
|
||||
|
||||
matches = be.grep("hello", path=str(root), glob="docs/*.md").matches
|
||||
assert matches is not None
|
||||
matched_paths = [m["path"] for m in matches]
|
||||
assert any(p.endswith("docs/guide.md") for p in matched_paths), f"expected docs/guide.md in {matched_paths}"
|
||||
assert not any(p.endswith("notes.md") for p in matched_paths), f"glob should have excluded notes.md but matched {matched_paths}"
|
||||
|
||||
|
||||
def test_grep_ripgrep_glob_virtual_mode(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Regression test for #2732, virtual mode variant.
|
||||
|
||||
Exercises the relative-path re-anchoring through `_to_virtual_path` so a
|
||||
regression in path handling can't silently drop results.
|
||||
"""
|
||||
if shutil.which("rg") is None:
|
||||
pytest.skip("ripgrep not installed")
|
||||
|
||||
root = tmp_path / "project"
|
||||
(root / "docs").mkdir(parents=True)
|
||||
(root / "docs" / "guide.md").write_text("hello world\n")
|
||||
(root / "notes.md").write_text("hello world\n")
|
||||
|
||||
other_cwd = tmp_path / "elsewhere"
|
||||
other_cwd.mkdir()
|
||||
monkeypatch.chdir(other_cwd)
|
||||
|
||||
be = FilesystemBackend(root_dir=str(root), virtual_mode=True)
|
||||
|
||||
matches = be.grep("hello", path="/", glob="docs/*.md").matches
|
||||
assert matches is not None
|
||||
matched_paths = [m["path"] for m in matches]
|
||||
assert any(p == "/docs/guide.md" for p in matched_paths), f"expected /docs/guide.md in {matched_paths}"
|
||||
assert not any("notes" in p for p in matched_paths), f"glob should have excluded notes.md but matched {matched_paths}"
|
||||
|
||||
|
||||
def test_grep_on_single_file_path(tmp_path: Path) -> None:
|
||||
"""Regression test: grep with `path` pointing at a single file must not crash.
|
||||
|
||||
Before #2732's fix, ripgrep was given the file path directly. Naively
|
||||
threading `cwd=base_full` would raise NotADirectoryError for file paths.
|
||||
"""
|
||||
if shutil.which("rg") is None:
|
||||
pytest.skip("ripgrep not installed")
|
||||
|
||||
target = tmp_path / "single.txt"
|
||||
target.write_text("hello single\n")
|
||||
|
||||
be = FilesystemBackend(root_dir=str(tmp_path), virtual_mode=False)
|
||||
|
||||
result = be.grep("hello", path=str(target))
|
||||
assert result.error is None, f"unexpected error: {result.error}"
|
||||
assert result.matches is not None
|
||||
assert any(m["path"].endswith("single.txt") for m in result.matches)
|
||||
|
||||
|
||||
def test_grep_preserves_symlink_path_in_results(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Regression test for #2732: result paths must keep symlink form.
|
||||
|
||||
Pre-fix, ripgrep emitted absolute paths exactly as it crawled them — no
|
||||
`.resolve()` was applied — so users saw the symlinked path they searched
|
||||
under. The fix must preserve that behavior.
|
||||
"""
|
||||
if shutil.which("rg") is None:
|
||||
pytest.skip("ripgrep not installed")
|
||||
|
||||
real = tmp_path / "real"
|
||||
real.mkdir()
|
||||
(real / "target.txt").write_text("hello symlink\n")
|
||||
|
||||
root = tmp_path / "project"
|
||||
root.mkdir()
|
||||
link = root / "via_link"
|
||||
try:
|
||||
link.symlink_to(real, target_is_directory=True)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("platform does not support directory symlinks")
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
be = FilesystemBackend(root_dir=str(root), virtual_mode=False)
|
||||
result = be.grep("hello", path=str(link))
|
||||
assert result.error is None
|
||||
assert result.matches is not None
|
||||
matched = [m["path"] for m in result.matches]
|
||||
assert matched, "expected at least one match"
|
||||
for p in matched:
|
||||
assert str(link) in p, f"symlink form lost; got {p}"
|
||||
assert str(real) not in p, f"path was resolved through the symlink; got {p}"
|
||||
|
||||
|
||||
def test_grep_containment_check_blocks_escaping_symlink(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Regression test: ripgrep results via symlinks that escape the root must be filtered.
|
||||
|
||||
A directory symlink inside `root` that points outside `root` gives ripgrep
|
||||
access to files beyond the intended search boundary. The containment check
|
||||
must drop those results so they never surface to callers.
|
||||
"""
|
||||
if shutil.which("rg") is None:
|
||||
pytest.skip("ripgrep not installed")
|
||||
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("hello secret\n")
|
||||
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
escape = root / "escape"
|
||||
try:
|
||||
escape.symlink_to(outside, target_is_directory=True)
|
||||
except (NotImplementedError, OSError):
|
||||
pytest.skip("platform does not support directory symlinks")
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
be = FilesystemBackend(root_dir=str(root), virtual_mode=False)
|
||||
result = be.grep("hello", path=str(root))
|
||||
matched = [m["path"] for m in (result.matches or [])]
|
||||
assert not any("secret" in p for p in matched), f"containment check failed — escaping symlink leaked result: {matched}"
|
||||
|
||||
|
||||
class TestToVirtualPath:
|
||||
"""Tests for FilesystemBackend._to_virtual_path."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user