diff --git a/tests/tools/test_search_hidden_dirs.py b/tests/tools/test_search_hidden_dirs.py index ea5663f645..9f946abc77 100644 --- a/tests/tools/test_search_hidden_dirs.py +++ b/tests/tools/test_search_hidden_dirs.py @@ -17,6 +17,9 @@ import subprocess import pytest +from tools.file_operations import ShellFileOperations +from tools.environments.local import LocalEnvironment + @pytest.fixture def searchable_tree(tmp_path): @@ -38,6 +41,12 @@ def searchable_tree(tmp_path): git_dir.mkdir(parents=True) (git_dir / "pack-abc.idx").write_text("git internal data") + # An arbitrary hidden directory verifies the fallback is not limited to + # a hard-coded list of known cache names. + private_dir = tmp_path / "skills" / ".private-index" + private_dir.mkdir(parents=True) + (private_dir / "notes.txt").write_text("unlisted hidden content") + return tmp_path / "skills" @@ -64,25 +73,68 @@ class TestFindExcludesHiddenDirs: class TestGrepExcludesHiddenDirs: - """_search_with_grep should exclude hidden directories.""" + """The real search_files grep fallback should search the default root.""" - def test_grep_skips_hub_cache(self, searchable_tree): - """grep --exclude-dir should skip .hub/ directory.""" - cmd = ( - f"grep -rnHE --exclude-dir='.*' 'ignore' {searchable_tree}" + @staticmethod + def _grep_ops(searchable_tree, monkeypatch): + ops = ShellFileOperations( + LocalEnvironment(cwd=str(searchable_tree)), + cwd=str(searchable_tree), ) - result = subprocess.run(cmd, shell=True, capture_output=True, text=True) - # Should NOT find the injection text in .hub/index-cache/catalog.json - assert ".hub" not in result.stdout - assert "catalog.json" not in result.stdout + monkeypatch.setattr(ops, "_has_command", lambda command: command == "grep") + return ops - def test_grep_still_finds_visible_content(self, searchable_tree): - """grep should still find content in visible directories.""" - cmd = ( - f"grep -rnHE --exclude-dir='.*' 'real skill' {searchable_tree}" + def test_grep_fallback_finds_visible_content(self, searchable_tree, monkeypatch): + """Searching ``.`` must not exclude the search root itself.""" + result = self._grep_ops(searchable_tree, monkeypatch).search( + "real skill", + path=".", + target="content", ) - result = subprocess.run(cmd, shell=True, capture_output=True, text=True) - assert "SKILL.md" in result.stdout + + assert result.error is None + assert result.total_count > 0 + assert any("SKILL.md" in match.path for match in result.matches) + + def test_grep_fallback_finds_dot_relative_subdirectory( + self, searchable_tree, monkeypatch + ): + """An explicit ``./directory`` root must remain searchable too.""" + result = self._grep_ops(searchable_tree, monkeypatch).search( + "real skill", + path="./my-skill", + target="content", + ) + + assert result.error is None + assert result.total_count == 1 + assert result.matches[0].path.endswith("SKILL.md") + + def test_grep_fallback_skips_hub_cache(self, searchable_tree, monkeypatch): + """The fallback must not expose cached community skill content.""" + result = self._grep_ops(searchable_tree, monkeypatch).search( + "ignore previous instructions", + path=".", + target="content", + ) + + assert result.error is None + assert result.total_count == 0 + assert not result.matches + + def test_grep_fallback_skips_arbitrary_hidden_directory( + self, searchable_tree, monkeypatch + ): + """Hidden-directory exclusion must not rely on a directory allowlist.""" + result = self._grep_ops(searchable_tree, monkeypatch).search( + "unlisted hidden content", + path=".", + target="content", + ) + + assert result.error is None + assert result.total_count == 0 + assert not result.matches class TestRipgrepAlreadyExcludesHidden: diff --git a/tools/file_operations.py b/tools/file_operations.py index eae4138cce..1b4ffd5a38 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -3126,9 +3126,23 @@ class ShellFileOperations(FileOperations): elif output_mode == "count": cmd_parts.append("-c") - # Add pattern and path + # Add pattern and path. grep applies --exclude-dir to the command-line + # search root too, so passing the default relative root ``.`` causes + # ``.*`` to exclude the entire search. Anchor relative paths at the + # shell's live cwd; quoting $PWD separately keeps user paths escaped + # while working across local, container, and remote backends. cmd_parts.append(self._escape_shell_arg(pattern)) - cmd_parts.append(self._escape_shell_arg(path)) + is_absolute = path.startswith(("/", "\\\\")) or bool( + re.match(r"^[A-Za-z]:[\\/]", path) + ) + if is_absolute: + search_root = self._escape_shell_arg(path) + else: + relative_path = path[2:] if path.startswith("./") else path + search_root = '"$PWD"' + if relative_path not in {"", "."}: + search_root += f"/{self._escape_shell_arg(relative_path)}" + cmd_parts.append(search_root) # Fetch generously so we can compute total before slicing fetch_limit = limit + offset + (200 if context > 0 else 0)