Python symlink race analysis comparing path validation with descriptor-relative file opening.

A Path Passed Validation. Could a Symlink Change Before the File Opened?

Wed, Nov 11, 2026

A pathname can pass a containment check and still name a different file by the time the application opens it. That is the narrow failure under review here: Python resolves or validates a pathname at one instant, the filesystem namespace changes, and a later open() performs a fresh lookup. Path.resolve(strict=True) gives a symlink-resolved interpretation of the path at resolution time; it does not retain an open kernel reference to that object. PurePath.is_relative_to() is even narrower: Python documents it as string-based and filesystem-independent.

This playbook treats that gap as a release-acceptance problem, not as an invitation to keep adding pathname predicates. The controlled scope is Python pathname validation followed by filesystem open, deliberate symlink replacement, descriptor-relative opens, O_NOFOLLOW, a nested-directory race, a conservative descriptor-walk repair, and Linux openat2() containment as a platform-specific escalation path. Linux documents openat() directory descriptors specifically as a mechanism for avoiding races involving replaceable pathname prefixes; openat2() adds resolution controls such as RESOLVE_BENEATH.

No execution results are claimed below. Every test outcome is EXPECTED / NOT EXECUTED until a qualifying run produces evidence from the exact runtime under review. The current release decision is therefore RERUN, not ACCEPT.

Define the admission and release contract

The admission rule must say what is being protected before code chooses Path.resolve(), os.open(), dir_fd, or an openat2() binding. For this fixture, the trusted root is an application-selected, existing directory. The input is a relative or absolute pathname intended to identify a regular file beneath that root. The application may inspect a candidate pathname, but release approval requires evidence about the object actually opened, not merely the pathname that previously passed a check.

The core contract is:

  1. A safe child path must open the synthetic safe payload.

  2. A sibling whose text shares the trusted prefix must not be classified as contained.

  3. A symlink that resolves outside must be detected as outside during validation.

  4. If that symlink is replaced after validation, a later pathname open must be treated as a distinct filesystem lookup.

  5. A simple descriptor-relative open must be demonstrated separately from containment.

  6. O_NOFOLLOW must be tested as a final-component rule, not described as an all-component policy.

  7. A replaceable intermediate directory must be exercised, because a trusted dir_fd plus final-component O_NOFOLLOW still permits earlier symlink components to be followed.

  8. A platform without the required primitives must produce a holdable unsupported result rather than a fabricated pass.

Python documents os.open(..., dir_fd=...) as supporting paths relative to directory descriptors, while Linux documents that O_NOFOLLOW fails when the trailing component is a symlink and still follows symlinks in earlier components.

This evidence model is methodologically adjacent to Refonte’s SQLite WAL backup committed rows playbook, where a successful operation is reconciled against an independently declared state rather than accepted on return status alone. The technical subject is different: this article does not reuse SQLite state, backup procedures, or database recovery evidence.

The release decision vocabulary is fixed up front. ACCEPT means the candidate opening design passes every required case with complete, unique evidence. REPAIR means the evidence demonstrates a design defect with a known correction boundary. RERUN means the protocol is applicable but required execution evidence is absent, partial, or invalid. HOLD means the platform or security assumptions cannot yet support the claimed containment property.

Pin the baseline and source versions

As of November 11, 2026, Python 3.15.2 is the current Python 3.15 maintenance release; Python.org records its release date as October 25, 2026. The source release page publishes a SHA-256 for the XZ tarball, which allows a qualifying lab to pin the interpreter source independently of a moving python:latest-style label.

Use this reference baseline:

CPython: 3.15.2
CPython release date: 2026-10-25
CPython source artifact:
  Python-3.15.2.tar.xz
Published SHA-256:
  8b5536d280142ecf6cdfe15d12a21ba1c65e300bbf7b28a8d0cdb849575797de

Python documentation:
  Python 3.15.2 pathlib
  Python 3.15.2 os

Linux requirement for openat2 reference path:
  kernel API introduced in Linux 5.6

Linux’s current openat2(2) documentation records the syscall’s history as Linux 5.6. It also states that glibc supplies no openat2() wrapper, so userspace normally invokes it through syscall(SYS_openat2, ...).

The executable fixture below deliberately uses only Python’s documented os.open(..., dir_fd=...), O_NOFOLLOW, and O_DIRECTORY path for the runnable baseline. That keeps the core experiment inside CPython’s standard library. The openat2() discussion remains a separately qualified Linux design option rather than a falsely portable Python API. Python’s os reference documents os.open and its directory-descriptor support, and states that dir_fd support currently works on Unix platforms and not Windows.

Pin the runtime and source hashes

Before running any case, assert the interpreter version and preserve the exact source identities:

set -eu

python3 -VV
python3 - <<'PY'
import os, platform, sys
print("executable:", sys.executable)
print("version:", sys.version)
print("platform:", platform.platform())
print("system:", platform.system())
print("release:", platform.release())
print("machine:", platform.machine())
print("os.name:", os.name)
print("dir_fd_open:", os.open in os.supports_dir_fd)
print("O_NOFOLLOW:", hasattr(os, "O_NOFOLLOW"))
print("O_DIRECTORY:", hasattr(os, "O_DIRECTORY"))
PY

sha256sum symlink_race_lab.py expected-cases.json

Assertion: the runtime must be the version being qualified, and the fixture and oracle hashes must match the frozen values in this article.

EXPECTED / NOT EXECUTED: CPython 3.15.2 on a qualifying Unix host reports directory-descriptor support plus the required flags.

Reject condition: wrong interpreter, changed fixture bytes, changed oracle bytes, missing capability output, or any result inferred from documentation rather than recorded from the execution host.

Build an isolated fixture with synthetic inputs

Use a disposable directory owned by the fixture. Do not substitute uploads, application configuration directories, production mounts, customer paths, or secrets. The test payloads are deliberately distinguishable ASCII strings: SAFE\n and OUTSIDE\n. That makes a false-positive success visible without relying on timestamps or ambiguous filenames.

This containment experiment is adjacent in method, but not subject, to Refonte’s SQLite BUSY_SNAPSHOT transaction retry playbook. That article uses controlled interleavings and explicit recovery boundaries for database snapshots; here the controlled interleaving is a filesystem namespace change between validation and open.

Save the following source as symlink_race_lab.py using UTF-8, LF line endings and a final newline. Its SHA-256 is:

c462f79db4c5c25a1f6fcd2fb9eded985f3f9c0b4812982fc8cc8f42c81e00b8
#!/usr/bin/env python3
import hashlib
import json
import os
import platform
import stat
import sys
import tempfile
from pathlib import Path, PurePosixPath

REVISION = "refonte-python-symlink-race-v1"
SAFE = b"SAFE\n"
OUTSIDE = b"OUTSIDE\n"

def read_fd(fd):
    chunks = []
    while True:
        b = os.read(fd, 65536)
        if not b:
            break
        chunks.append(b)
    return b"".join(chunks)

def identity_fd(fd):
    st = os.fstat(fd)
    return {
        "dev": st.st_dev,
        "ino": st.st_ino,
        "mode": stat.S_IFMT(st.st_mode),
    }

def validate_path(root, candidate):
    resolved = candidate.resolve(strict=True)
    return {
        "resolved": str(resolved),
        "inside": resolved.is_relative_to(root),
        "regular": resolved.is_file(),
    }

def open_relative(root_fd, rel, flags=0):
    fd = os.open(
        rel,
        os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | flags,
        dir_fd=root_fd,
    )
    try:
        return {
            "bytes": read_fd(fd).decode("ascii"),
            "identity": identity_fd(fd),
        }
    finally:
        os.close(fd)

def open_no_symlink_beneath(root_fd, rel):
    p = PurePosixPath(rel)
    if (
        p.is_absolute()
        or not p.parts
        or any(part in ("", ".", "..") for part in p.parts)
    ):
        raise ValueError(
            "relative path without '.', '..', or empty components required"
        )

    nofollow = os.O_NOFOLLOW
    cloexec = getattr(os, "O_CLOEXEC", 0)
    directory = os.O_DIRECTORY

    current = os.dup(root_fd)
    try:
        for part in p.parts[:-1]:
            nxt = os.open(
                part,
                os.O_RDONLY | directory | nofollow | cloexec,
                dir_fd=current,
            )
            os.close(current)
            current = nxt

        fd = os.open(
            p.parts[-1],
            os.O_RDONLY | nofollow | cloexec,
            dir_fd=current,
        )
        try:
            st = os.fstat(fd)
            if not stat.S_ISREG(st.st_mode):
                raise RuntimeError("final object is not a regular file")
            return {
                "bytes": read_fd(fd).decode("ascii"),
                "identity": identity_fd(fd),
            }
        finally:
            os.close(fd)
    finally:
        os.close(current)

def record(case, fields):
    return {"case": case, fields}

def main():
    report = {
        "fixture_revision": REVISION,
        "python": sys.version,
        "implementation": platform.python_implementation(),
        "platform": platform.platform(),
        "system": platform.system(),
        "release": platform.release(),
        "machine": platform.machine(),
        "supports_dir_fd_open": os.open in os.supports_dir_fd,
        "has_O_NOFOLLOW": hasattr(os, "O_NOFOLLOW"),
        "has_O_DIRECTORY": hasattr(os, "O_DIRECTORY"),
        "cases": [],
    }

    required = (
        os.name == "posix"
        and os.open in os.supports_dir_fd
        and hasattr(os, "O_NOFOLLOW")
        and hasattr(os, "O_DIRECTORY")
    )

    if not required:
        report["cases"].append(record(
            "unsupported_platform",
            status="UNSUPPORTED_PLATFORM",
            reason="requires POSIX dir_fd, O_NOFOLLOW, and O_DIRECTORY",
        ))
        print(json.dumps(report, indent=2, sort_keys=True))
        return

    with tempfile.TemporaryDirectory(
        prefix="refonte-symlink-race-"
    ) as td:
        ws = Path(td)
        root = ws / "root"
        outside = ws / "outside"
        root.mkdir()
        outside.mkdir()

        (root / "safe.txt").write_bytes(SAFE)
        (outside / "outside.txt").write_bytes(OUTSIDE)

        root_resolved = root.resolve(strict=True)
        root_fd = os.open(
            root_resolved,
            os.O_RDONLY
            | os.O_DIRECTORY
            | getattr(os, "O_CLOEXEC", 0),
        )

        try:
            candidate = root / "safe.txt"
            validation = validate_path(root_resolved, candidate)
            with open(candidate, "rb") as f:
                data = f.read()
            report["cases"].append(record(
                "safe_child_path",
                validation=validation,
                opened_bytes=data.decode("ascii"),
            ))

            sibling = ws / "root-archive"
            sibling.mkdir()
            (sibling / "outside.txt").write_bytes(OUTSIDE)
            candidate = sibling / "outside.txt"
            validation = validate_path(root_resolved, candidate)
            report["cases"].append(record(
                "sibling_prefix",
                naive_startswith=str(candidate).startswith(
                    str(root_resolved)
                ),
                validation=validation,
            ))

            link = root / "escape.txt"
            link.symlink_to(outside / "outside.txt")
            validation = validate_path(root_resolved, link)
            report["cases"].append(record(
                "symlink_to_outside",
                validation=validation,
            ))

            swap = root / "swap.txt"
            swap.symlink_to(root / "safe.txt")
            before = validate_path(root_resolved, swap)
            swap.unlink()
            swap.symlink_to(outside / "outside.txt")
            with open(swap, "rb") as f:
                after_bytes = f.read()
                after_id = identity_fd(f.fileno())
            report["cases"].append(record(
                "symlink_swap_after_validation",
                validation_before=before,
                opened_bytes=after_bytes.decode("ascii"),
                opened_identity=after_id,
            ))

            opened = open_relative(root_fd, "safe.txt")
            report["cases"].append(record(
                "descriptor_relative_open",
                opened=opened,
            ))

            leaf = root / "leaf-link.txt"
            leaf.symlink_to(root / "safe.txt")
            try:
                opened = open_relative(
                    root_fd,
                    "leaf-link.txt",
                    os.O_NOFOLLOW,
                )
                result = {"blocked": False, "opened": opened}
            except OSError as exc:
                result = {
                    "blocked": True,
                    "errno": exc.errno,
                    "error": type(exc).__name__,
                }
            report["cases"].append(record(
                "no_follow_policy",
                result=result,
            ))

            nested = root / "nested"
            nested.mkdir()
            (nested / "payload.txt").write_bytes(SAFE)

            outside_nested = outside / "nested-target"
            outside_nested.mkdir()
            (outside_nested / "payload.txt").write_bytes(OUTSIDE)

            before = validate_path(
                root_resolved,
                nested / "payload.txt",
            )

            nested_old = root / "nested-original"
            nested.rename(nested_old)
            nested.symlink_to(
                outside_nested,
                target_is_directory=True,
            )

            vulnerable = open_relative(
                root_fd,
                "nested/payload.txt",
                os.O_NOFOLLOW,
            )

            try:
                open_no_symlink_beneath(
                    root_fd,
                    "nested/payload.txt",
                )
                repair = {"blocked": False}
            except OSError as exc:
                repair = {
                    "blocked": True,
                    "errno": exc.errno,
                    "error": type(exc).__name__,
                }
            finally:
                nested.unlink()
                nested_old.rename(nested)

            report["cases"].append(record(
                "nested_directory_race",
                validation_before=before,
                vulnerable_open=vulnerable,
                repair_no_symlink_walk=repair,
            ))

            report["cases"].append(record(
                "unsupported_platform",
                status="NOT_APPLICABLE_ON_THIS_SUPPORTED_PLATFORM",
            ))
        finally:
            os.close(root_fd)

        report["workspace_removed_after_context"] = False

    report["workspace_removed_after_context"] = not Path(td).exists()

    print(json.dumps(report, indent=2, sort_keys=True))

if name == "__main__":
    main()

The source intentionally stages namespace replacement itself. It is not a scheduler benchmark and does not require a winning attacker thread. That deterministic sequencing proves the logical gap between check and use without claiming a real-world exploit probability.

Author the independent expected-case manifest

The fixture must not invent its oracle from whatever it observes. Freeze expected-cases.json first, review it separately, and hash it before execution. The expected cases are policy statements derived from documented pathname semantics and the synthetic layout, not summaries generated from the eventual evidence file.

Save exactly:

{
  "cases": [
    {
      "case": "safe_child_path",
      "expect": {
        "opened_bytes": "SAFE\n",
        "validation.inside": true
      }
    },
    {
      "case": "sibling_prefix",
      "expect": {
        "naive_startswith": true,
        "validation.inside": false
      }
    },
    {
      "case": "symlink_to_outside",
      "expect": {
        "validation.inside": false
      }
    },
    {
      "case": "symlink_swap_after_validation",
      "expect": {
        "opened_bytes": "OUTSIDE\n",
        "validation_before.inside": true
      }
    },
    {
      "case": "descriptor_relative_open",
      "expect": {
        "opened.bytes": "SAFE\n"
      }
    },
    {
      "case": "no_follow_policy",
      "expect": {
        "result.blocked": true
      }
    },
    {
      "case": "nested_directory_race",
      "expect": {
        "repair_no_symlink_walk.blocked": true,
        "validation_before.inside": true,
        "vulnerable_open.bytes": "OUTSIDE\n"
      }
    },
    {
      "case": "unsupported_platform",
      "expect": {
        "supported_baseline": "NOT_APPLICABLE_ON_THIS_SUPPORTED_PLATFORM",
        "unsupported_host": "UNSUPPORTED_PLATFORM"
      }
    }
  ],
  "fixture_revision": "refonte-python-symlink-race-v1",
  "status": "EXPECTED / NOT EXECUTED"
}

The SHA-256 of those exact bytes, including the final newline, is:

ea3bc5e9cacd0e82b9a2c81f63b43bdd833f04458f2c235994a3d9e23a2bf05b

Freeze the oracle before executing

Assertion: expected-cases.json must exist and match the published hash before symlink_race_lab.py runs.

EXPECTED / NOT EXECUTED: all eight named case identities exist exactly once in the manifest.

Reject condition: the manifest is regenerated from evidence, edited after observing a failure, narrowed to remove a disagreeing case, or expanded with undocumented expectations after the run.

This is the central defense against false-positive success. A process exit of zero only says that the harness reached completion. It does not mean that OUTSIDE\n appeared only where intended, that a negative control blocked, or that all required cases were recorded.

Reproduce the misleading success condition

The most important case is symlink_swap_after_validation.

At time A, root/swap.txt points to root/safe.txt. Path.resolve(strict=True) is expected to return that safe target, and is_relative_to(root) is expected to be true. Python documents resolve() as making a path absolute and resolving symlinks; the result is a pathname value, not an already-open file descriptor.

The fixture then removes swap.txt and creates a new symlink at the same pathname pointing to the synthetic outside file. Only after that does it call Python’s built-in open(swap, "rb").

EXPECTED / NOT EXECUTED: validation reports inside true; the later pathname open reads OUTSIDE\n.

That result would not mean that Path.resolve() malfunctioned. It would mean the application asked two different questions at two different times. The first filesystem lookup answered what swap.txt meant during validation. The second filesystem lookup answered what swap.txt meant during open. The namespace was deliberately changed in between.

Linux’s open(2) documentation makes the kernel-side distinction useful: after a successful open(), the returned descriptor refers to an open file description and remains a reference to that object even if the pathname is later removed or changed to identify another file. The vulnerability window exists before that stable descriptor has been obtained.

A common attempted repair is “resolve first, then open the original input again.” That preserves the race. Another is “validate twice.” That merely creates a second check/use interval. A third is “use is_relative_to().” Python explicitly states that method is string-based and does not access the filesystem, so it cannot itself freeze namespace identity.

The accepted repair boundary therefore moves validation as close as possible to the descriptor that will actually be used.

Run the controlled positive comparison

The descriptor_relative_open case separates one useful property from the stronger property engineers often assume.

The fixture first opens the trusted root directory and retains its descriptor. It then calls:

os.open("safe.txt", os.O_RDONLY, dir_fd=root_fd)

Python documents that os.open() can interpret a relative path against a supplied directory descriptor. Linux documents the corresponding openat() behavior and specifically explains directory FDs as a way to avoid races caused by replaceable pathname prefixes.

EXPECTED / NOT EXECUTED: the simple child safe.txt opens as SAFE\n.

That is a useful control, but it is not yet proof that every descendant path is confined. A relative string such as nested/payload.txt still contains additional pathname components that must be resolved below the root descriptor. If one of those components becomes a symlink, ordinary openat() semantics may follow it. Descriptor relativity fixes the starting anchor; it does not automatically impose a “must remain beneath this directory” policy on all later components.

That distinction is why the GitHub Actions matrix coverage article is a relevant methodological comparison: it distinguishes a successful aggregate status from proof that every required matrix identity actually ran. Here the analogous discipline is to distinguish “used dir_fd” from proof that every pathname component obeyed the intended containment rule. The systems and cases are otherwise unrelated.

Retain the negative-control evidence

The negative control is nested_directory_race, not the safe child.

Assertion: a qualifying run must preserve evidence that ordinary descriptor-relative os.open("nested/payload.txt", ..., dir_fd=root_fd) can be redirected when nested is replaced with an outside-pointing symlink.

EXPECTED / NOT EXECUTED: the vulnerable open reads OUTSIDE\n.

Reject condition: the negative control is deleted because it “fails,” or a passing repair case is presented without the corresponding vulnerable comparison.

Probe failures and boundary conditions

O_NOFOLLOW deserves special care because its name invites overclaiming. Linux documents that when the trailing component is a symlink, open() with O_NOFOLLOW fails with ELOOP; it also explicitly says symbolic links in earlier pathname components are still followed.

The no_follow_policy case therefore creates only a final-component symlink:

root/
  leaf-link.txt -> root/safe.txt

It opens leaf-link.txt relative to the trusted root FD with O_NOFOLLOW.

EXPECTED / NOT EXECUTED: the call is blocked with an OSError; on Linux the documented final-component condition corresponds to ELOOP. The ledger should preserve the numeric errno rather than assert that every Unix platform will format the same exception text.

The nested case then deliberately changes an earlier component:

before:
  root/nested/payload.txt -> SAFE

after:
  root/nested -> outside/nested-target
  outside/nested-target/payload.txt -> OUTSIDE

The candidate call remains:

os.open(
    "nested/payload.txt",
    os.O_RDONLY | os.O_NOFOLLOW,
    dir_fd=root_fd,
)

EXPECTED / NOT EXECUTED: it reads OUTSIDE\n, because O_NOFOLLOW applies to payload.txt, while nested is an earlier component and can still be followed under ordinary openat() semantics. That expectation follows directly from Linux’s documented flag behavior.

Platform qualification is equally explicit. Python says dir_fd support currently works on Unix and not Windows. Therefore a Windows result is not “test passed differently”; for this fixture it is UNSUPPORTED_PLATFORM → HOLD unless a separately designed Windows mechanism and oracle are provided.

Capture a per-case evidence ledger

A useful evidence file must answer more than “did Python raise?” The fixture emits runtime provenance plus one record per case. Preserve the output as immutable build evidence:

set -eu

python3 symlink_race_lab.py > evidence.json

python3 - <<'PY'
import json
from pathlib import Path

p = Path("evidence.json")
obj = json.loads(p.read_text())
print("fixture_revision:", obj["fixture_revision"])
print("case_count:", len(obj["cases"]))
for row in obj["cases"]:
    print(row["case"])
print("workspace_removed_after_context:",
      obj.get("workspace_removed_after_context"))
PY

sha256sum \
  symlink_race_lab.py \
  expected-cases.json \
  evidence.json

A case ledger suitable for release review should retain at least the following fields where applicable:

Evidence field

Why it matters

Case identity

Reconciliation denominator

Fixture revision

Prevents mixing protocols

Interpreter and executable

Identifies runtime under test

OS, kernel, machine

Determines platform applicability

supports_dir_fd_open

Records actual Python capability

O_NOFOLLOW / O_DIRECTORY presence

Records flag availability

Resolved pathname

Shows validation-time target

inside result

Shows admission predicate

Opened bytes

Distinguishes SAFE from OUTSIDE

st_dev, st_ino

Identifies opened object within the run

Exception type and errno

Diagnoses expected blocks

Cleanup state

Confirms disposable fixture restoration

Device and inode values are run-local evidence, not portable constants. Linux documents that a successful open() returns a descriptor referring to an open file description and that later pathname changes do not retarget that descriptor. That is why descriptor identity belongs in the ledger.

Reject missing and duplicate records

Assertion: the set of required manifest identities must equal the set of observed case identities, and every observed required identity must occur once.

EXPECTED / NOT EXECUTED: eight manifest identities reconcile to eight evidence identities on a supported baseline; unsupported hosts may terminate with the one explicit unsupported record and therefore produce HOLD rather than a fabricated eight-case pass.

Reject condition: missing case, duplicate case, malformed JSON, wrong fixture revision, missing runtime metadata, absent expected hash, partial redirection output, or cleanup failure.

A zero exit status cannot compensate for a missing denominator.

Reconcile identities and final state

Reconciliation should happen after execution and independently of the fixture logic. Do not change the oracle to fit the evidence. For each dotted expectation key, traverse the evidence record and compare the declared value.

A minimal reconciler can be run inline:

python3 - <<'PY'
import json
from collections import Counter

manifest = json.load(open("expected-cases.json"))
evidence = json.load(open("evidence.json"))

expected_ids = [x["case"] for x in manifest["cases"]]
rows = evidence.get("cases", [])
observed_ids = [x.get("case") for x in rows]

counts = Counter(observed_ids)
duplicates = sorted(k for k, v in counts.items() if v != 1)
missing = sorted(set(expected_ids) - set(observed_ids))
unexpected = sorted(set(observed_ids) - set(expected_ids))

print("missing:", missing)
print("duplicates:", duplicates)
print("unexpected:", unexpected)

if evidence.get("cases") == [{
    "case": "unsupported_platform",
    "reason": "requires POSIX dir_fd, O_NOFOLLOW, and O_DIRECTORY",
    "status": "UNSUPPORTED_PLATFORM",
}]:
    print("DECISION=HOLD")
    raise SystemExit(0)

if missing or duplicates or unexpected:
    print("DECISION=RERUN")
    raise SystemExit(2)

print("IDENTITY_RECONCILIATION=COMPLETE")
print("VALUE_RECONCILIATION=REQUIRES_MANIFEST_COMPARISON")
PY

For production release automation, implement full dotted-field comparison and make disagreement machine-failing. The abbreviated command above intentionally does not pretend to be a complete acceptance engine.

The Probability calibrator release validation article is an adjacent example of binding a release decision to identities and an independently defined contract rather than celebrating one favorable metric. Its model-calibrator pairing logic is not reused here; the relevant overlap is the evidence discipline.

For this pathname playbook, final-state reconciliation also requires cleanup. The fixture restores the renamed nested directory before TemporaryDirectory exits. The evidence must record that the temporary workspace no longer exists after context exit. Cleanup success does not erase the earlier OUTSIDE\n evidence; it only proves that the disposable test state was restored.

Separate documentation from observation

Three classes of statement must remain distinct.

Documented behavior comes from primary references. Python documents Path.resolve() symlink resolution, is_relative_to() as string-based, os.open(..., dir_fd=...), and Unix-only dir_fd support. Linux documents O_NOFOLLOW as final-component-only, the race-avoidance rationale for directory descriptors, and openat2() resolution flags.

Hypothesis is what this fixture predicts from those contracts: after replacing swap.txt, reopening that pathname is expected to return OUTSIDE\n; after replacing an intermediate nested component, final-component O_NOFOLLOW is expected not to stop the escape.

Observed result exists only after a qualifying execution. This article contains none; every test remains EXPECTED / NOT EXECUTED.

The related article Python Path Containment: Prefix Checks Can Admit Escapes covers prefix errors, symlink resolution, a deliberate namespace-change case, and the conclusion that a resolved pathname is not a retained open handle; it also identifies openat2() as an escalation context.

Related readings include Logrotate Finished, but the Application Is Writing to the Old File, which centers on pathname-versus-open-descriptor identity, and Python NamedTemporaryFile: Can Another Process Reopen It After Close?, which centers on pathname lifetime and cross-platform reopenability.

Related reading

Main intent

Example

Audience

Tested decision

Scope boundary

Python Path Containment

Static resolved-location admission and its mutable-namespace limit

Prefix, symlink escape, deterministic namespace change

Python/security engineers

Accept static file or hold mutable namespace

Covers commonprefix, commonpath, and the existing check/use race

Logrotate descriptor continuity

Which inode a live writer still references after rotation

Rename/create log rotation

Linux operators

Reopen/reconcile descriptor

Focuses on logging continuity

NamedTemporaryFile reopen

Pathname lifecycle and reopen timing

Temporary-file close/context boundaries

Python application engineers

Accept/repair/hold/reconcile lifecycle

Focuses on temporary-file semantics

This playbook focuses on the next steps: qualify descriptor-relative containment, demonstrate why dir_fd + O_NOFOLLOW is not equivalent to all-component confinement, exercise an intermediate-component swap, contrast that with a descriptor walk that rejects symlinks component by component, and define the Linux openat2(RESOLVE_BENEATH) escalation boundary.

Explain the repair boundary

Assertion: do not repair the race by adding another pathname check.

EXPECTED / NOT EXECUTED: the vulnerable controls continue to demonstrate that validation-time pathname state and open-time pathname state can differ.

Reject condition: a proposed “fix” still performs a security decision on one pathname lookup and then performs an unconstrained later lookup of attacker-mutable components.

Implement the smallest safe repair

For the policy in this fixture, the smallest pure-Python repair is intentionally conservative: reject . and .., disallow symlinks in every relative component, open each directory component relative to an already-open descriptor with O_DIRECTORY | O_NOFOLLOW, then open the final file relative to the last directory descriptor with O_NOFOLLOW, and finally validate the opened object with fstat().

That is what open_no_symlink_beneath() implements.

This repair changes the security object from “a pathname that previously resolved inside” to “the chain of descriptors successfully opened under the declared no-symlink policy.” Once a component directory is opened, subsequent traversal is relative to that stable descriptor rather than to a previously checked spelling. Linux documents directory FDs as stable references even when directory pathnames are renamed, which is the underlying property the repair uses.

For nested_directory_race, the intermediate nested entry becomes a symlink after the validation step. The vulnerable single os.open("nested/payload.txt", O_NOFOLLOW, dir_fd=root_fd) is expected to follow nested. The repair instead attempts to open nested itself with O_DIRECTORY | O_NOFOLLOW.

EXPECTED / NOT EXECUTED: the repair blocks the substituted symlink and never opens OUTSIDE\n.

This is not a general sandbox. It deliberately rejects all symlink components, which may be stricter than an application’s real policy. It says nothing about hard-link provenance, bind mounts, mount replacement, remote filesystem behavior, ACL authorization, content trust, or whether the trusted root itself was acquired securely.

If the application must permit symlinks that remain beneath the root, Linux openat2() is the more relevant primitive. RESOLVE_BENEATH prevents resolution from succeeding when a component escapes the directory represented by dirfd; the manpage recommends specifying RESOLVE_NO_MAGICLINKS explicitly rather than relying on RESOLVE_BENEATH’s current magic-link side effect. If the application forbids symlinks entirely, RESOLVE_NO_SYMLINKS applies to all path components and differs materially from ordinary O_NOFOLLOW.

A Linux-specific candidate might therefore use:

dirfd = trusted root descriptor
how.flags   = O_RDONLY | O_CLOEXEC
how.resolve = RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS

or, for a no-symlink policy:

how.resolve =
    RESOLVE_BENEATH |
    RESOLVE_NO_MAGICLINKS |
    RESOLVE_NO_SYMLINKS

This pseudocode is not a Python standard-library call. The Linux documentation states that even glibc has no wrapper and requires syscall(); Python’s documented os interface exposes os.open(..., dir_fd=...), not a documented os.openat2() API. That is a platform integration requiring its own reviewed binding, ABI handling, tests, and fallback decision.

Test rollback, recovery and rerun safety

The disposable fixture mutates only objects it creates. In the nested case it renames root/nested to root/nested-original, installs a synthetic symlink at root/nested, exercises both candidate opens, then unlinks only that synthetic symlink and renames the original directory back.

That restoration sequence is intentionally narrow. It does not justify recursive removal of unknown paths, privilege changes, ACL rewrites, ownership repair, or replacement of shared directories in a real service.

The recovery contract is:

  • Preserve the evidence file before changing the implementation.

  • Restore only fixture-owned objects whose identity is known.

  • Do not reinterpret a reproduced OUTSIDE\n read as harmless simply because cleanup later succeeded.

  • Modify the candidate opening design, not the frozen expected manifest.

  • Hash the repaired source.

  • Rerun all eight cases under the same qualified baseline.

  • If platform support changes, treat that as a new qualification rather than merging old and new evidence.

The Pytest strict XPASS release gate article is relevant at the evidence-policy layer: an apparently green test process can carry a result whose release meaning differs from “accept.” Its technical subject is pytest exception policy rather than filesystem races, so its fixture and XPASS semantics should not be transplanted here.

Rollback for application code means returning to the last opening implementation whose evidence package still satisfies the current trust model. That is not automatically equivalent to reverting a commit. If the old implementation used pathname validation followed by an unconstrained reopen, restoring it would restore the same known defect.

Assign recovery ownership

Assertion: the owner of the opening policy must be identified before repair.

EXPECTED / NOT EXECUTED: application engineering owns path semantics and candidate code; platform engineering owns kernel/filesystem capability qualification; security review owns the declared confinement property and exceptions; release engineering owns completeness of the evidence gate.

Reject condition: a failing race case is assigned to “the filesystem” with no owner, or a platform limitation is silently converted into an application pass.

Apply the ACCEPT / REPAIR / RERUN / HOLD matrix

The final gate is deliberately stricter than the harness exit code.

Decision

Evidence condition

Release action

ACCEPT

Supported platform; exact fixture and oracle hashes; all required case identities present once; values reconcile; vulnerable controls behave as expected; repaired candidate blocks the nested escape; opened-object checks pass; cleanup succeeds

Approve only the qualified opening design and declared policy

REPAIR

Evidence is complete and reproduces a defect in the candidate design, such as validation followed by reopening a mutable pathname or relying on final-component O_NOFOLLOW for nested containment

Change the opening mechanism, preserve the failed evidence, and rerun

RERUN

Applicable platform, but execution was not performed, evidence is partial, hash differs, records are missing/duplicated, or output is malformed

Produce a clean evidence set from the frozen protocol

HOLD

Required platform primitives are unavailable, openat2() semantics are needed but not integrated and qualified, trust-root ownership is unresolved, or the claimed containment property exceeds the tested mechanism

Do not approve containment until the design boundary is resolved

As of November 11, 2026, the release decision is:

RERUN

Reason:

All eight case outcomes are EXPECTED / NOT EXECUTED.
No runtime ledger has been supplied.
No ACCEPT claim is supportable yet.

After a real run, a reproduced symlink_swap_after_validation or nested_directory_race is not itself a harness failure; it is expected negative-control evidence. If the application candidate under release still uses that vulnerable pattern, the application release decision becomes REPAIR.

If the repaired no-symlink descriptor walk is the candidate and every required case reconciles, the decision may become ACCEPT for that exact policy and platform. Acceptance does not extend to symlink-permitting policy, Windows, mount-confinement claims, arbitrary network filesystems, or Linux openat2() until those are separately qualified.

If the application requires in-root symlinks and therefore needs RESOLVE_BENEATH, but no reviewed binding or helper exists on the target system, the decision is HOLD, not “fall back to resolve() and hope the interval is small.”

Develop related skills with Refonte Learning

This procedure sits within broader cloud-security engineering concerns such as trust boundaries, secure software lifecycle work, incident response, and risk assessment. Refonte Learning’s current Cloud Security Engineer Essentials page lists a three-month period, an expected dedication of 10–12 hours per week, and recommends basic networking and cloud-computing knowledge. It also lists cloud architecture security, IAM, threat detection, incident response, secure SDLC, risk assessment, monitoring, governance, and Zero Trust among the competencies presented on the page.

The same page states an admission prerequisite of working toward a bachelor’s or higher-level degree. It says successful completion receives a Training Certificate and a Certificate of Internship, and separately describes a “Potential Internship” among the program characteristics. These statements do not establish guaranteed placement or employment outcomes.

Nothing on the reviewed program page establishes specialized instruction in Python symlink races, openat2(), descriptor-relative pathname containment, or the exact fixture in this article. Engineers who want broader cloud-security foundations can review the program’s published curriculum and prerequisites, while treating this pathname-race playbook as a separate technical procedure whose acceptance still depends on direct runtime evidence.