diff --git a/headroom/learn/plugins/claude.py b/headroom/learn/plugins/claude.py index 399cacfba..48fb75b69 100644 --- a/headroom/learn/plugins/claude.py +++ b/headroom/learn/plugins/claude.py @@ -82,9 +82,9 @@ class ClaudeCodePlugin(LearnPlugin, ConversationScanner): name = _project_display_name(project_path, entry.name) context_file = None - if project_path.exists(): + if _path_exists(project_path): claude_md = project_path / "CLAUDE.md" - if claude_md.exists(): + if _path_exists(claude_md): context_file = claude_md memory_dir = entry / "memory" @@ -368,6 +368,23 @@ class ClaudeCodePlugin(LearnPlugin, ConversationScanner): # ============================================================================= +def _path_exists(path: Path) -> bool: + """Like ``Path.exists()`` but treats an unreadable path as absent. + + ``_decode_project_path`` probes speculative candidate paths (e.g. + ``/home/marco/rocha`` when reconstructing ``/home/marco-rocha/...``). A + candidate can collide with another user's directory whose parent isn't + stat-able, and ``Path.exists()`` calls ``os.stat`` which then raises + ``PermissionError`` instead of returning ``False`` — crashing the whole + ``learn`` command (issue #2443). Match ``_greedy_path_decode``'s existing + ``OSError`` handling and treat any such error as "does not exist". + """ + try: + return path.exists() + except OSError: + return False + + def _decode_windows_path(drive: str, parts: list[str]) -> Path | None: """Reconstruct a Windows path from drive letter + dash-split tokens. @@ -378,10 +395,10 @@ def _decode_windows_path(drive: str, parts: list[str]) -> Path | None: if not tokens: return None win_path = Path(f"{drive}:\\" + "\\".join(tokens)) - if win_path.exists(): + if _path_exists(win_path): return win_path drive_root = Path(f"{drive}:\\") - if drive_root.exists(): + if _path_exists(drive_root): result = _greedy_path_decode(drive_root, tokens) if result: return result @@ -411,7 +428,7 @@ def _decode_project_path(escaped_name: str) -> Path | None: return None simple = Path("/" + escaped_name[1:].replace("-", "/")) - if simple.exists(): + if _path_exists(simple): return simple if len(parts) < 3: @@ -445,9 +462,9 @@ def _project_display_name(project_path: Path, fallback: str) -> str: def _greedy_path_decode(base: Path, parts: list[str]) -> Path | None: """Greedily decode remaining path parts using real child directories.""" if not parts: - return base if base.exists() else None + return base if _path_exists(base) else None - if not base.exists() or not base.is_dir(): + if not _path_exists(base) or not base.is_dir(): return None try: diff --git a/tests/test_learn/test_scanner.py b/tests/test_learn/test_scanner.py index 15b7aa718..9ad0e1a77 100644 --- a/tests/test_learn/test_scanner.py +++ b/tests/test_learn/test_scanner.py @@ -573,3 +573,42 @@ class TestDecodeProjectPath: assert result == project # The home component is reconstructed whole, never split on a separator. assert home.name in result.parts + + +# --------------------------------------------------------------------------- +# PermissionError on speculative candidate paths (issue #2443) +# --------------------------------------------------------------------------- + + +class TestDecodePermissionError: + """A candidate path that raises PermissionError must not crash decode. + + When the username contains a dash (``marco-rocha``), decoding + ``-home-marco-rocha-butterfly-sylphina`` probes candidates like + ``/home/marco/rocha``. If ``/home/marco`` is another user's unreadable + directory, ``Path.exists()`` raises ``PermissionError`` from ``os.stat`` + rather than returning ``False`` — this used to crash ``headroom learn``. + """ + + def test_permission_error_treated_as_absent(self, monkeypatch: pytest.MonkeyPatch) -> None: + real_exists = Path.exists + + def fake_exists(self: Path, *args: object, **kwargs: object) -> bool: + if str(self) == "/home/marco/rocha": + raise PermissionError(13, "Permission denied", str(self)) + return real_exists(self, *args, **kwargs) + + monkeypatch.setattr(Path, "exists", fake_exists) + + # Must not raise; the unreadable candidate is treated as non-existent. + result = _decode_project_path("-home-marco-rocha-butterfly-sylphina") + assert result is None or isinstance(result, Path) + + def test_path_exists_swallows_oserror(self, monkeypatch: pytest.MonkeyPatch) -> None: + from headroom.learn.plugins.claude import _path_exists + + def boom(self: Path, *args: object, **kwargs: object) -> bool: + raise PermissionError(13, "Permission denied", str(self)) + + monkeypatch.setattr(Path, "exists", boom) + assert _path_exists(Path("/home/marco")) is False