Skip to content

fix(input-handler): traverse ancestors with O_PATH where available - #443

Merged
yashrajp22 merged 1 commit into
NVIDIA:mainfrom
bevanjkay:fix/traverse-with-o-path
Aug 27, 2026
Merged

fix(input-handler): traverse ancestors with O_PATH where available#443
yashrajp22 merged 1 commit into
NVIDIA:mainfrom
bevanjkay:fix/traverse-with-o-path

Conversation

@bevanjkay

Copy link
Copy Markdown
Contributor

Problem

Since v2.9.4, _open_regular_file_from_trusted_directory walks every path 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 filesystem access per path hierarchy — Landlock, systemd ProtectSystem, containers with restricted mounts — do not grant read on /, so the very first os.open fails with EACCES and every scan and baseline reports Could 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 test runs under a Landlock sandbox on Linux, and every scan fails there. v2.9.3 and earlier are unaffected — they used shutil.copy2.

scan on 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 reports failed / read_error: File content could not be read, since build_context, static_yara, nested_artifacts, multi_skill and structured_skill all 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:

import os
os.makedirs("/tmp/demo/parent", exist_ok=True)
open("/tmp/demo/parent/SKILL.md", "w").write("# Skill")
os.chmod("/tmp/demo/parent", 0o111)          # search-only, no read
os.open("/tmp/demo/parent", os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
# PermissionError: [Errno 13] Permission denied

That is the regression test added here.

Change

_DIRECTORY_OPEN_FLAGS = os.O_DIRECTORY | os.O_NOFOLLOW | getattr(os, "O_PATH", os.O_RDONLY)

O_PATH requires only search access, and the descriptor it returns is valid as the dir_fd for the relative openat() calls this walk performs. On platforms without O_PATH (macOS, Windows) the getattr falls back to O_RDONLY, i.e. 0, so the flags are byte-for-byte unchanged there.

Security

The symlink guarantees are unchanged. Verified against both flag sets:

case O_RDONLY (current) O_PATH (this PR)
plain regular file opened opened
symlinked intermediate directory refused, ENOTDIR refused, ENOTDIR
symlinked final component refused, ELOOP refused, ELOOP

O_DIRECTORY rejects the symlinked intermediate case, because O_PATH | O_NOFOLLOW yields a descriptor referring to the symlink itself, which is not a directory. The final component is still opened with O_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 xfailed
  • make lint — all checks passed
  • ruff format --check src/ tests/ — 218 files already formatted
  • The new test test_secure_open_traverses_search_only_ancestors fails on main with _FileOpenError and passes with this change
  • Verified end to end under Homebrew's Landlock sandbox: brew test skillspector fails on main, passes with this change

The new test is skipped where O_PATH is absent, and when running as root, since root bypasses directory permission checks.

Unrelated suggestion

_FileOpenError captures error_class but never surfaces it, and _UnsafeFileError and _FileOpenError share the wording Could 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.

@yashrajp22 yashrajp22 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/skillspector/input_handler.py Outdated
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
bevanjkay force-pushed the fix/traverse-with-o-path branch from bddc810 to 27be335 Compare August 27, 2026 11:45
@yashrajp22
yashrajp22 merged commit 365b5ed into NVIDIA:main Aug 27, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants