A Linux administrator changes a regular file from mode 0600 to 000. The metadata check succeeds. A new O_RDONLY open fails with EACCES. So do new O_WRONLY and O_RDWR opens. Yet a process that opened the file earlier can still read b"BASELINE\n" through its retained read/write descriptor, overwrite those nine bytes with b"CHANGED!\n", and read the change back through a separately retained read-only descriptor.
That is the operational question this playbook isolates: did the permission change stop the access path you intended to stop, or did it only stop fresh pathname opens in the tested context?
The preparation deliberately holds the pathname and device/inode identity constant. It does not use replacement, rotation, unlinking, locks, inheritance, mappings, or descriptor transfer. Linux open(2) distinguishes the pathname open operation, its requested access mode, the returned descriptor, and the open file description behind it; chmod(2) documents the mode-bit change itself. Neither fact alone is enough to infer what every later I/O operation will do under every filesystem or security policy.
The preparation result is a controlled laboratory observation, not a production incident and not proof of production containment. The useful outcome is narrower: pair denied fresh opens with retained-descriptor tests, close every known reference in a controlled holder, and keep permission restoration separate from content recovery.
Define the access-cessation claim before changing permissions
Start with the policy claim, not with chmod.
For this experiment, “access cessation” has three separately testable parts. First, after the file reaches mode 000, new pathname opens for read, write, and combined read/write should be denied under the captured process identity. Second, a descriptor opened before the mode change must be tested rather than assumed to have lost access. Third, if that known holder remains capable of I/O, there must be an explicit, reviewable procedure for stopping that holder from issuing future operations.
Those statements are deliberately narrower than “nobody can access this file.” They say nothing about bytes already copied into another process, unknown descriptors elsewhere, file-backed mappings, in-flight operations, or security mechanisms outside this fixture. Those unresolved conditions require evidence of their own.
The responsibility split should be explicit before anyone interprets the result. The application owner owns the lifecycle of descriptors held by the controlled process: when I/O stops, which handles exist, and when closure is acknowledged. Operations owns evidence about the file object and execution environment: pathname, device/inode, mode, filesystem, runtime, and the actual operation results. Security or service ownership decides whether that evidence satisfies the intended containment claim. If content changed during the interval, the data owner decides whether and how it is recovered.
This is also an owner-controlled laboratory. The fixture creates its own temporary regular file and can change its mode back to 0600. chmod(2) permits the file owner, or a caller with the relevant privilege, to change mode bits. That means the exercise is useful for diagnosing ordinary permission behavior, but it is not a design for containing a hostile owner who can simply restore permissions.
Finally, keep erasure out of the claim. Stopping a future read does not erase information already read. Stopping a future write does not undo a write already completed. Permission control, descriptor closure, and content recovery are three different operational decisions.
Capture the process and filesystem boundary
Permission evidence is meaningful only with an execution boundary attached to it. Record the process identity, capability state, runtime, kernel, architecture, C library, filesystem, and relevant security policy before interpreting either a denial or a success.
The preparation manifest supplied for this article was:
Property | Preparation value |
Python runtime | CPython 3.12.14 |
Kernel | Linux 6.18.55 |
Architecture | x86_64 |
C library | glibc 2.39 |
Filesystem actually exercised | overlayfs |
Effective UID | 0 |
Effective GID | 0 |
Effective capabilities | CapEff=0000000000000000 |
Fixture result | PASS_11_CASES |
Latest preparation device/inode | (27, 1052377) |
The device/inode pair is an observation from that run, not a constant that another run should reproduce. A rerun must compare its own contemporaneous stat and fstat identities.
The fixture contains two important guards. It reads /proc/self/status and stops unless the effective capability mask is zero. It also obtains the filesystem type through a GNU-compatible stat -f -c %T call and stops unless the returned name is one of overlayfs, tmpfs, ext2/ext3, ext4, xfs, or btrfs. That allowlist is a harness boundary, not evidence that all six filesystem families were tested. Preparation exercised only overlayfs.
Linux capabilities matter because the Linux privilege model splits powers historically associated with UID 0 into independently controlled capabilities. CAP_DAC_OVERRIDE can bypass ordinary file read, write, and execute permission checks; CAP_DAC_READ_SEARCH can bypass specified read/search checks. The reviewed capabilities(7) documentation therefore supports recording actual capability state rather than treating an account label as a complete privilege description.
Likewise, the fixture's low-level calls are documented in Python's os module. The page reviewed on October 24, 2026 displayed Python 3.12.15 documentation, while the preparation execution reported CPython 3.12.14. The documentation describes os.open() as a low-level descriptor interface and os.pread()/os.pwrite() as explicit-offset descriptor I/O; that documentation patch version must not be silently substituted for the runtime version.
These habits sit inside broader Linux administration foundations: identify the operating environment, permissions, process state, and recovery boundary before changing a live assumption. The adjacent Refonte material covers Linux, security, troubleshooting, command-line work, networking, and recovery as administration concerns; this playbook remains focused on one regular file and its descriptors.
Record credentials instead of inferring privilege
Do not rewrite the preparation as “an unprivileged user test” or “a normal non-root test.” It was neither.
The observed process had effective UID 0, effective GID 0, and an all-zero effective capability mask. The accurate description is a capability-constrained UID-0 process. That distinction matters precisely because UID alone is an incomplete explanation of Linux authorization, while “non-root” would be factually wrong.
The negative controls are therefore especially valuable. Whatever one expects from the numeric UID, actual os.open() calls under the captured process context produced EACCES for all three requested fresh-open modes after mode 000. Those observed operations are stronger evidence for this laboratory than reasoning from the label “root.”
Before carrying the conclusion into a service, rerun under that service's existing intended identity, filesystem, capability profile, process model, and security policy. Do not create a substitute account or alter enforcement merely to make the trace resemble this preparation. A materially different identity or policy produces a RERUN-TARGET decision, not permission to transplant the laboratory conclusion.
Establish an owned-file baseline and retain the handles
The fixture begins with a private temporary directory and one regular file containing exactly nine bytes: b"BASELINE\n". It changes that file to 0600, opens one O_RDWR descriptor, opens another O_RDONLY descriptor, and deliberately duplicates the read/write descriptor.
That creates three known local handles with two important relationships. rw and duplicate refer to the same open file description because duplicate comes from os.dup(rw). ro comes from a separate os.open() and has its own read-only access mode. Linux dup(2) documents that a duplicate descriptor refers to the same open file description as the original. Python documents os.dup() as returning a duplicate descriptor.
Save the following source exactly as security_fixture.py. The verified source SHA-256 is:
1f5c81944ce1f54d278024f1e5c3234fd0d5bfa1b4bff6180c8fde9074d383d4Run it with assertions enabled:
python3 security_fixture.pyDo not use Python's -O option. The fixture's assert statements are part of the evidence: they stop execution if identity, bytes, mode, counts, or expected errors diverge.
import errno
import json
import os
import platform
from pathlib import Path
import stat
import subprocess
import sys
import tempfile
if sys.platform != "linux":
raise SystemExit("HOLD: Linux required")
status = Path("/proc/self/status").read_text()
cap_eff = next(line.split()[1] for line in status.splitlines() if line.startswith("CapEff:"))
if int(cap_eff, 16) != 0:
raise SystemExit("HOLD: use a process with zero effective capabilities")
rows = []
def add(case, fields):
rows.append({"case": case, fields})
def ident(st):
return {"device": st.st_dev, "inode": st.st_ino, "mode": oct(stat.S_IMODE(st.st_mode))}
def expect_errno(call, expected, success_cleanup=None):
try:
result = call()
except OSError as exc:
assert exc.errno == expected, (exc.errno, expected)
return exc.errno
else:
if success_cleanup is not None:
success_cleanup(result)
raise AssertionError("operation unexpectedly succeeded")
with tempfile.TemporaryDirectory(prefix="refonte-chmod-fd-") as directory:
base = Path(directory)
fs = subprocess.check_output(["stat", "-f", "-c", "%T", directory], text=True).strip()
if fs not in {"overlayfs", "tmpfs", "ext2/ext3", "ext4", "xfs", "btrfs"}:
raise SystemExit("HOLD: unqualified local filesystem " + fs)
path = base / "owned-fixture.bin"
path.write_bytes(b"BASELINE\n")
path.chmod(0o600)
rw = ro = duplicate = None
try:
rw = os.open(path, os.O_RDWR)
ro = os.open(path, os.O_RDONLY)
duplicate = os.dup(rw)
original = ident(os.fstat(rw))
assert os.pread(ro, 9, 0) == b"BASELINE\n"
add("C1", result="baseline_read_and_write_descriptors_opened", original)
path.chmod(0o000)
now = ident(path.stat())
assert now["mode"] == "0o0"
assert (now["device"], now["inode"]) == (original["device"], original["inode"])
assert ident(os.fstat(rw)) == now
add("C2", result="mode_zero_same_object", now)
for case, mode, label in [("C3",os.O_RDONLY,"O_RDONLY"),("C4",os.O_WRONLY,"O_WRONLY"),("C5",os.O_RDWR,"O_RDWR")]:
e = expect_errno(lambda mode=mode: os.open(path,mode), errno.EACCES, os.close)
add(case, operation="fresh_open", access_mode=label, errno=e)
data = os.pread(rw, 9, 0)
assert data == b"BASELINE\n"
add("C6", operation="retained_pread", data=data.decode())
written = os.pwrite(rw, b"CHANGED!\n", 0)
assert written == 9
assert os.pread(ro, 9, 0) == b"CHANGED!\n"
add("C7", operation="retained_pwrite_and_readback", written=written, data="CHANGED!\n")
e = expect_errno(lambda: os.pwrite(ro,b"x",0), errno.EBADF)
add("C8", operation="readonly_descriptor_pwrite", errno=e)
os.close(rw)
rw = None
written = os.pwrite(duplicate,b"DUPLIVE!\n",0)
assert written == 9 and os.pread(ro,9,0) == b"DUPLIVE!\n"
add("C9", operation="duplicate_after_original_closed", written=written, data="DUPLIVE!\n")
os.close(ro)
ro = None
old_number = duplicate
os.close(duplicate)
duplicate = None
e = expect_errno(lambda: os.pwrite(old_number,b"x",0), errno.EBADF)
add("C10", operation="closed_descriptor_before_number_reuse", errno=e)
path.chmod(0o600)
fresh = os.open(path,os.O_RDONLY)
try:
data=os.pread(fresh,9,0)
assert data == b"DUPLIVE!\n"
final=ident(os.fstat(fresh))
assert final == original
add("C11", operation="mode_restored_fresh_open", data=data.decode(), **final)
finally:
os.close(fresh)
finally:
path.chmod(0o600)
for fd in (rw,ro,duplicate):
if fd is not None:
os.close(fd)
print(json.dumps({"status":"PASS_11_CASES", "python":platform.python_version(), "kernel":platform.release(), "platform":platform.platform(), "euid":os.geteuid(), "egid":os.getegid(), "CapEff":cap_eff, "filesystem":fs, "rows":rows},indent=2))The ordered design is important. Baseline descriptors are opened before restriction. Identity is captured before and after chmod. Fresh opens are attempted while the old descriptors remain live. Retained reads and writes follow. The original read/write handle is closed before its duplicate is tested. All known retained handles are then closed before the old numeric descriptor is probed. Only afterward is the mode restored.
The source depends on Linux /proc and a GNU-compatible stat -f -c %T invocation. No claim is made that those dependencies were tested on another operating system.
Keep the pathname and file object stable
This is not an inode-replacement experiment.
C1 records the device and inode through os.fstat(rw). After the mode change, C2 obtains identity from path.stat() and compares it with the original pair; it also requires os.fstat(rw) to match the path's current device, inode, and mode. A changed inode would invalidate the experiment because the question is specifically what happens when the same named regular file object changes permission mode while earlier descriptors remain open.
Linux's VFS documentation describes a file object as representing a file opened by a process, corresponding to the POSIX concept of an open file description. That gives useful terminology, but it does not by itself prove how every later security decision is made.
For the adjacent but intentionally different identity problem, see descriptor identity during log rotation. That workflow asks whether a writer stayed attached to an old object after pathname replacement. Here there is no replacement at all: the path, device, and inode remain the controlled constants.
Change the mode and verify the actual metadata
C2 is the first point where the phrase “the file is mode 000” becomes evidence rather than intention.
The fixture executes:
path.chmod(0o000)It then obtains the pathname's stat data, requires the resulting permission mode to be "0o0", compares device and inode with the C1 baseline, and requires os.fstat(rw) to report the same identity and mode.
That sequence establishes two things locally. The intended object's mode bits changed to zero, and the pathname plus retained descriptor still resolve to the same recorded device/inode object. chmod(2) documents chmod() as changing the mode bits of the pathname-designated file; the reviewed Linux man-pages 6.19 page is dated February 8, 2026, with the HTML rendering generated September 9, 2026.
What C2 does not establish is equally important. A successful metadata operation is not evidence that a process has relinquished a descriptor. A mode display is not evidence about the next pread() or pwrite(). An unchanged inode is not evidence that no other process has it open. And zero exit status from chmod would not, on its own, prove that the intended object was the one evaluated; that is why identity and current mode are checked together.
This is the first diagnostic discipline to retain for a real change: separate change execution from security effect. “The command succeeded” answers whether the metadata operation completed. “Fresh opens are denied” answers a different question. “The old holder can no longer issue I/O” is a third question and needs its own evidence.
Prove that fresh opens are denied
The preparation tests fresh pathname opens three times after C2, while the file remains mode 000 and while the earlier descriptors remain open.
C3 requests O_RDONLY. C4 requests O_WRONLY. C5 requests O_RDWR. All three attempts raise OSError with errno.EACCES, numeric value 13. The fixture asserts that exact error.
That matters because open(2) defines O_RDONLY, O_WRONLY, and O_RDWR as the requested read-only, write-only, and read/write access modes for a new open. It also documents EACCES when the requested file access is not allowed, while noting that EACCES can also arise from denied search permission in a pathname prefix or related access conditions.
The fixture controls the surrounding path by creating and using its own temporary directory, retaining the same pathname, and confirming the same target object immediately beforehand. That makes the three actual open attempts the negative controls of interest. Still, a rerun should preserve its actual error. A FileNotFoundError, changed device/inode, unexpected EPERM, successful open, or policy-specific error must not be rewritten as expected EACCES.
This is where the scope differs from Refonte's article on ACL masks and effective permissions. That adjacent diagnosis considers why a new access attempt can be denied despite a named ACL entry. This playbook does not repair ACL masks or analyze ACL algebra; its variable is the ordinary mode change on a stable object, followed by direct comparison of new and already-held access paths.
Use the attempted operation as the negative control
Do not replace the C3–C5 probes with a permission display or a generic preflight check.
The security claim is about open(), so test open(). Python documents os.open() as returning a low-level file descriptor on success and its os interfaces as raising OSError or a subclass when an operating-system operation fails.
The preparation's negative control therefore has a precise tuple:
operation = fresh os.open, requested access mode = O_RDONLY/O_WRONLY/O_RDWR, identity = captured capability-constrained UID-0 process, object = C2 device/inode, result = EACCES.
An unexpected success is not an invitation to weaken the assertion until the fixture passes. It means this fresh-open denial matrix is not reproduced in that environment. Keep the successful descriptor long enough to close it safely, preserve the result, capture the differing identity/filesystem/security-policy context, and choose RERUN-TARGET or HOLD-CONTAINMENT as appropriate.
The negative control is also distinct from fixture failure. For example, an unsupported filesystem stopped by the harness says “this run is outside the qualified local scope,” not “Linux permission revocation failed.”
Read and write through the retained descriptor
C6 asks the question that a chmod 000 containment assumption often leaves untested: what can the descriptor opened before the metadata change do now?
The fixture issues:
data = os.pread(rw, 9, 0)and requires exactly:
b"BASELINE\n"C7 then performs:
written = os.pwrite(rw, b"CHANGED!\n", 0)requires written == 9, and reads the same nine-byte range back through the separately retained O_RDONLY descriptor, requiring:
b"CHANGED!\n"The preparation therefore observed continued retained-descriptor reading and writing after mode 000, despite all three fresh pathname open modes returning EACCES.
The I/O form is deliberately low level. pread(2) and pwrite(2) operate on an existing descriptor at an explicit offset without changing the file offset; the calls return the number of bytes actually transferred, and a successful operation can legally transfer fewer bytes than requested. Python's os.pread() and os.pwrite() expose those explicit-offset operations, with os.pwrite() returning the number of bytes actually written. That is why the fixture asserts nine rather than assuming the entire payload was written.
Using raw descriptors, unbuffered os.pwrite(), and immediate os.pread() readback also removes a misleading explanation: C7 is not a Python buffered file object merely flushing data that had been staged before chmod. The write operation itself is issued after C2, at offset zero, through the retained descriptor, and its new bytes are read back from the file through a different retained descriptor.
The appropriate conclusion is still bounded. The preparation demonstrates a local result in this recorded environment: changing this regular file's mode to zero did not itself prevent the already-open O_RDWR descriptor from performing these tested operations.
Do not turn that into “Linux has no checks after open.” The kernel's fixed-version 6.11 security_file_permission documentation explicitly describes a hook used by operations that read or write an open file, allowing security modules to perform additional checks or revalidation. It separately notes that mapped access has its own considerations. The documentation version, Linux 6.11 API docs, is not the preparation's Linux 6.18.55 runtime.
Nor does readback establish crash durability. Successful write/read operations answer the byte-level question being tested; they do not establish that those bytes survived a later crash or reached physical media.
Separate descriptor access mode from current file mode
C8 prevents the opposite mistake: observing that one retained descriptor still writes does not mean every old descriptor suddenly becomes read/write capable.
The ro descriptor was originally created with:
os.open(path, os.O_RDONLY)After the mode is 000 and after C7 has demonstrated retained write access through rw, the fixture intentionally attempts:
os.pwrite(ro, b"x", 0)The preparation result is EBADF, errno 9.
That result must not be grouped with C3–C5 simply as “permission denied.” They answer different questions. C3–C5 are fresh pathname opens rejected with EACCES while requesting new read, write, or read/write access. C8 is an attempted write through a descriptor that was already opened read-only. Linux open(2) defines the three access modes used when opening the file; a later chmod changes file mode bits, not the historical access mode under which this descriptor was established.
That remains true when permissions are later restored. Returning the inode's permission bits to 0600 does not transform the old O_RDONLY descriptor into an O_RDWR descriptor. Conversely, reducing current mode bits to 000 did not, in this preparation, rewrite the already-established rw descriptor into read-only.
The practical point is that file mode versus descriptor access mode is not a semantic distinction for documentation alone. C7 and C8 turn it into an observable control: the retained O_RDWR path writes; the retained O_RDONLY path rejects a write.
Classify the failing operation before naming the cause
For every failure, keep five fields together:
operation, descriptor origin, requested action, error class, and bytes transferred.
That prevents two very different events from being flattened into a generic “permissions problem.”
For C5, the operation was a fresh os.open(path, os.O_RDWR) after mode zero; there was no returned descriptor and the observed error was EACCES. For C8, the descriptor already existed, originated from O_RDONLY, the requested operation was pwrite, no byte was accepted by the asserted operation, and the observed error was EBADF.
That distinction becomes critical during review. A dashboard or log line saying only “errno 9” or only “permission denied” discards the context needed to decide whether the current inode permission policy rejected a new access path or an existing descriptor was simply unsuitable for the attempted operation.
Close the original handle and inspect the duplicate
C9 exists because a closure claim is only as good as the handle inventory behind it.
At C1, the fixture creates:
rw = os.open(path, os.O_RDWR)
ro = os.open(path, os.O_RDONLY)
duplicate = os.dup(rw)The local inventory is therefore known: one independent read-only descriptor plus an original read/write descriptor and its deliberate duplicate.
Linux dup(2) states that dup() allocates a new descriptor referring to the same open file description as the original descriptor. The two descriptor numbers can be used interchangeably for the shared open file description, while descriptor-specific flags remain distinct.
C9 closes only rw:
os.close(rw)
rw = NoneIt then writes through duplicate:
written = os.pwrite(duplicate, b"DUPLIVE!\n", 0)The preparation asserts a nine-byte write and confirms through ro that offset zero now contains:
b"DUPLIVE!\n"So “the original fd was closed” is not equivalent to “the controlled holder has no remaining read/write reference.” Closing one descriptor removes that descriptor; the deliberate duplicate remains a usable reference in this run.
The explicit-offset design is useful here too. Although duplicate descriptors share their open file description and therefore share properties such as file offset and status flags documented by dup(2), C9 does not rely on a shared current offset because pwrite(..., 0) names the offset directly.
For adjacent descriptor-ownership background, see inherited descriptors and flock ownership. That article explores a different problem involving lock lifetime and process inheritance. This fixture intentionally avoids fork protocols and locks; the single same-process dup exists only to demonstrate that a local resource inventory must include duplicate references before closure can be accepted.
In production, do not assume the inventory is equally simple. If you cannot account for the holder model, the right decision is HOLD-CONTAINMENT, not a confident extrapolation from this three-descriptor laboratory.
Close the known references and bound the result
C10 is the strongest closure evidence in the fixture, and one of the easiest results to overstate.
After C9, the script closes ro, saves the current numeric value of duplicate, closes duplicate, and immediately attempts a pwrite using that just-closed number. No intervening operation intentionally allocates another descriptor. The asserted result is EBADF.
Linux close(2) documents that close() closes a descriptor so that it no longer refers to a file and the number may later be reused. When it was the last descriptor referring to an open file description, the resources associated with that open file description can be released.
In this single synchronous script with a known inventory, a bounded access-cessation procedure is therefore:
Stop the controlled application path from issuing another operation.
Allow the currently executing synchronous operation, if any, to reach its known completion boundary.
Inventory every descriptor reference the controlled holder is supposed to own, including deliberate duplicates.
Close each known descriptor in an explicit order.
Before descriptor-number reuse, record that an immediate operation through the just-closed tested number fails as expected.
If mode 000 is meant to remain the new-open barrier, separately retain the fresh-open negative controls.
Record application-owner acknowledgment that its handle lifecycle reached the intended closed state.
C10 provides evidence for step five in this controlled order. It does not discover descriptors owned by other processes, mappings, or unmodeled references. It does not prove that a numeric fd will remain invalid forever; close(2) expressly says the number may be reused.
It also does not justify a universal multithreaded “close it from somewhere else” recipe. Linux's close(2) documentation warns about another thread performing I/O while a descriptor is closed. On Linux, a blocked I/O operation can hold a reference to the underlying open file description and may complete even after another thread closes that descriptor. That is why unknown in-flight I/O or a different process model leads to HOLD-CONTAINMENT until the application owner supplies a suitable quiesce-and-close design.
Finally, descriptor closure is not a durability primitive. A successful close() does not itself guarantee that written data reached durable physical storage. Durability is outside this access-revocation fixture.
Reconcile the eleven cases with the documented contracts
The preparation reported PASS_11_CASES. In this article, PASS means only that every encoded assertion in the supplied fixture matched its preparation environment. It does not mean “all Linux systems behave identically,” and it does not mean production containment was executed.
The decisive comparison is not any single row. It is the sequence:
fresh open after chmod 000 → denied;
retained O_RDWR descriptor → read and write succeed here;
retained O_RDONLY descriptor → write fails;
close original O_RDWR while duplicate survives → duplicate still writes;
close the known remaining references → immediate old-number probe fails;
restore mode → fresh access returns, with the last written content still present.All three payloads are nine bytes including the newline. The ledger therefore presents them as byte strings so \n cannot be mistaken for two literal characters.
Case | Operation | Preparation observation | Review meaning |
C1 | At 0600, open O_RDWR, O_RDONLY, and dup(O_RDWR); read baseline | b"BASELINE\n", nine bytes | Positive baseline and known handle inventory |
C2 | chmod 000; compare stat and fstat | Mode zero, same device/inode | Metadata changed on the intended object |
C3 | Fresh O_RDONLY open | EACCES, errno 13 | New read open denied |
C4 | Fresh O_WRONLY open | EACCES, errno 13 | New write open denied |
C5 | Fresh O_RDWR open | EACCES, errno 13 | New combined open denied |
C6 | pread through retained O_RDWR | b"BASELINE\n" | Existing read path remains usable here |
C7 | pwrite through retained O_RDWR, then pread through O_RDONLY | Nine bytes written; b"CHANGED!\n" read back | Existing write path remains usable here |
C8 | pwrite through retained O_RDONLY | EBADF, errno 9 | Opening mode is a separate constraint |
C9 | Close original O_RDWR, then use duplicate | Nine bytes written; b"DUPLIVE!\n" read back | Closing one handle did not close its duplicate |
C10 | Close remaining handles; use just-closed number before reuse | EBADF, errno 9 | Tested handle is invalid in this controlled order |
C11 | Restore 0600; fresh read and identity comparison | b"DUPLIVE!\n"; original object and mode | Access restored; previous content not restored |
The documented contracts support interpreting the ledger without pretending that documentation executed the experiment. open(2) defines fresh open access modes and its open-file-description terminology; dup(2) defines the duplicate relationship; pread(2)/pwrite(2) define explicit-offset I/O and actual transfer counts; and close(2) defines descriptor closure and reuse caveats. The actual C1–C11 outcomes are preparation observations supplied with the runtime manifest.
A reader's rerun should preserve that separation. If C3 unexpectedly succeeds, do not change its expected result to success and still label the matrix equivalent. If C7 returns a short write, preserve the count. If C2 changes device/inode, the same-object experiment has failed. If the capability guard stops execution, the result is outside this preparation boundary. If Python assertions are disabled, the run no longer supplies the same fixture evidence.
The reviewed man-pages pages used here come from Linux man-pages 6.19; the open(2), chmod(2), and pread(2) pages identify the February 8, 2026 collection content and September 9, 2026 HTML rendering. That documentation version is another item to record rather than confuse with the Linux 6.18.55 preparation kernel.
Stop at the filesystem and security-policy boundary
Two primary-source cautions prevent this laboratory from becoming an unsupported universal rule.
First, chmod(2) explicitly documents different behavior for NFS: restricting permissions can affect already-open files because access control occurs on the server while open files are maintained by the client. That alone is sufficient reason not to export the overlayfs observation to remote filesystems.
Second, the Linux 6.11 security_file_permission documentation says that security modules can perform additional permission checking when operations read or write already-open files and can revalidate on use. Therefore the accurate conclusion is not “DAC is checked only on open” or “there are no post-open security checks.” The accurate conclusion is: in the recorded preparation, changing ordinary mode bits to 000 denied the tested fresh opens while the tested retained descriptors continued or failed according to the observed matrix.
The harness's local-filesystem allowlist limits where it will run; it does not prove identical behavior for every listed implementation, mount configuration, kernel, container boundary, or LSM policy. If an intended target differs, use RERUN-TARGET. Do not disable an LSM, alter a mount, or weaken enforcement to manufacture the preparation trace.
Restore permissions without inventing content recovery
C11 deliberately demonstrates why permission restoration and byte recovery need separate owners and separate approvals.
After the known descriptors are closed, the fixture changes the path back to 0600, performs a fresh O_RDONLY open, reads nine bytes at offset zero, and compares fstat identity with the original C1 record. The assertions require both the original object/mode tuple and the content:
b"DUPLIVE!\n"That is a clean metadata-recovery result. The permission state was restored, and a new read open became possible again under the preparation process context. chmod(2) supports the interpretation that the file's mode bits were changed; C11 supplies the observed fresh-access and identity result.
But note what is not restored: b"BASELINE\n" never comes back. Neither does the intermediate b"CHANGED!\n" state. The last retained-descriptor write remains the file content observed after permission restoration.
The fixture's finally block also calls path.chmod(0o600) before temporary cleanup, ensuring that its own owned fixture is not intentionally left at mode zero if an assertion or later operation fails. That is laboratory hygiene, not a generic recovery policy.
For a real integrity event, content recovery needs an independent decision. Before changing evidence-bearing content, preserve whatever artifacts the responsible investigation requires. Determine which writes actually occurred rather than assuming the laboratory's nine-byte overwrite pattern. Establish an approved source of truth, such as an application-authoritative copy or a separately governed recovery source, and have the data owner approve restoration. None of those production recovery steps are proven by C11.
Also keep restoration authorization explicit. RESTORE-METADATA applies only when returning the verified object to the prior permission state is an approved recovery action. It should not be used automatically merely because 0600 happened to be the preparation baseline.
Metadata restoration does not undo writes
The shortest correct statement is: chmod 000 to chmod 0600 changes metadata; it does not reverse C7 or C9.
C7 changed offset zero to b"CHANGED!\n". C9 changed it again to b"DUPLIVE!\n". C11 restored the mode and then observed b"DUPLIVE!\n". That is the exact separation the recovery process must preserve.
Nor can this experiment establish secure deletion, erase data already copied by another process, or prove crash durability. close(2) itself warns that successful descriptor closure does not guarantee that data has been physically saved to disk. Conversely, seeing the bytes in a subsequent pread() is sufficient for this fixture's logical-content assertion but is not a storage-durability test.
Permission restoration should therefore be logged as metadata restoration, while any content-integrity action receives its own evidence, authorization, source, and result.
Choose a decision that matches the evidence
A useful review does not end with “worked” or “failed.” It assigns one of a small number of decisions whose scope is visible.
Decision | Required evidence | Action | Accountable owner |
ACCEPT-DEMONSTRATION | All eleven cases and same-object assertions match under the captured local manifest; limitations remain explicit | Accept the demonstrated local distinction between denied fresh opens and retained-descriptor behavior | Technical reviewer, with security/service owner acknowledging that this is not production containment approval |
REPAIR-PROCEDURE | Proposed access cessation changes mode but a known holder still performs I/O through a retained descriptor | Application owner defines an acknowledged, scoped quiesce-and-close or authorized service-lifecycle procedure; operations verifies the denies and known holder state | Application owner for lifecycle; operations for evidence; security/service owner for acceptance |
RERUN-TARGET | Service identity, capabilities, filesystem, process model, runtime, or security enforcement differs materially from preparation | Repeat the relevant controls in the actual target context without weakening policy to force matching output | Operations and service owner, with security review where containment is claimed |
HOLD-CONTAINMENT | Unknown holders, mappings, in-flight operations, ambiguous identity, unexpected errors, changed object identity, or unexplained content effects remain | Keep the containment claim unapproved; assign each unresolved scope and obtain the missing evidence | Security/service owner coordinates; responsible application, operations, or data owner resolves assigned gaps |
RESTORE-METADATA | Authorized recovery calls for the prior permission state on the verified same object, and required content evidence has been preserved | Restore approved mode, document the effect on future access, and treat byte recovery separately | Authorized service/data owner approves; operations performs and records the metadata change |
For the supplied preparation, the qualified local result is ACCEPT-DEMONSTRATION: C1–C11 matched the fixture assertions under CPython 3.12.14, Linux 6.18.55, x86_64, glibc 2.39, overlayfs, EUID/EGID 0, and CapEff=0.
That acceptance says something precise: in this preparation, mode 000 denied all three tested fresh opens while the retained read/write descriptor still read and wrote; the retained read-only descriptor did not gain write capability; the duplicate stayed usable after closing the original; and closing the known local references invalidated the tested descriptor number before reuse.
It does not approve complete production containment.
If an operating procedure currently treats “chmod 000 returned success” as the end of access cessation while a controlled application can retain a usable descriptor, the appropriate decision is REPAIR-PROCEDURE. The repair is not “run chmod harder.” It is to make descriptor lifecycle part of the procedure: reach an application-owned no-new-I/O boundary, account for known references, close them under the application's process model, receive acknowledgment, and verify the holder state together with the intended fresh-open denial.
A different production account or security policy produces RERUN-TARGET, even if someone believes it is “close enough” to the fixture. Actual process credentials matter more than labels. The same applies when moving from the tested overlayfs environment to another filesystem.
Unknown scope moves the decision in the opposite direction. If another holder may exist, a mapping is in scope, I/O may already be running, the object identity changed, or the evidence cannot explain a result, choose HOLD-CONTAINMENT. C10 is not an inventory tool for an entire host.
Finally, metadata restoration never follows merely from demonstration acceptance. Use RESTORE-METADATA only when the responsible owner has authorized returning the verified object to the previous mode and the evidence needs around content have been handled first.
Assign closure, integrity and revalidation ownership
A handover should contain enough information that another reviewer can tell exactly what was and was not proved without reconstructing the conversation that produced it.
A compact record is:
Scope:
Path and regular-file assumption:
Runtime and kernel:
Actual EUID/EGID:
Effective capabilities:
Filesystem:
Security-policy context:
Baseline device/inode/mode:
Known handle inventory:
Mode-change timestamp/order:
Fresh-open results:
Retained pread/pwrite results and byte counts:
Duplicate result:
Closure order:
Closure acknowledgment:
Immediate post-close result:
Content delta:
Restored metadata, if authorized:
Known exclusions/unresolved holders:
Decision:
Application owner:
Operations owner:
Security/service approver:
Data/recovery owner:
Reviewer:
Revalidation triggers:The ownership split matters more than the template. The application team owns the resource lifecycle inside its process: which descriptors exist, how new I/O is prevented during cessation, and what acknowledgement means. Operations owns the object and environment ledger. Security or service ownership approves or rejects the containment statement. The data owner controls recovery of changed content.
For broader organizational context, Refonte's material on incident response ownership and recovery emphasizes defined roles, containment, recovery, communication, and documentation. This playbook uses only that ownership principle; it does not expand the file-descriptor exercise into a general incident-response procedure.
Revalidation is necessary whenever a premise supporting the decision changes. At minimum, rerun after a relevant change in service identity or capabilities, process model, filesystem or mount behavior, kernel/security-policy configuration, or descriptor-lifecycle implementation. A successful historical run is evidence about its captured state, not a perpetual waiver from retesting.
Preserve raw operation results rather than only the final decision label. A later reviewer should be able to distinguish “C3 returned EACCES,” “C8 returned EBADF,” and “C10 returned EBADF before reuse.” Those differences explain which access path was denied and why each case contributes different evidence.
Build stronger verification into Linux administration practice
The durable administration habit is straightforward: translate a security intention into observable operations, capture the environment in which those operations run, and keep metadata state, descriptor state, content state, and recovery authorization separate.
Refonte Learning's System Administration Program currently describes foundations including Windows and Linux administration, networking, security, troubleshooting, command-line work, virtualization, and backup/recovery, with practical labs/projects and a listed commitment of 10–12 hours per week. Its page separately lists basic computer/network knowledge and an admission requirement of working toward a bachelor's or higher-level degree. It describes a Potential Internship, while completion wording names a Training Certificate and Certificate of Internship; none of that establishes that this exact chmod/descriptor laboratory, Python pread/pwrite exercise, or a particular mentoring cadence is taught.
The page currently contains conflicting duration presentations, so no duration is asserted here. The operational verdict also stands independently of enrollment.
For this playbook, acceptance remains concrete: ACCEPT-DEMONSTRATION for the supplied bounded local result; REPAIR-PROCEDURE when chmod was mistaken for closure of known held access; RERUN-TARGET when the real identity, filesystem, process model, or security policy differs; HOLD-CONTAINMENT when unresolved holders or operations remain; and RESTORE-METADATA only as a separately authorized recovery step.
