diff --git a/plugins/memory/__init__.py b/plugins/memory/__init__.py index 7a9fae23f1..bf2c6dab50 100644 --- a/plugins/memory/__init__.py +++ b/plugins/memory/__init__.py @@ -62,12 +62,13 @@ def _get_project_plugins_dir() -> Optional[Path]: def _is_memory_provider_dir(path: Path) -> bool: """Cheap text heuristic (no import): ``__init__.py`` mentions the memory provider contract.""" init_file = path / "__init__.py" - if not init_file.exists(): - return False try: + if not init_file.exists(): + return False source = init_file.read_text(errors="replace", encoding="utf-8")[:8192] return "register_memory_provider" in source or "MemoryProvider" in source - except Exception: + except OSError as exc: # one mode-000 / ACL-denied child must not abort discovery + logger.warning("Skipping unreadable plugin directory %s: %s", path, exc) return False diff --git a/plugins/plugin_loader.py b/plugins/plugin_loader.py index 279b277c9d..84111d6704 100644 --- a/plugins/plugin_loader.py +++ b/plugins/plugin_loader.py @@ -13,6 +13,8 @@ import sys from pathlib import Path from typing import Any, Callable, List, Optional, Tuple +_log = logging.getLogger(__name__) + _PLUGINS_ROOT = Path(__file__).parent @@ -39,8 +41,16 @@ def iter_plugin_dirs(root: Path) -> List[Path]: """Sorted child dirs of *root* that have an ``__init__.py`` (skips ``_``/``.`` names).""" if not root.is_dir(): return [] - return [child for child in sorted(root.iterdir()) - if child.is_dir() and not child.name.startswith(("_", ".")) and (child / "__init__.py").exists()] + dirs: List[Path] = [] + for child in sorted(root.iterdir()): + if child.name.startswith(("_", ".")): + continue + try: + if child.is_dir() and (child / "__init__.py").exists(): + dirs.append(child) + except OSError as exc: # one mode-000 / ACL-denied child must not abort the listing + _log.warning("Skipping unreadable plugin directory %s: %s", child, exc) + return dirs def read_plugin_description(plugin_dir: Path) -> str: diff --git a/tests/plugins/memory/test_discovery_sources.py b/tests/plugins/memory/test_discovery_sources.py index 1384083a6c..f19f411922 100644 --- a/tests/plugins/memory/test_discovery_sources.py +++ b/tests/plugins/memory/test_discovery_sources.py @@ -219,3 +219,27 @@ def test_activation_is_not_gated_on_plugins_enabled(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) assert memory_plugins.load_memory_provider("gatedmem") is not None + + +def test_unreadable_user_plugin_does_not_abort_memory_discovery(tmp_path, monkeypatch): + """One mode-000 / ACL-denied ``$HERMES_HOME/plugins/`` must not hide the bundled + providers or its readable siblings from the dashboard / ``hermes memory`` pickers (#111804).""" + plugins_root = tmp_path / "plugins" + _write_provider_dir(plugins_root, "goodmem") + denied = plugins_root / "denied" + denied.mkdir() + (denied / "__init__.py").write_text("", encoding="utf-8") + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + real_stat = Path.stat # chmod 000 does not bite as root; fail the child's stat instead + + def stat(self, *args, **kwargs): + if self.parent == denied: + raise PermissionError(13, "Permission denied", str(self)) + return real_stat(self, *args, **kwargs) + + monkeypatch.setattr(Path, "stat", stat) + + names = memory_plugins.list_memory_provider_names() + assert "goodmem" in names + assert "denied" not in names + assert memory_plugins.find_provider_dir("denied") is None diff --git a/tests/plugins/test_plugin_loader_unreadable.py b/tests/plugins/test_plugin_loader_unreadable.py new file mode 100644 index 0000000000..e813987db6 --- /dev/null +++ b/tests/plugins/test_plugin_loader_unreadable.py @@ -0,0 +1,30 @@ +"""One mode-000 / ACL-denied child under a plugin root must not abort the listing (#111804).""" + +from __future__ import annotations + +from pathlib import Path + +from plugins import plugin_loader + + +def _deny(monkeypatch, denied: Path) -> None: + """chmod 000 does not bite as root, so fail the stat of the denied child's ``__init__.py``.""" + real_stat = Path.stat + + def stat(self, *args, **kwargs): + if self.parent == denied: + raise PermissionError(13, "Permission denied", str(self)) + return real_stat(self, *args, **kwargs) + + monkeypatch.setattr(Path, "stat", stat) + + +def test_iter_plugin_dirs_skips_unreadable_child(tmp_path, monkeypatch, caplog): + for name in ("denied", "good"): + (tmp_path / name).mkdir() + (tmp_path / name / "__init__.py").write_text("", encoding="utf-8") + _deny(monkeypatch, tmp_path / "denied") + + with caplog.at_level("WARNING", logger="plugins.plugin_loader"): + assert plugin_loader.iter_plugin_dirs(tmp_path) == [tmp_path / "good"] + assert "Skipping unreadable plugin directory" in caplog.text