From 64ad7dec0d02256a0ef8330d98ebe5949b517e5e Mon Sep 17 00:00:00 2001 From: ClawdIA Date: Mon, 27 Apr 2026 15:31:15 -0300 Subject: [PATCH] fix(file-ops): allow file search in hidden roots --- tests/tools/test_file_operations.py | 61 +++++++++++++++++++++++++++++ tools/file_operations.py | 41 ++++++++++++++++--- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index 500cd6141a..9e9ffa8ad3 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -2,6 +2,7 @@ import os import pytest +import subprocess from pathlib import Path from unittest.mock import MagicMock @@ -388,6 +389,66 @@ class TestSearchPathValidation: assert "search failed" in result.error.lower() or "Search error" in result.error +class TestSearchFilesFallbackHiddenPaths: + def _make_env(self): + env = MagicMock() + env.cwd = "/" + + def execute(command, **kwargs): + completed = subprocess.run( + command, + shell=True, + text=True, + capture_output=True, + ) + return { + "output": completed.stdout, + "returncode": completed.returncode, + } + + env.execute = execute + return env + + def test_hidden_root_with_hidden_ancestor_includes_files(self, tmp_path, monkeypatch): + """Fallback find should include visible files when path is inside hidden root.""" + root = tmp_path / ".hermes" / "logs" + root.mkdir(parents=True) + visible_file = root / "agent.log" + hidden_dir_file = root / ".hidden" / "secret.log" + nested_hidden_file = root / "nested" / ".secret.log" + visible_nested_file = root / "nested" / "visible.log" + + for p in [visible_file, nested_hidden_file, visible_nested_file, hidden_dir_file]: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("x") + + ops = ShellFileOperations(self._make_env()) + monkeypatch.setattr(ops, "_has_command", lambda command: command == "find") + result = ops._search_files("*.log", str(root), limit=50, offset=0) + + assert result.error is None + assert set(result.files) == {str(visible_file), str(visible_nested_file)} + + def test_normal_root_still_excludes_hidden_descendants(self, tmp_path, monkeypatch): + """Fallback find should still exclude hidden descendant paths for normal roots.""" + root = tmp_path / "repo" + root.mkdir() + visible_file = root / "agent.log" + visible_nested_file = root / "nested" / "visible.log" + hidden_dir_file = root / ".hidden" / "secret.log" + + for p in [visible_file, visible_nested_file, hidden_dir_file]: + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text("x") + + ops = ShellFileOperations(self._make_env()) + monkeypatch.setattr(ops, "_has_command", lambda command: command == "find") + result = ops._search_files("*.log", str(root), limit=50, offset=0) + + assert result.error is None + assert set(result.files) == {str(visible_file), str(visible_nested_file)} + + class TestShellFileOpsWriteDenied: def test_write_file_denied_path(self, file_ops): result = file_ops.write_file("~/.ssh/authorized_keys", "evil key") diff --git a/tools/file_operations.py b/tools/file_operations.py index 73e739e730..627fdf9678 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -987,6 +987,12 @@ class ShellFileOperations(FileOperations): else: search_pattern = pattern.split('/')[-1] + search_root = Path(path) + has_hidden_path_ancestor = any( + part not in (".", "..") and part.startswith(".") + for part in search_root.parts + ) + # Prefer ripgrep: respects .gitignore, excludes hidden dirs by # default, and has parallel directory traversal (~200x faster than # find on wide trees). Mirrors _search_content which already uses rg. @@ -1002,17 +1008,25 @@ class ShellFileOperations(FileOperations): ) # Exclude hidden directories (matching ripgrep's default behavior). - hidden_exclude = "-not -path '*/.*'" + hidden_exclude = "-not -path '*/.*'" if not has_hidden_path_ancestor else "" + hidden_filter_expr = f" {hidden_exclude}" if hidden_exclude else "" - cmd = f"find {self._escape_shell_arg(path)} {hidden_exclude} -type f -name {self._escape_shell_arg(search_pattern)} " \ - f"-printf '%T@ %p\\n' 2>/dev/null | sort -rn | tail -n +{offset + 1} | head -n {limit}" + # Use shell pagination for standard roots. For hidden roots, gather full + # output so we can re-apply hidden-descendant filtering while allowing + # explicit hidden-root searches. + pagination_expr = "" + if not has_hidden_path_ancestor: + pagination_expr = f" | tail -n +{offset + 1} | head -n {limit}" + + cmd = f"find {self._escape_shell_arg(path)}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \ + f"-printf '%T@ %p\\n' 2>/dev/null | sort -rn{pagination_expr}" result = self._exec(cmd, timeout=60) if not result.stdout.strip(): # Try without -printf (BSD find compatibility -- macOS) - cmd_simple = f"find {self._escape_shell_arg(path)} {hidden_exclude} -type f -name {self._escape_shell_arg(search_pattern)} " \ - f"2>/dev/null | head -n {limit + offset} | tail -n +{offset + 1}" + cmd_simple = f"find {self._escape_shell_arg(path)}{hidden_filter_expr} -type f -name {self._escape_shell_arg(search_pattern)} " \ + f"2>/dev/null | sort -rn{pagination_expr}" result = self._exec(cmd_simple, timeout=60) files = [] @@ -1025,6 +1039,23 @@ class ShellFileOperations(FileOperations): else: files.append(line) + # For explicit hidden roots, find's path-based filtering excludes every + # file under the hidden path. Apply descendant filtering after command + # execution so only the explicit root ancestry is bypassed. + if has_hidden_path_ancestor: + normalized_root = search_root.resolve() + filtered_files = [] + for file_path in files: + try: + rel_parts = Path(file_path).resolve().relative_to(normalized_root).parts + except ValueError: + rel_parts = Path(file_path).parts + if any(part not in (".", "..") and part.startswith(".") for part in rel_parts): + continue + filtered_files.append(file_path) + files = filtered_files[offset:offset + limit] + # pagination for standard roots is already applied in shell + return SearchResult( files=files, total_count=len(files)