fix(learn): treat unreadable candidate paths as absent in project decode (#2446)

## Description

`headroom learn` crashes with an uncaught `PermissionError` when the
current user's username contains a dash. `_decode_project_path` (in
`headroom/learn/plugins/claude.py`) probes speculative candidate paths
when reconstructing an original filesystem path from a Claude Code
encoded project directory name. When the username is e.g. `marco-rocha`,
one candidate becomes `/home/marco/rocha`, which can collide with
another user's home directory whose parent isn't stat-able.
`Path.exists()` calls `os.stat` internally, raising `PermissionError`
instead of returning `False`, so the whole `learn` command crashes
before returning any recommendations.

Fixes #2443

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Add `_path_exists()` to `headroom/learn/plugins/claude.py` — a thin
wrapper around `Path.exists()` that returns `False` on any `OSError`
(including `PermissionError`), mirroring the existing `OSError` handling
already used in `_greedy_path_decode`.
- Route every speculative candidate-path existence check in the decode
path through `_path_exists()`: the Windows drive/path probes in
`_decode_windows_path`, the `simple` POSIX candidate and greedy-branch
bases in `_decode_project_path`/`_greedy_path_decode`, and the decoded
`project_path`/`CLAUDE.md` checks in `discover_projects`.
- Add regression tests covering the exact issue shape (`PermissionError`
on `/home/marco/rocha`) and the `_path_exists` helper directly.
- Leave `CHANGELOG.md` untouched — release-please generates it from
conventional commits.

## Testing

- [x] Unit tests pass (`python -m pytest
tests/test_learn/test_scanner.py::TestDecodePermissionError -q`)
- [x] Linting passes (`ruff check`, `ruff format --check` on the two
changed files)
- [x] New tests added for new functionality

### Test Output

```text
$ python -m pytest tests/test_learn/test_scanner.py::TestDecodePermissionError -q
collected 2 items
tests\test_learn\test_scanner.py ..                                      [100%]
2 passed in 1.86s

$ ruff check headroom/learn/plugins/claude.py tests/test_learn/test_scanner.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.13, local dev checkout of headroom
on branch off upstream/main
- Exact command / steps: Simulated the issue by monkeypatching
`Path.exists` to raise `PermissionError` for the colliding candidate
`/home/marco/rocha`, then calling
`_decode_project_path("-home-marco-rocha-butterfly-sylphina")`
- Observed result: Before the fix the call propagates `PermissionError`
(crash, matching the reported traceback); after the fix it returns
without raising and the unreadable candidate is treated as non-existent.
Both regression tests pass.
- Not tested: End-to-end `headroom learn --apply` on a real Linux
multi-user box with an actually unreadable `/home/<prefix>` — reproduced
via the documented minimal logic instead.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Parideboy 2026-07-22 15:16:44 +02:00 committed by GitHub
parent 3e976712e7
commit a09ba6c087
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 63 additions and 7 deletions

View file

@ -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:

View file

@ -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