A directory descriptor can bound a pathname, but only with the right resolution rules
A directory file descriptor gives relative operations a stable starting point, but it does not by itself prevent `..`, symlink, or mount traversal. Linux openat2 and Python dir_fd make the boundary explicit in different ways.
A pathname is a request to resolve names
A pathname such as /srv/uploads/alice/report.txt is not a stable reference to a file. It is a sequence of names the operating system resolves through directories at the time of an operation. If an application accepts a name from a client and joins it to an upload directory, string checks like rejecting ../ may miss alternate spellings, symbolic links, or changes made between a check and the eventual open. The important question is not whether the string looks contained, but what objects the kernel is allowed to traverse while resolving it.
A directory file descriptor offers a more reliable starting point. openat() and related calls interpret a relative path from that already-open directory, rather than from the process current working directory. This removes one source of ambient state: another part of the program changing directories cannot silently retarget the operation. But the descriptor is only a starting point. Without more constraints, a path may still include parent components or links that lead somewhere outside the intended tree. [1]
The descriptor is a capability with a scope question
A capability is a reference that combines authority with the object on which that authority operates. A directory descriptor can play that role: code holding it can name entries relative to the opened directory, while code without it may not have that route. Passing the descriptor to a helper can be clearer than passing a global absolute path, because the helper receives a concrete handle and a limited naming context.
The word “limited” needs care. A plain directory descriptor does not automatically mean “anything opened from here remains below here.” The openat2() system call adds resolution policies for this case. Linux’s RESOLVE_BENEATH rejects resolution that escapes above the supplied directory and rejects absolute paths; RESOLVE_IN_ROOT instead treats the descriptor as the root for that one resolution. These policies have different semantics, especially around absolute paths and symbolic links, so choose based on the interface you want to offer rather than treating them as interchangeable containment switches. [1]
Links and mounts are policy, not edge cases
Suppose a service stores user-controlled files under one directory. A symbolic link named latest might point to an outside location. Should a request for latest follow it? The answer depends on the storage model. If links are legitimate within the tree, rejecting every symlink may surprise users; if the tree is a security boundary, permitting link traversal may contradict the promise. RESOLVE_NO_SYMLINKS disallows symlink resolution throughout the path, while RESOLVE_NO_MAGICLINKS specifically blocks proc-style magic links. The kernel documentation notes that RESOLVE_BENEATH currently also disables magic-link resolution, but recommends setting the explicit flag because that behavior could change. [1]
Mount traversal is another separate decision. RESOLVE_NO_XDEV rejects crossing mount points, including bind mounts. That may be right for a sandbox but wrong for a deployment that intentionally mounts storage inside the tree. Security flags narrow behavior, and each restriction can change normal filesystem semantics. A robust design names which traversals are allowed and handles the resulting errors as ordinary outcomes, rather than silently retrying with weaker rules.
Race resistance belongs in the operation
A common but fragile pattern is to check a path, then open it. Between those steps, another process with write access to a directory could replace a component. This is a time-of-check/time-of-use race: the checked object and opened object need not be the same. Resolving under kernel-enforced constraints during the open avoids relying on a separate user-space inspection to establish containment. openat2() can also return EAGAIN when it cannot prove that a .. traversal stayed inside the boundary during a race. That error is a signal to make an explicit bounded retry or fail the request, not evidence that the requested file was absent. [1]
Python exposes some pieces portably, not the whole Linux policy
Python’s os module exposes dir_fd parameters for a number of filesystem calls, including open() on supported platforms. That lets code anchor an operation to an open directory without changing the process working directory. The documentation also makes the portability boundary clear: support differs by function and platform, and callers can inspect os.supports_dir_fd. The feature currently works on Unix, not Windows. A Python program should check the operation it actually needs, not infer support from the mere existence of the keyword. [2]
The standard dir_fd interface does not expose the complete openat2() resolution mask as a portable high-level API. On Linux, applications needing those stronger kernel rules generally need a binding or a carefully maintained syscall wrapper, including correct structure sizing, zero initialization, error handling, and a policy for unsupported kernels. A fallback that drops the containment constraint is a security change, not a compatibility detail. If the program cannot preserve its guarantee on a platform, it should refuse that mode or use a different design.
Design the authority before validating the string
For a file service, begin by opening the intended root directory and deciding what operations the caller should have: read only, create new names, replace existing entries, or remove them. Then define link and mount behavior, select resolution rules that express those choices, and treat resolution failure as a normal rejected request. Keep descriptors close to the component that owns the authority, and avoid converting them back into global paths merely to pass them through layers.
This is not a complete sandbox. A descriptor does not reduce the permissions of the process, protect other paths opened through unrelated code, or make the file contents trustworthy. It gives one operation a better-defined starting object; kernel resolution constraints can bound that operation. Together, those ideas replace the vague promise “the path stays in this directory” with a checkable statement about which filesystem traversals the program permits.