diff --git a/src/skillspector/input_handler.py b/src/skillspector/input_handler.py index 9daf9e18..6468bbf3 100644 --- a/src/skillspector/input_handler.py +++ b/src/skillspector/input_handler.py @@ -297,7 +297,11 @@ def _open_regular_file_no_follow(file_path: Path) -> BinaryIO: def _open_regular_file_from_trusted_directory(file_path: Path) -> BinaryIO: """Open *file_path* one non-symlinked component at a time.""" - directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + # Traversal only needs search access on each component. Prefer O_PATH where it + # exists (Linux); O_RDONLY additionally demands read access, which sandboxes + # such as Landlock withhold on "/". Elsewhere (e.g. macOS) this is O_RDONLY, + # i.e. 0, leaving the flags unchanged. + directory_flags = os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_PATH", os.O_RDONLY) directory_fd: int | None = None try: directory_fd = os.open(file_path.anchor, directory_flags) diff --git a/tests/unit/test_input_handler.py b/tests/unit/test_input_handler.py index 70e33ba2..ec52b678 100644 --- a/tests/unit/test_input_handler.py +++ b/tests/unit/test_input_handler.py @@ -29,6 +29,7 @@ ALLOWED_GIT_HOSTS, InputHandler, _open_regular_file_from_windows_handle, + _open_regular_file_no_follow, ) @@ -233,6 +234,24 @@ def test_resolve_file_open_failure_does_not_create_temp_dir(tmp_path: Path) -> N handler.cleanup() +@pytest.mark.skipif(not hasattr(os, "O_PATH"), reason="requires O_PATH (Linux)") +@pytest.mark.skipif( + hasattr(os, "geteuid") and os.geteuid() == 0, reason="root bypasses directory permissions" +) +def test_secure_open_traverses_search_only_ancestors(tmp_path: Path) -> None: + """Traversal needs search access on ancestors, not read access.""" + parent = tmp_path / "search_only" + parent.mkdir() + source = parent / "SKILL.md" + source.write_text("# Skill", encoding="utf-8") + os.chmod(parent, 0o111) + try: + with _open_regular_file_no_follow(source) as opened: + assert opened.read() == b"# Skill" + finally: + os.chmod(parent, 0o755) + + def test_resolve_file_rejects_platform_without_safe_open_support( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: