fix(input-handler): traverse ancestors with O_PATH where available - #443
Merged
Merged
Conversation
7 tasks
yashrajp22
reviewed
Aug 27, 2026
yashrajp22
left a comment
Collaborator
There was a problem hiding this comment.
Tiny nit on the security table in the description: the "current flags" column says a symlinked intermediate directory is refused with ENOTDIR, but with O_RDONLY | O_DIRECTORY | O_NOFOLLOW the kernel fails with ELOOP (O_NOFOLLOW kicks in during path resolution, before the directory check runs). It's the new O_PATH variant that produces ENOTDIR. Doesn't change the conclusion — both errnos are in the {ELOOP, ENOTDIR} set the code already catches, so the walk fails closed either way — just worth correcting so the table matches what the kernel actually returns.
The descriptor-relative open walks every component from the filesystem root with O_RDONLY | O_DIRECTORY | O_NOFOLLOW. O_RDONLY demands read access on each ancestor, including "/", but traversal only needs search access. Sandboxes that grant access per path hierarchy -- Landlock, systemd ProtectSystem, containers with restricted mounts -- do not grant read on "/", so the very first open fails with EACCES and every scan reports "Could not safely open file". Use O_PATH for the intermediate directory descriptors where it exists. It requires only search access, and the resulting descriptor is still valid for the relative openat() calls this walk performs. Elsewhere (macOS, Windows) getattr falls back to O_RDONLY, i.e. 0, leaving the flags unchanged. The symlink guarantees are unaffected: O_DIRECTORY still rejects a symlinked intermediate component with ENOTDIR, and the final component is still opened with O_RDONLY | O_NOFOLLOW, so a symlinked target still fails with ELOOP. Both errnos already map to _UnsafeFileError. Signed-off-by: Bevan Kay <email@bevankay.me>
bevanjkay
force-pushed
the
fix/traverse-with-o-path
branch
from
August 27, 2026 11:45
bddc810 to
27be335
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Since v2.9.4,
_open_regular_file_from_trusted_directorywalks every path component from the filesystem root withO_RDONLY | O_DIRECTORY | O_NOFOLLOW.O_RDONLYdemands read access on each ancestor, including/, but traversal only needs search access.Sandboxes that grant filesystem access per path hierarchy — Landlock, systemd
ProtectSystem, containers with restricted mounts — do not grant read on/, so the very firstos.openfails withEACCESand everyscanandbaselinereportsCould not safely open file. Because the message omits the errno, this reads as a symlink-safety rejection rather than a permission problem.This surfaced while packaging SkillSpector for Homebrew:
brew testruns under a Landlock sandbox on Linux, and every scan fails there. v2.9.3 and earlier are unaffected — they usedshutil.copy2.scanon a directory is affected too.resolve()returns the directory without calling_wrap_single_file, so a report is still produced, but every static analyzer then reportsfailed/read_error: File content could not be read, sincebuild_context,static_yara,nested_artifacts,multi_skillandstructured_skillall read through the same helper. The result is a report with no analysis behind it.No sandbox is needed to reproduce it. On any Linux, as a non-root user, a directory with search-but-not-read permission is enough:
That is the regression test added here.
Change
O_PATHrequires only search access, and the descriptor it returns is valid as thedir_fdfor the relativeopenat()calls this walk performs. On platforms withoutO_PATH(macOS, Windows) thegetattrfalls back toO_RDONLY, i.e.0, so the flags are byte-for-byte unchanged there.Security
The symlink guarantees are unchanged. Verified against both flag sets:
O_RDONLY(current)O_PATH(this PR)ENOTDIRENOTDIRELOOPELOOPO_DIRECTORYrejects the symlinked intermediate case, becauseO_PATH | O_NOFOLLOWyields a descriptor referring to the symlink itself, which is not a directory. The final component is still opened withO_RDONLY | O_NOFOLLOW. Both errnos already map to_UnsafeFileError.This narrows the privilege the walk requires, from read+search to search.
Validation
make test-unit— 2952 passed, 14 skipped, 38 deselected, 4 xfailedmake lint— all checks passedruff format --check src/ tests/— 218 files already formattedtest_secure_open_traverses_search_only_ancestorsfails onmainwith_FileOpenErrorand passes with this changebrew test skillspectorfails onmain, passes with this changeThe new test is skipped where
O_PATHis absent, and when running as root, since root bypasses directory permission checks.Unrelated suggestion
_FileOpenErrorcaptureserror_classbut never surfaces it, and_UnsafeFileErrorand_FileOpenErrorshare the wordingCould not safely open file. A permission failure is therefore indistinguishable from a symlink rejection in the CLI output, which made this considerably harder to diagnose than it needed to be. Happy to send a follow-up including the errno in the message if that's welcome — left out here to keep this diff focused.