mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-07 02:51:50 +00:00
fix(file-ops): allow file search in hidden roots
This commit is contained in:
parent
9e2628ee7c
commit
64ad7dec0d
2 changed files with 97 additions and 5 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import pytest
|
import pytest
|
||||||
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
|
@ -388,6 +389,66 @@ class TestSearchPathValidation:
|
||||||
assert "search failed" in result.error.lower() or "Search error" in result.error
|
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:
|
class TestShellFileOpsWriteDenied:
|
||||||
def test_write_file_denied_path(self, file_ops):
|
def test_write_file_denied_path(self, file_ops):
|
||||||
result = file_ops.write_file("~/.ssh/authorized_keys", "evil key")
|
result = file_ops.write_file("~/.ssh/authorized_keys", "evil key")
|
||||||
|
|
|
||||||
|
|
@ -987,6 +987,12 @@ class ShellFileOperations(FileOperations):
|
||||||
else:
|
else:
|
||||||
search_pattern = pattern.split('/')[-1]
|
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
|
# Prefer ripgrep: respects .gitignore, excludes hidden dirs by
|
||||||
# default, and has parallel directory traversal (~200x faster than
|
# default, and has parallel directory traversal (~200x faster than
|
||||||
# find on wide trees). Mirrors _search_content which already uses rg.
|
# 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).
|
# 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)} " \
|
# Use shell pagination for standard roots. For hidden roots, gather full
|
||||||
f"-printf '%T@ %p\\n' 2>/dev/null | sort -rn | tail -n +{offset + 1} | head -n {limit}"
|
# 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)
|
result = self._exec(cmd, timeout=60)
|
||||||
|
|
||||||
if not result.stdout.strip():
|
if not result.stdout.strip():
|
||||||
# Try without -printf (BSD find compatibility -- macOS)
|
# 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)} " \
|
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 | head -n {limit + offset} | tail -n +{offset + 1}"
|
f"2>/dev/null | sort -rn{pagination_expr}"
|
||||||
result = self._exec(cmd_simple, timeout=60)
|
result = self._exec(cmd_simple, timeout=60)
|
||||||
|
|
||||||
files = []
|
files = []
|
||||||
|
|
@ -1025,6 +1039,23 @@ class ShellFileOperations(FileOperations):
|
||||||
else:
|
else:
|
||||||
files.append(line)
|
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(
|
return SearchResult(
|
||||||
files=files,
|
files=files,
|
||||||
total_count=len(files)
|
total_count=len(files)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue