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:
Nick Hollon
2026-05-19 16:57:01 -04:00
committed by GitHub
parent d3b00ffee7
commit e50fa3f00a
3 changed files with 183 additions and 4 deletions
+5
View File
@@ -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."""