A pathname can name a different object by the time a second lookup checks it. On Linux, openat2() addresses that boundary by attaching resolution constraints to the same kernel operation that walks the pathname and opens the resulting object. The policy is evaluated during lookup rather than inferred from a pathname inspected before or after the open.

This distinction matters whenever a process accepts path components from a less-trusted source while intending to keep resolution inside a directory, reject symbolic links, avoid mount crossings, or require a cache-only lookup. The relevant object is not the input string alone. It is the result of resolving that string against a live namespace whose directory entries, links, and mounts can change concurrently.

A directory descriptor fixes the starting reference

openat() already separates pathname resolution from the process-wide current working directory. For a relative path, its dirfd argument selects the directory from which lookup begins.

That removes one source of ambient state, but it does not by itself impose a containment rule. A relative path containing .. can move above the starting directory. A symbolic link encountered during traversal can redirect lookup. An absolute symbolic link can restart resolution from the process root. Mount points can move lookup into another mounted filesystem.

A directory descriptor therefore answers where relative traversal starts. It does not automatically specify which traversal transitions are acceptable.

Applications have historically tried to add such policy in userspace by inspecting path components, calling metadata operations, rejecting suspicious strings, or opening one component at a time. Some designs can be made robust with careful descriptor-relative traversal, but a check followed by a separate open creates a dangerous abstraction if both operations perform independent pathname resolution.

The namespace can change between those operations. A component observed as an ordinary directory during validation can be replaced before the later lookup. The defect is not merely insufficient string filtering; it is a mismatch between the object checked and the object opened.

Resolution flags move policy into the pathname walk

openat2() extends openat() with an open_how structure. Its resolve field carries flags that constrain pathname resolution itself.

A compact call shape is:

struct open_how how = {
    .flags = O_RDONLY | O_CLOEXEC,
    .resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS,
};

int fd = syscall(SYS_openat2, dirfd, path, &how, sizeof(how));

If the call succeeds, the returned descriptor was obtained through a lookup that satisfied the requested constraints. There is no intermediate userspace interval in which the application validates one resolution and then asks the kernel to perform another unrestricted resolution for the open.

That atomicity is narrow. It does not freeze the filesystem namespace, make later path lookups equivalent, or stop another process from renaming objects after the descriptor has been returned. It binds the specified resolution policy to this open operation.

Once the descriptor exists, later descriptor-based operations refer to the opened object according to their normal semantics. A later pathname lookup is a new resolution event and can observe a changed namespace.

RESOLVE_BENEATH rejects escape from the starting tree

RESOLVE_BENEATH requires successful resolution to remain beneath the directory named by dirfd. Absolute input paths are rejected, and traversal that would escape the directory through components such as .. or through absolute symbolic links cannot succeed.

This is stronger than removing literal ../ sequences from an input string. Path traversal is semantic. Symbolic links and concurrent namespace changes mean that lexical normalization alone cannot establish the final lookup boundary.

The flag also has a precise scope: it constrains this resolution relative to the supplied directory descriptor. It does not create a new process root, and it does not alter pathname semantics for unrelated system calls.

Current Linux behavior also prevents magic-link resolution when RESOLVE_BENEATH is used, but the documented interface does not promise that implication permanently. Code that requires magic links to be rejected should request RESOLVE_NO_MAGICLINKS explicitly rather than depending on the current side effect.

This is an important API property. A security condition should be represented by the flag that specifies that condition, not by another flag whose present implementation happens to imply it.

RESOLVE_IN_ROOT changes absolute-path semantics for one call

RESOLVE_IN_ROOT provides a different boundary. During this resolution, the directory referenced by dirfd acts as the root.

An absolute input path is interpreted relative to that directory rather than the process root. Absolute symbolic links encountered during traversal are also interpreted relative to the supplied root. A .. at that root does not climb above it.

The effect resembles a temporary root for one pathname operation, not a process-wide chroot() transition. Other threads and later calls retain their existing root context.

That difference is useful in code that needs per-operation path virtualization. It also means RESOLVE_IN_ROOT should not be described as a general sandbox. It constrains pathname resolution for the call. Process capabilities, already-open descriptors, other system calls, network access, and other namespaces remain separate control surfaces.

RESOLVE_BENEATH and RESOLVE_IN_ROOT therefore express related but distinct policies. The first rejects paths that escape beneath the starting directory. The second gives the starting directory root-like semantics for the lookup, including treatment of absolute paths and absolute symbolic links.

O_NOFOLLOW is often associated with symbolic-link protection, but its scope is limited to the final pathname component. Intermediate symbolic links can still participate in resolution.

RESOLVE_NO_SYMLINKS applies across the pathname walk. If resolution would traverse a symbolic link in any component, the operation fails. It also implies the magic-link restriction.

That broader rule can be appropriate when symbolic links are forbidden by the interface contract, but it is stricter than containment alone. A symbolic link can remain inside an allowed tree. Rejecting all links may therefore reject valid layouts that pose no boundary escape under a different policy.

This separates two questions that are often collapsed into one check: whether links are permitted at all, and whether traversal may leave a designated tree. RESOLVE_NO_SYMLINKS answers the first. RESOLVE_BENEATH or RESOLVE_IN_ROOT can participate in the second.

The distinction affects compatibility. System and application directory layouts commonly use symbolic links. A blanket no-link policy can turn a namespace refactoring into an application error even when the new target remains inside the intended boundary.

Mount boundaries are another independent dimension

RESOLVE_NO_XDEV prevents pathname traversal across mount points, including bind mounts. This can keep lookup on the same mount as its starting point.

A directory-tree boundary and a mount boundary are not equivalent. A mount can appear beneath an allowed directory, so a path can remain lexically and structurally below dirfd while crossing into another mounted filesystem. Conversely, a policy may permit such mounted content while still forbidding escape above the directory.

Treating mount traversal as a separate flag exposes that distinction directly.

The restriction also has operational consequences. Bind mounts and mounted subtrees are normal parts of many Linux layouts. Enabling RESOLVE_NO_XDEV without a requirement for same-mount traversal can reject paths that would otherwise satisfy the directory containment policy.

The useful property is not maximal restriction. It is correspondence between the requested flags and the actual interface boundary.

Cache-only resolution exposes a retry boundary

RESOLVE_CACHED changes a different property: the call succeeds only when pathname resolution can be completed from cached lookup information without revalidation or filesystem I/O.

When that condition cannot be met, openat2() reports EAGAIN. The result is not equivalent to a missing file or denied access. It states that the cache-only constraint could not satisfy the operation.

This supports architectures that separate a nonblocking or low-latency fast path from a slower fallback. The fallback is a new operation, and its policy must be selected deliberately. Retrying without RESOLVE_CACHED removes only the cache requirement; other resolution constraints can remain in place.

The distinction matters for error handling. Treating EAGAIN as permanent lookup failure changes the interface semantics. Treating it as permission to retry with every constraint removed can silently widen the pathname policy.

Failure can represent an unresolved safety condition

openat2() can return EAGAIN with RESOLVE_BENEATH or RESOLVE_IN_ROOT when the kernel cannot safely establish that a .. traversal stayed within the required boundary because of concurrent activity. A caller may retry the operation.

This behavior is significant: the kernel does not need to convert uncertainty into success. If the requested invariant cannot be established for that resolution attempt, failure preserves the boundary.

An EXDEV result can indicate that the requested containment or no-cross-mount rule was violated. ELOOP can represent a prohibited symbolic-link or magic-link traversal. These errors describe resolution-policy outcomes in addition to the ordinary errors associated with opening a file.

Code that maps every such error to a generic “not found” result loses information that may matter for auditing, API behavior, or fallback decisions. At the same time, exposing raw filesystem distinctions to an untrusted remote caller can reveal details an application does not intend to publish. Error translation is therefore a separate interface decision from enforcement.

The extensible structure is part of the contract

open_how is size-versioned. Callers pass both a pointer to the structure and its size, allowing the kernel interface to append fields over time.

The structure should be zero-initialized. Zero values for extension fields preserve the behavior associated with their absence, while nonzero bytes in fields added by newer headers can otherwise produce errors on kernels with different support.

openat2() also rejects unknown or conflicting flag values rather than silently ignoring them in the same manner as some older interfaces. That strictness matters when flags express security policy: silent acceptance of an unsupported restriction would create a gap between requested and enforced behavior.

Kernel-version support remains an explicit deployment condition. openat2() was added in Linux 5.6, and individual resolution features have their own availability history. Software that supports older kernels needs a defined fallback or a defined refusal mode. A fallback that performs a weaker open is not semantically equivalent merely because it returns a file descriptor.

The descriptor is the durable result of one constrained lookup

The central boundary in openat2() is between pathname text and object identity. The pathname is resolved through a mutable namespace; the successful result is a descriptor referring to the object selected by that constrained resolution.

Resolution flags let the caller state properties that must hold while the kernel performs that selection. They do not turn path strings into stable object identifiers, and they do not make future lookups inherit the same policy automatically.

For interfaces that accept less-trusted paths, this changes the shape of the race analysis. The critical question is no longer whether a preflight check saw an acceptable path. It is whether the operation that produced the descriptor enforced the required traversal constraints while resolving the live namespace.