# File access

---

# File access

_Peios / Peios Security Fundamentals / File access_

> FACS applies KACS access control to files. AccessCheck runs once at open; the granted mask is cached on the fd and gates every later operation.

**FACS** — the File Access Control Shim — is the kernel layer that applies KACS access control to files. Where the kernel's access pipeline runs against any protected object, FACS is the specific bridge between the file system (inode, dentry, page cache) and the access check. Every read, write, open, and metadata operation on a file goes through FACS at some level; FACS decides whether the operation proceeds and, when relevant, what flags or rights apply.

The model FACS uses is the **handle model**: AccessCheck runs once at `open` time, the granted mask is cached on the file descriptor that comes back, and every subsequent operation through that fd reads from the cache rather than re-running the access check. The implications of this single design choice ripple through every page in this topic.

This page covers the model at a conceptual level. Later pages cover the handle model in detail, the syscalls for opening files (native and legacy), the SD management operations, and the special cases that the model has to accommodate.

## The handle model in one sentence

**Access is decided at the moment a file descriptor is opened; from then on, the descriptor carries an immutable "granted access mask" that gates every operation.**

That sentence is the whole model. Once a file is open, the kernel knows what the caller is allowed to do with this file via this handle. Reads, writes, metadata queries, mmaps — each operation has a required access mask (what it needs to do its work) and succeeds only if the required mask is a subset of the cached granted mask.

The mask does not change. Adjustments to the file's DACL, to the calling token, or to anything else after the open do not affect the cached mask. The handle is a snapshot of what the access check said at open time.

The full mechanics — what gets cached, what operations consult the cache, what is and is not subject to the model — are in [The handle model](/peios/security-fundamentals/file-access/the-handle-model.md).

## Why the handle model

Two main reasons:

**Performance.** Re-running AccessCheck on every read or write would be expensive. The DACL walk plus narrowing layers plus possibly CAAP evaluation is non-trivial work. Caching the result on the handle reduces operations to a single bitmask comparison — orders of magnitude cheaper.

**Consistency.** A long-running operation should not change behaviour halfway through because the DACL was modified. A file being read for backup should not start failing midway because an administrator tightened the DACL; either the backup tool has the right at the start (in which case it should complete) or it doesn't (in which case it shouldn't have been able to open in the first place).

The trade-off is that an open handle survives a policy change. If you tighten a file's DACL, processes that already have the file open keep their existing access. New opens get the new policy; old handles are unchanged.

This is the **check-at-open** principle — covered for CAAP in [Central access policies](/peios/security-fundamentals/central-access-policies/overview.md) and applied uniformly here. The kernel does not retroactively revoke open handles.

## Where FACS sits

FACS lives between the file system and the rest of KACS. The arrangement:

```mermaid
flowchart LR
    A["Userspace syscall (open, read, write)"] --> B["FACS"]
    B -->|at open| C["AccessCheck"]
    B -->|cached mask on fd| D["File operation"]
    C --> D
    D -->|persisted| E["Filesystem (ext4, xfs, etc.)"]
```

At `open`, FACS calls AccessCheck and caches the result on the fd. At every subsequent operation, FACS reads the cached mask and decides. The filesystem itself doesn't see KACS; it sees only operations that FACS has already vetted.

This means:

- **The DACL and the filesystem are decoupled.** FACS is what enforces KACS on files; the filesystem stores the SD as metadata (an xattr, typically) but does not interpret it.
- **A filesystem can be FACS-managed or not.** Mount policy determines whether FACS applies to a given mount; see [Mount policies](/peios/using-peios/mount-policies/overview.md).
- **Different filesystems can store SDs differently.** ext4, XFS, NTFS — each has its own xattr or native mechanism for persisting the SD. FACS handles the abstraction; the access check works the same way regardless.

## Two open syscalls

The kernel exposes two ways to open a file:

| Syscall | Mode | Common use |
|---|---|---|
| `kacs_open` | KACS-native — caller specifies an explicit desired access mask | New code; programmatic file access by services |
| `openat` / `open` | Legacy — caller specifies POSIX flags (O_RDONLY, O_WRONLY, etc.) that map to access masks | Existing Linux applications |

`kacs_open` is the native interface. The caller specifies exactly which rights it wants and either gets all of them or fails. `openat` and friends are the POSIX-compatibility interface — they use the same FACS machinery internally but with a different mapping from input flags to access mask and with split "core" and "compat" semantics for handling partial grants.

Both paths converge in FACS. The cached mask on the resulting fd is the same shape regardless of which syscall produced the open.

The two interfaces are covered in detail in [Opening files](/peios/security-fundamentals/file-access/opening-files.md).

## What FACS reads from where

A summary of the data sources that feed into a FACS access check at open:

| Source | What it provides |
|---|---|
| Calling thread's effective token | Identity for AccessCheck — user SID, groups, integrity, privileges, etc. |
| File's inode | The owner SID, primary group, DACL, SACL (read via xattr or filesystem-native channel) |
| Mount policy | Whether FACS applies at all, how missing SDs are handled, the mount-level SD template |
| Process PSB | PIP fields for the dominance check during AccessCheck |
| Caller's `kacs_open` parameters | The desired access mask, the create disposition, optionally a creator-supplied SD |

These come together at `open`. AccessCheck runs over them and produces the granted mask that goes on the fd.

## What FACS does not do

A few clarifications:

- **FACS does not store SDs.** The filesystem stores SDs; FACS reads them. Different filesystems use different mechanisms (xattrs on ext4 / XFS / Btrfs, native security streams on NTFS, mount-level synthesis on FAT/exFAT, in-memory on tmpfs).
- **FACS does not validate SDs at boot.** The first access to a file is when its SD is first read and validated; the kernel does not pre-scan filesystems looking for problems.
- **FACS does not propagate DACL changes.** Modifying a file's DACL affects future opens, not existing handles. This is the check-at-open rule.
- **FACS does not gate operations that bypass the file system.** A process that has a file mapped into memory via mmap can read or write that memory without going through FACS for each access — the access check happened at mmap time (or at open, then at mmap), and after that the kernel has no efficient way to intercept memory accesses. Mitigations like LSV apply at the mmap call; runtime memory access is governed by the page-table permissions the kernel set when the mapping was created.

## Where to start

If you want the handle model in detail — what is cached, what operations check the cache, when the cache can be stale, how fd transfer works — read [The handle model](/peios/security-fundamentals/file-access/the-handle-model.md).

If you want the open syscalls — `kacs_open` for KACS-native, `openat` for legacy compatibility, the differences in semantics and the rules for each — read [Opening files](/peios/security-fundamentals/file-access/opening-files.md).

If you want to read or modify a file's SD — `kacs_get_sd`, `kacs_set_sd`, the security_information bitmask, the rules for setting owner/DACL/SACL/label — read [Managing file security](/peios/security-fundamentals/file-access/managing-file-security.md).

If you want the edge cases — O_PATH, the exec dual gate, append-only files, sticky bit, POSIX ACLs that no longer work, NFS dual authority — read [Special cases](/peios/security-fundamentals/file-access/special-cases.md).

---

# The handle model

_Peios / Peios Security Fundamentals / File access_

> Every FACS-managed fd carries a granted access mask, stamped at open and immutable. What the cache contains, which operations consult it, and fd transfer.

The handle model is the rule that an open file descriptor carries a fixed snapshot of access permissions, taken at the moment of open. Every operation through the fd consults this snapshot — not the file's current SD, not the calling token's current state, not anything else. The snapshot is the granted access mask, an immutable 32-bit value attached to the fd.

This page covers what the snapshot contains, which operations consult it, why immutability is the right choice, and the implications for handle transfer between processes.

## What is on the fd

When `open` succeeds, the kernel attaches several things to the resulting fd. The KACS-relevant pieces:

| Field | Set at | Mutable after open? |
|---|---|---|
| Granted access mask | AccessCheck at open | **No** |
| Continuous audit mask | AccessCheck at open (if alarm ACEs matched) | No |
| FACS-managed flag | Open path | No |
| (Filesystem state: position, mode, etc.) | open | Mutable per operation |

The granted access mask is the central object. It is whatever AccessCheck decided the caller's effective token was entitled to on this file at this moment. The bits set are the rights the fd holder has; the bits not set are rights they do not have.

The continuous audit mask is the union of all alarm ACEs that matched at open time. Every subsequent operation through the fd will check against this mask and emit a continuous-audit event if the operation's required mask overlaps it. See [The SACL](/peios/security-fundamentals/security-descriptors/the-sacl.md) and [Audit ACEs](/peios/security-fundamentals/auditing/audit-aces.md).

The FACS-managed flag distinguishes fds that go through the handle model from those that don't. O_PATH fds, for example, are not FACS-managed (covered in [Special cases](/peios/security-fundamentals/file-access/special-cases.md)).

## What operations consult the cache

Almost every operation on a FACS-managed fd checks the cached mask. The rule: the operation has a *required access mask* (what it needs the caller to have been granted at open) and succeeds only if `required & ~granted == 0` — every required bit is in the granted mask.

| Operation | Required access |
|---|---|
| `read` | `FILE_READ_DATA` |
| `pread`, `readv`, `process_vm_readv` (when reading from the fd as target) | Same |
| `write` | `FILE_WRITE_DATA` |
| `pwrite`, `writev` | Same |
| Append-only write (RWF_APPEND, O_APPEND) | `FILE_APPEND_DATA` (without requiring `FILE_WRITE_DATA`) |
| `ftruncate` | `FILE_WRITE_DATA` |
| `fchmod` | `FILE_WRITE_ATTRIBUTES` |
| `fchown` | `WRITE_OWNER` (updates only the inert Linux uid/gid; see Special cases) |
| `mmap(PROT_READ)` | `FILE_READ_DATA` |
| `mmap(PROT_WRITE)` | `FILE_WRITE_DATA` |
| `mmap(PROT_EXEC)` | `FILE_EXECUTE` |
| `fstat` | `FILE_READ_ATTRIBUTES` |
| `fgetxattr` | `FILE_READ_EA` (for ordinary xattrs; the security namespace is unconditionally denied) |
| `fsetxattr` | `FILE_WRITE_EA` (same exception) |
| `fdatasync`, `fsync` | None — these are control operations, not data operations |
| `flock`, `fcntl(F_SETLK)` | None |

Each operation knows what it requires; the kernel compares against the cached mask and decides.

A subtle case: **the operation's required mask comes from the operation's semantics, not from POSIX flags**. A `read()` on a fd opened O_RDONLY needs `FILE_READ_DATA` because read needs FILE_READ_DATA, not because the fd was opened with O_RDONLY. The two coincide because `open(O_RDONLY)` requested and got FILE_READ_DATA, but the per-operation check is on what the operation needs, not on what the open requested.

## Immutability and why

The cached granted mask cannot change after open. The kernel does not provide a syscall to refresh it. Modifying the file's DACL does not propagate to existing fds. Adjusting the calling token's privileges does not retroactively re-grant rights on previously-opened fds.

The reasoning is two-fold:

**Atomic policy.** A long-running operation should not change behaviour partway through. A backup tool that opens a file at time T should be able to complete the read at time T+1 even if the DACL was modified between. Either the right to read was granted at open (in which case the read should succeed) or it wasn't (in which case the open should have failed).

**Performance.** Caching is the whole point. If the kernel re-evaluated AccessCheck on every read, the handle model would not give any of the performance benefit. The cache is what makes the model work.

The implication: a DACL update is observable only to new opens. Existing handles continue with the rights they had. To force a policy change to take effect, the operator must either close existing handles (or kill the processes holding them) or wait for the handles to be released naturally.

## fd transfer preserves the mask

A file descriptor can be transferred between processes — via `dup`, fork, SCM_RIGHTS, exec, pidfd_getfd. In every case, the transfer preserves the granted access mask exactly.

A fd passed via SCM_RIGHTS to another process gives that process the same access the originating process had through the fd. The recipient's token is not re-checked at transfer; the granted mask is what counts.

This is intentional. Capabilities are passable. A process that opened a file with broad rights can hand the fd to a helper process; the helper inherits the rights even if the helper's own token would not have granted them.

The model is: *opening a file produces a capability (the fd + the cached mask), and capabilities are transferable*. The transfer is the way the model expects rights to be delegated.

The corollary: a process should be careful about which fds it shares. Passing a fd to a less-trusted helper grants that helper the rights the fd carries. There is no narrowing-at-transfer mechanism.

A more restrictive variant — handing off only some of the access rights — requires opening a fresh fd with the narrower set. The original fd holder, who presumably has the broader set, can open the file again with a narrower mask and pass that fd.

## fork and exec

`fork` copies the file table; all fds are inherited by the child along with their cached masks. The child has the same rights on the same files as the parent at the moment of fork.

`exec` (with no special flags) preserves the file table; fds carry over to the new program with their cached masks intact.

`exec` with `FD_CLOEXEC` set on a fd causes that fd to be closed at exec; the child program does not inherit it. This is the standard "close on exec" mechanism, used widely. A handle that should not survive exec gets `FD_CLOEXEC` either at open (via `O_CLOEXEC`) or later (via `fcntl(F_SETFD, FD_CLOEXEC)`).

Within the same process, threads share the file table; a fd opened in one thread is usable from any thread in the same process.

The cached mask follows the fd everywhere the fd goes.

## When the cache can be wrong

The cached mask is a snapshot at open time. There are a small number of cases where the cache may be wrong relative to the world at large:

- **The DACL was changed after open.** The cache reflects the DACL at open; the on-disk DACL may now grant different rights. New opens would get the new rights; this fd still has the old. Not a bug — the model is check-at-open.
- **The calling token's state changed.** The token's privileges may have been adjusted, the integrity level may have changed (via NEW_PROCESS_MIN at exec, say). The cached mask reflects the token at open. Operations through this fd use the cached value, not the current token state.
- **The file was deleted and replaced.** An `unlink` of the path the fd was opened against does not affect the fd; the inode remains alive as long as the fd is open. The cache still reflects what was true of the now-unlinked inode. A new file created at the same path is a different inode; the cache is unaffected.
- **Mount policy changed.** Changing the mount policy on a superblock does not retroactively affect the cached masks of fds open against files on that superblock. The cache is the snapshot; mount policy is the input to future opens.

In each case, the model's answer is the same: the cache is the truth for this fd's purposes. If you need a fresh view, close and reopen.

## Operations not subject to the cache

A handful of operations bypass the cache or use a different check:

- **`execveat(AT_EMPTY_PATH)`** is the one exception in v0.20. Exec re-evaluates AccessCheck rather than using the cache. The reasoning is that exec changes the calling process's identity and PSB in ways that the cached open-time decision did not account for; running a fresh check makes the exec decision honest. This is the only v0.20 use-time access check.
- **O_PATH fds** are not FACS-managed and have no cached mask. Operations against them either work unconditionally (`fstat`) or use a fresh AccessCheck (operations that need real access).
- **Mapping a file's data into kernel space directly** (e.g. via `splice` between two fds) involves both fds' cached masks; neither is bypassed.

The exception list is short. For the overwhelming majority of operations, the cached mask is the gate.

## Implications for processes

Knowing the handle model has practical implications for code that handles files:

> [!WARNING]
> **Don't expect tightening to revoke open handles.** A security incident response that revokes a permission needs to also close fds. The kernel does not.

**Open with exactly the rights you need.** A fd opened with broader rights than needed is a broader capability than needed. If something might pass the fd elsewhere, the something gets the broader rights too. Minimum-rights opens are good hygiene.

**Pass fds carefully.** A fd shared via SCM_RIGHTS is a capability transfer. Audit who you pass to and what they could do with it.

**Use FD_CLOEXEC for fds you don't want exec to inherit.** This is also a defence-in-depth measure against accidental capability leakage to child processes.

**Refreshing access requires reopening.** A program that wants to see DACL changes needs to close and reopen, not poll for changes.

## Where to go next

For the two syscalls that produce these fds and stamp the granted mask, read [Opening files](/peios/security-fundamentals/file-access/opening-files.md).

For reading and writing the SD behind a handle, read [Managing file security](/peios/security-fundamentals/file-access/managing-file-security.md).

For the edges of the model — O_PATH, the exec dual gate, append-only handles — read [Special cases](/peios/security-fundamentals/file-access/special-cases.md).

---

# Opening files

_Peios / Peios Security Fundamentals / File access_

> kacs_open with an explicit desired mask versus legacy openat with POSIX flags — strict-mode grants, create dispositions, MAXIMUM_ALLOWED, and SD-at-creation.

There are two ways to open a file in Peios. **`kacs_open`** is the native interface — the caller specifies an explicit desired access mask, and the kernel either grants all of it or fails the open. **`openat`** (and the older `open`) is the legacy POSIX interface — POSIX flags map to access masks, and the kernel uses split "core" and "compat" semantics so that rights requested but not granted can sometimes be silently dropped without failing the open.

This page covers both syscalls, the create-disposition options, the `MAXIMUM_ALLOWED` mode, and the rules around the caller supplying an SD for newly-created files.

## kacs_open — KACS-native

`kacs_open` is the canonical Peios open syscall:

```
fd = kacs_open(dirfd, path, &how, sizeof(how), &status)
```

Where `how` is a `kacs_open_how` struct:

| Field | Meaning |
|---|---|
| `desired_access` | The 32-bit mask of rights the caller wants. May include MAXIMUM_ALLOWED. |
| `create_disposition` | One of SUPERSEDE / OPEN / CREATE / OPEN_IF / OVERWRITE / OVERWRITE_IF. |
| `create_options` | Modifier flags — DIRECTORY, DELETE_ON_CLOSE. |
| `flags` | Additional flags — AT_EMPTY_PATH, AT_SYMLINK_NOFOLLOW. |
| `sd_ptr`, `sd_len` | Optional creator-supplied SD for new files. |

The kernel:

1. Resolves the path.
2. Decides whether the file exists / should exist based on `create_disposition`.
3. Runs AccessCheck with the calling token, the file's SD (or computes one for a newly-created file), and the `desired_access` mask.
4. **Strict-mode semantics**: every bit in `desired_access` must be granted. If any requested right is not granted, the open fails with `-EACCES`. There is no "partial open" — either every requested bit is in the granted mask or the operation fails entirely.
5. On success, returns the fd with the granted mask cached. If `status` was non-null, writes one of OPENED / CREATED / OVERWRITTEN / SUPERSEDED to it indicating what happened.

The strict-mode semantics are the right behaviour for native code. A caller that asks for read-and-write either gets both or fails; ambiguity is removed. The caller knows exactly what rights it has on the resulting fd because the mask is exactly what was requested.

The `MAXIMUM_ALLOWED` flag (covered below) is the exception — when set, the kernel relaxes strict mode and returns whatever rights the caller could have gotten.

## kacs_open_how — fields in detail

### desired_access

The 32-bit access mask. Standard format: object-specific bits 0–15, standard bits 16–20, special bits 24–25, generic bits 28–31. The kernel expands generic bits (GENERIC_READ, GENERIC_WRITE, etc.) using the file GenericMapping at evaluation time.

Special rights:
- `MAXIMUM_ALLOWED` (0x02000000) — request the maximum grantable mask. See below.
- `ACCESS_SYSTEM_SECURITY` (0x01000000) — the right to read or modify the SACL. Gated by SeSecurityPrivilege.

A `desired_access` of zero is rejected — the kernel does not grant a fd with no rights.

### create_disposition

Defines what to do depending on whether the file exists (numeric constant values: [Other constants](/peios/using-peios/constants-and-catalogs/other-constants.md)):

| Value | If file exists | If file does not exist |
|---|---|---|
| `SUPERSEDE` | Delete and recreate. | Create. |
| `OPEN` | Open. | Fail with ENOENT. |
| `CREATE` | Fail with EEXIST. | Create. |
| `OPEN_IF` | Open. | Create. |
| `OVERWRITE` | Truncate to zero and open. | Fail with ENOENT. |
| `OVERWRITE_IF` | Truncate to zero and open. | Create. |

`OPEN_IF` is the most common ("get me access to this file, creating if necessary"). `SUPERSEDE` removes the inode and creates a new one — useful for atomic-replace patterns.

### create_options

Modifier flags:

| Flag | Meaning |
|---|---|
| `DIRECTORY` | The target must be (or will be created as) a directory. If the disposition would create and this flag is set, a directory is created. If the disposition is open-only and the target is not a directory, fails with ENOTDIR. |
| `DELETE_ON_CLOSE` | The file should be deleted when the last fd referencing it is closed. Useful for temporary files. |

### flags

Path-resolution flags:

| Flag | Meaning |
|---|---|
| `AT_EMPTY_PATH` | The path is empty; operate on the directory referenced by `dirfd`. |
| `AT_SYMLINK_NOFOLLOW` | Do not follow a terminal symlink. If the path resolves to a symlink, fails with ELOOP. |

### sd_ptr / sd_len

Optionally, the caller can supply a security descriptor for a newly-created file. The SD must be in self-relative format and within the standard size limit (65,535 bytes).

The rules for the creator-supplied SD:
- Permitted on a disposition that creates (CREATE, OPEN_IF when the file does not exist, OVERWRITE_IF when it does not exist, SUPERSEDE when it does not exist).
- **Rejected on open-existing branches** (OPEN, OPEN_IF when the file exists, OVERWRITE when the file exists). Supplying an SD on an existing-file path is an error (-EINVAL).
- If not supplied for a creation, the kernel synthesises an SD from the parent's inheritable ACEs and the creator's token defaults (see [Inheritance](/peios/security-fundamentals/security-descriptors/inheritance.md)).
- The caller must be entitled to set the owner SID — the same rule as `kacs_set_sd` (own SID or a SE_GROUP_OWNER group, or SeRestorePrivilege to override).

### status

Optional output indicating what happened:

| Value | Meaning |
|---|---|
| `OPENED` (1) | An existing file was opened. |
| `CREATED` (2) | A new file was created. |
| `OVERWRITTEN` (3) | An existing file was truncated and opened. |
| `SUPERSEDED` (4) | An existing file was deleted and a new file was created. |

This is useful for OPEN_IF / OVERWRITE_IF dispositions where the caller wants to know what happened. For non-conditional dispositions, the answer is predictable from the disposition itself.

## MAXIMUM_ALLOWED — relax strict mode

When `MAXIMUM_ALLOWED` (0x02000000) is set in `desired_access`, the kernel changes mode:

1. The `MAXIMUM_ALLOWED` flag is stripped from the desired mask.
2. AccessCheck runs in maximum-allowed mode (see [DACL evaluation](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md)).
3. The full DACL is walked, accumulating every right the caller could have been granted.
4. The result is returned as the cached granted mask. **It is not compared to the desired mask for strict-mode purposes.**

Using `MAXIMUM_ALLOWED`, the open succeeds with whatever rights the caller could have gotten, including possibly fewer than what they "asked for". The other bits in `desired_access` are treated as a *hint* about what the caller would want — the kernel still evaluates them — but the call does not fail if the caller's actual rights are narrower.

The flag exists for tools that want to do "open with whatever access I can get" rather than "open with exactly these rights". A backup tool, for example, might want to open every file it encounters with whatever access is available rather than refusing the file because it cannot get write.

`MAXIMUM_ALLOWED` must be combined with at least one concrete data or execute bit. Calling with `MAXIMUM_ALLOWED` alone is rejected with `-EINVAL` — the kernel needs to know that *some* operation is intended, even if the specific rights are flexible.

## openat (legacy) — POSIX-compatibility

The legacy `openat` (and the older `open`) takes POSIX flags and maps them to access masks. The mapping is:

| POSIX flag | Maps to |
|---|---|
| `O_RDONLY` | `FILE_READ_DATA \| FILE_READ_ATTRIBUTES \| FILE_READ_EA \| READ_CONTROL \| SYNCHRONIZE` (the read category from GENERIC_READ) |
| `O_WRONLY` | `FILE_WRITE_DATA \| FILE_WRITE_ATTRIBUTES \| FILE_WRITE_EA \| READ_CONTROL \| SYNCHRONIZE` |
| `O_RDWR` | `O_RDONLY` mapping ∪ `O_WRONLY` mapping |
| `O_APPEND` | Adds `FILE_APPEND_DATA` |
| `O_TRUNC` | Requires `FILE_WRITE_DATA` |
| `O_CREAT` | Sets the disposition to OPEN_IF (with the FILE_ADD_FILE right on the parent) |
| `O_EXCL` | With O_CREAT, switches the disposition to CREATE (fail if exists) |
| `O_PATH` | Special — see Special cases. The fd is not FACS-managed. |
| `O_NOFOLLOW` | Sets AT_SYMLINK_NOFOLLOW. |

So `openat(O_RDONLY)` maps to "open with FILE_READ_DATA + the rest of the read category". The kernel then runs AccessCheck for that mask.

### Core vs compat rights

The wrinkle: legacy openat uses a **core vs compat** split. The mapped rights are partitioned into:

- **Core** rights — the rights that the operation fundamentally needs. If any core right is denied, the open fails.
- **Compat** rights — rights that are conventionally granted with this POSIX flag but are not strictly necessary. If any compat right is denied, it is silently dropped from the cached mask — the open still succeeds, just with a narrower grant.

For O_RDONLY, the core right is `FILE_READ_DATA` (you cannot read without it). The compat rights are `FILE_READ_ATTRIBUTES`, `FILE_READ_EA`, `READ_CONTROL`, `SYNCHRONIZE` — these are convenient to have, but a read-only open is still meaningful without them.

The result is that legacy `open(O_RDONLY)` succeeds when FILE_READ_DATA is granted, even if the other read-category rights are not. The fd ends up with the granted subset cached.

This is the difference from `kacs_open`. `kacs_open(FILE_READ_DATA | FILE_READ_ATTRIBUTES)` requires *both* and fails if either is denied; `open(O_RDONLY)` requires FILE_READ_DATA and silently drops FILE_READ_ATTRIBUTES if it is denied.

The reasoning: POSIX applications expect open to succeed with reasonable defaults. Failing because a niche right was not granted would break compatibility. The split lets the application succeed with the rights it really needs and treat the others as bonuses.

### Why kacs_open exists

If legacy `openat` works fine for POSIX applications, why have `kacs_open`?

Two reasons:

**Precision.** New code that knows what rights it needs should be able to request them explicitly. `kacs_open` lets a service say "I need exactly these rights" and either get them or be told they are not granted. There is no silent narrowing.

**Direct SD provision.** `kacs_open` lets the caller pass an explicit SD for a newly-created file. Legacy `openat` does not — POSIX `open(O_CREAT)` uses umask-based defaults plus the parent's inheritable ACEs, with no path for the caller to specify a custom SD.

For most application code, legacy `openat` is the right tool. For services that care about exact-rights or that need explicit-SD-at-creation, `kacs_open` is the answer.

## Errors

Both syscalls can fail with:

| Error | Cause |
|---|---|
| `-EACCES` | Strict-mode access check failed (kacs_open), or core rights denied (legacy). Or a related path component is unreachable. |
| `-EEXIST` | The disposition was CREATE and the file already exists. |
| `-ENOENT` | The disposition was OPEN or OVERWRITE and the file does not exist. |
| `-ENOTDIR` | KACS_CREATE_OPT_DIRECTORY was set but the target is not a directory; or a path component is not a directory. |
| `-ELOOP` | AT_SYMLINK_NOFOLLOW was set and the path resolves to a symlink. |
| `-EINVAL` | Various — MAXIMUM_ALLOWED with no concrete bits, an invalid create_disposition, an SD on an open-existing branch, etc. |
| `-EBADF` | The dirfd is invalid. |

The error code tells you what went wrong. The kernel does not give cryptic codes; each maps to a specific named failure mode.

## See also

- [The handle model](/peios/security-fundamentals/file-access/the-handle-model.md) — what the cached mask these opens produce actually does.
- [Managing file security](/peios/security-fundamentals/file-access/managing-file-security.md) — reading and writing the SD behind a handle.
- [DACL evaluation](/peios/security-fundamentals/security-descriptors/dacl-evaluation.md) — the maximum-allowed walk MAXIMUM_ALLOWED relies on.

---

# Managing file security

_Peios / Peios Security Fundamentals / File access_

> Reading and writing file SDs with kacs_get_sd and kacs_set_sd — the security_information bitmask, per-component access rules, and the LABEL flag.

Reading and writing a file's security descriptor is done through two dedicated syscalls — **`kacs_get_sd`** and **`kacs_set_sd`** — not through the xattr layer. Direct xattr access to the SD storage (the `security.peios.sd` or `system.ntfs_security` xattr) is unconditionally denied; the only way through is the syscall pair.

This page covers the syscalls, the `security_information` bitmask that decides which SD parts are accessed, the access rules, and the special `LABEL_SECURITY_INFORMATION` flag for setting the integrity label specifically.

## Why the xattr layer is denied

You might expect that the SD, stored in an xattr, would be readable and writable through the standard `getxattr` / `setxattr` syscalls. It is not. The kernel refuses every xattr operation on the SD xattr regardless of who is asking and what they hold.

The reasoning:

- **Atomic semantics.** Reading or writing the SD via xattr would expose the raw bytes; tools could read a partial SD (during a write by someone else), or write an SD whose internal structure is inconsistent with the file's other state.
- **Access rule unification.** The SD has its own access rules — `READ_CONTROL` to read, `WRITE_DAC` / `WRITE_OWNER` / `ACCESS_SYSTEM_SECURITY` to write different parts. Routing through `kacs_get_sd` / `kacs_set_sd` puts these rules in one place; routing through xattr would require duplicating them at the xattr layer.
- **Format flexibility.** Different filesystems store the SD differently. The syscall abstraction lets the kernel translate; the xattr layer would force a specific format.

So the syscall pair is the only path. Reading or writing the SD goes through them; xattr operations on the SD xattr are denied.

## kacs_get_sd

The read syscall:

```
size = kacs_get_sd(dirfd, path, security_info, buf, buf_len, flags)
```

Returns the SD bytes for the requested components.

| Parameter | Meaning |
|---|---|
| `dirfd`, `path` | The file to read. Standard dirfd-relative resolution. |
| `security_info` | A bitmask saying which components to return. See below. |
| `buf`, `buf_len` | Output buffer. |
| `flags` | AT_EMPTY_PATH (use dirfd as fd-relative), AT_SYMLINK_NOFOLLOW (don't follow terminal symlink). |

The kernel:

1. Resolves the path.
2. Runs AccessCheck — the caller needs the access rights corresponding to the requested components (covered below).
3. Reads the file's SD from its filesystem-native storage.
4. Constructs a **subset SD** containing only the requested components. Other components have offset 0 in the header and the corresponding PRESENT bit is clear.
5. Returns the subset SD in `buf`, with the total length as the return value.

### Probe mode

Calling with `buf_len = 0` (or `buf` = NULL) is a **probe** — the kernel computes the size the SD would take and returns it without writing to the buffer. The probe returns the same size value that a non-probe call would; the caller can use this size to allocate exactly the right buffer.

The probe call always returns the size on success. It does not return `-ERANGE` — the probe is itself a question about size, and answering it is the kernel's job. `-ERANGE` would be the wrong signal for a deliberate probe.

A non-probe call with `buf_len < required` returns `-ERANGE` with the required size written somewhere accessible (typically the same length parameter, or via a separate output). The caller can then reallocate and retry.

### Access rules

Different components need different rights:

| Requested component (via `security_info` flag) | Required right |
|---|---|
| `OWNER_SECURITY_INFORMATION` | `READ_CONTROL` |
| `GROUP_SECURITY_INFORMATION` | `READ_CONTROL` |
| `DACL_SECURITY_INFORMATION` | `READ_CONTROL` |
| `SACL_SECURITY_INFORMATION` | `ACCESS_SYSTEM_SECURITY` |
| `LABEL_SECURITY_INFORMATION` | `READ_CONTROL` (the integrity label is in the SACL but its read is gated by READ_CONTROL, not ACCESS_SYSTEM_SECURITY) |

(The numeric flag values are catalogued in [Other constants](/peios/using-peios/constants-and-catalogs/other-constants.md).)

`READ_CONTROL` is the standard "read SD" right and is implicitly granted to the owner. `ACCESS_SYSTEM_SECURITY` is gated by `SeSecurityPrivilege` — the SACL is read-restricted to administrators with the privilege.

A caller asking for components they do not have rights for gets `-EACCES`. A caller asking for a mix can succeed for the components they can access — but the kernel does this as an all-or-nothing operation: if any requested component fails its access check, the whole call fails.

For combining requested components: just OR the flags. `kacs_get_sd(..., OWNER | DACL, ...)` returns owner and DACL but not SACL or group.

### SACL and LABEL are mutually exclusive

`SACL_SECURITY_INFORMATION` and `LABEL_SECURITY_INFORMATION` cannot be combined in one call. Setting both flags returns `-EINVAL`. The reasoning: `LABEL_SECURITY_INFORMATION` is a focused query for just the integrity label (which lives in the SACL); it has different access requirements than reading the full SACL. The kernel keeps the two paths separate.

## kacs_set_sd

The write syscall:

```
result = kacs_set_sd(dirfd, path, security_info, sd_buf, sd_len, flags)
```

| Parameter | Meaning |
|---|---|
| `dirfd`, `path` | Target file. |
| `security_info` | Which components to update. |
| `sd_buf`, `sd_len` | The new SD bytes (self-relative format). |
| `flags` | AT_EMPTY_PATH, AT_SYMLINK_NOFOLLOW. |

The kernel:

1. Resolves the path.
2. Parses the SD blob. Rejects malformed SDs (size limit, bad ACL structure, etc.) with `-EINVAL`.
3. Runs AccessCheck for the required rights per the components being updated.
4. Validates additional rules — owner SID is the caller's own or a SE_GROUP_OWNER group (unless SeRestorePrivilege), MANDATORY-flagged resource attributes are not removed (unless SeTcbPrivilege), integrity label is not raised above caller's own (unless SeRelabelPrivilege).
5. Writes the SD to the file's native storage.
6. Returns 0 on success.

The write is atomic: either all requested components are updated or none are.

### Access rules for writes

| Component | Required right |
|---|---|
| Owner | `WRITE_OWNER` (plus the owner SID validation) |
| Group | `WRITE_OWNER` |
| DACL | `WRITE_DAC` |
| SACL | `ACCESS_SYSTEM_SECURITY` |
| LABEL (integrity label only) | `WRITE_OWNER` (plus integrity constraint) |

`WRITE_DAC` is the standard "modify DACL" right, implicitly granted to the owner. `WRITE_OWNER` is needed to change the owner field (and the validation rules apply per [Ownership](/peios/security-fundamentals/security-descriptors/ownership.md)). `ACCESS_SYSTEM_SECURITY` is the SACL gate.

### The integrity label

`LABEL_SECURITY_INFORMATION` (0x10) is the focused write path for setting just the integrity label. The label lives in the SACL as a `SYSTEM_MANDATORY_LABEL_ACE`, but setting it via this flag is treated as a separate operation from setting the full SACL — with different access requirements:

- The right needed is `WRITE_OWNER`, not `ACCESS_SYSTEM_SECURITY`.
- The caller cannot raise the integrity label above the calling token's own integrity level (without `SeRelabelPrivilege`).
- Lowering the integrity label to at or below the caller's own integrity level is allowed.

`LABEL_SECURITY_INFORMATION` and `SACL_SECURITY_INFORMATION` cannot be combined in one call — same rule as for reading.

The use case: a process that wants to lower its files' integrity labels without holding `SeSecurityPrivilege`. The label is in the SACL conceptually, but setting it gets the `WRITE_OWNER` gate rather than the SACL gate, because adjusting the label down is a less sensitive operation than rewriting the audit policy.

### SD parsing and validation

The provided SD blob must be:

- In self-relative format (`SE_SELF_RELATIVE` flag set in control bits).
- Within the 65,535-byte size limit.
- Internally consistent — the offsets in the header point to valid locations, the ACLs parse cleanly, the SIDs are well-formed.

Any failure of validation returns `-EINVAL`. The original SD on the file is unchanged.

### Setting only some components

The `security_information` flags tell the kernel which components of the provided SD to apply. A blob containing owner + DACL with only `DACL_SECURITY_INFORMATION` set updates only the DACL; the file's existing owner is preserved.

The blob structure must still be valid — components not being applied are typically absent (offset 0, PRESENT bit clear) in the blob, but the blob's header still needs to be a valid SD header.

This is the pattern for updating one part of an SD without touching the others. Read the SD (probe + fetch), update the relevant component, write back with only that component's flag set.

### Ownership transfer rules

Setting the owner via `kacs_set_sd` triggers the rules described in [Ownership](/peios/security-fundamentals/security-descriptors/ownership.md):

- The new owner must be the caller's own user_sid, **or** a SID in the caller's groups with `SE_GROUP_OWNER` set, **or** the caller must hold `SeRestorePrivilege`.
- The caller must have `WRITE_OWNER` on the object, or hold `SeTakeOwnershipPrivilege`.

A failure here returns `-EACCES`.

The same rules apply whether you set ownership via `OWNER_SECURITY_INFORMATION` alone or in combination with other components.

## What about file mode (the POSIX rwx bits)?

The traditional POSIX file mode — `chmod`-style read/write/execute bits — does not exist in the same way under FACS. The Linux mode bits are stored alongside the SD as ordinary inode metadata, but they are **not consulted by FACS for access control** (except the execute bit as an exec prerequisite). The DACL is what decides access; the mode is informational only.

The mode-changing syscalls still work, gated on SD rights:

- `fchmod()` succeeds only if the fd's granted mask includes `WRITE_DAC` (legacy opens request it as a compat right); otherwise `-EACCES` (`-EBADF` on O_PATH fds).
- `chmod()` runs a fresh access check requiring `WRITE_DAC` on the file's SD.
- In both cases the mode bits are updated but the SD is untouched. To change what actually decides access, call `kacs_set_sd` with a new DACL.

The mode is inert metadata; the SD is the truth.

## Errors

Common errors from both syscalls:

| Error | Cause |
|---|---|
| `-EACCES` | Access check failed for the requested components. |
| `-EINVAL` | Malformed SD blob, invalid security_information combination, size limit exceeded, or other validation failure. |
| `-EPERM` | Owner SID validation failed without SeRestorePrivilege, or integrity label too high without SeRelabelPrivilege, or attempted MANDATORY attribute removal without SeTcbPrivilege. |
| `-ERANGE` | (`kacs_get_sd` only) Buffer too small; required size returned. |
| `-ENOENT` | The target path does not exist. |
| `-ELOOP` | AT_SYMLINK_NOFOLLOW and the path is a symlink. |

Most failures are diagnostic and clear from the error code.

## See also

- [Ownership](/peios/security-fundamentals/security-descriptors/ownership.md) — the owner-transfer rules kacs_set_sd enforces.
- [The sd command](/peios/security-fundamentals/security-descriptors/sd-command.md) — the shell wrapper for these syscalls.
- [Opening files](/peios/security-fundamentals/file-access/opening-files.md) — supplying an SD at file creation instead.

---

# Special cases

_Peios / Peios Security Fundamentals / File access_

> The edges of FACS — O_PATH, the exec dual gate, append-only handles, the inert sticky bit, replaced POSIX ACLs, NFS dual authority, and whiteouts.

The handle model handles most of file access cleanly. But files have edges — operations that bypass the model, semantics that don't fit, filesystems whose behaviour FACS cannot fully control. This page covers those edges.

Each of the special cases below is something that someone debugging FACS behaviour will eventually hit. Knowing the rules ahead of time keeps the gotchas from being surprises.

## O_PATH — fds without a cached mask

`O_PATH` is a Linux flag for opening a file in a "path-only" mode. The fd that comes back can be used to refer to the file (for `openat(dirfd, ..., 0, fd)`, for `fstatat`, for path manipulation) but cannot be used for actual data operations.

Under FACS, an O_PATH open does not run a full AccessCheck. The fd is not FACS-managed; it has no cached granted mask. Operations that would normally consult the cache either work unconditionally (because they don't need any access) or use a fresh live AccessCheck (because they do):

| Operation on O_PATH fd | Behaviour |
|---|---|
| `fstat`, `fstatat` | Works without access check. The fd is a path reference; getting stat info is allowed. |
| `openat` using the fd as dirfd | Normal — runs AccessCheck for the new open. The dirfd's O_PATH status doesn't affect the new fd. |
| `kacs_get_sd` with AT_EMPTY_PATH | Runs a live AccessCheck (the fd has no cached mask to consult). |
| `kacs_set_sd` with AT_EMPTY_PATH | Same — live AccessCheck. |
| `read`, `write`, `mmap` | **Denied** with `EBADF`. The fd cannot be used for data operations. |
| `fchmod`, `fchown`, `fgetxattr`, `fsetxattr`, `ioctl` | **Denied** with `EBADF`. |

O_PATH is useful for path manipulation — keeping a reference to a directory while traversing the tree, or referring to a file by fd rather than by path. For these uses, the lack of a cached mask is irrelevant.

The catch: a process that wants to do *anything* with an O_PATH fd beyond path navigation needs to reopen it. The reopen is a fresh access check that decides what the resulting fd can actually do.

## The exec dual gate

Exec is the only operation in v0.20 that performs a **use-time** access check rather than relying on the cached mask. The reasoning: exec changes the process's identity (via the new binary's PIP) and effectively replaces it; the open-time decision is no longer the right one to use.

Specifically, `execveat(AT_EMPTY_PATH)` on an open fd:

1. Runs a fresh AccessCheck against the file's current DACL using the caller's current effective token.
2. Requires both the **`+x` mode bit** on the file and **`FILE_EXECUTE`** on the caller's access to be present.

The two checks answer two different questions:

- **`+x` on the file** means *this file is intended to be run as a program*. It is a property of the file itself, set by whoever owns it — the file's author saying "this is an executable, not data". A file without `+x` is data; trying to exec it fails regardless of who is asking, because exec on data is meaningless.
- **`FILE_EXECUTE` on the access** means *this caller is allowed to execute this program*. It is an access decision against the DACL — the question of whether this principal, on this token, is authorised to run this binary.

Both have to be true for exec to proceed. A data file (no `+x`) is not runnable by anyone; an executable file the caller is not authorised for is runnable in principle but not by this caller.

The dual gate is the v0.20 exception to the handle model. The reason for live evaluation here is that exec's consequences are large enough — the binary that runs is what the kernel will then trust at the verified PIP level — that running on a stale cache is the wrong default.

`mmap(PROT_EXEC)` is **different** from exec. It uses the cached mask (`FILE_EXECUTE` from the open), and additionally runs the LSV mitigation if enabled. mmap is not the same as exec; the dual-gate-with-live-check applies only to exec specifically.

## Append-only handles

A handle opened with `FILE_APPEND_DATA` granted but **not** `FILE_WRITE_DATA` is an append-only handle. The kernel enforces this strictly:

- `write()` at the current offset is allowed only if the current offset is at end-of-file. Otherwise denied.
- `pwrite()` at any offset other than end-of-file is denied (RWF_APPEND-style).
- Mapping the file shared writable (`mmap(MAP_SHARED, PROT_WRITE)`) is denied.
- `ftruncate` to extend the file is denied (cannot rewind via truncate).
- `fallocate` modes that would mutate (FALLOC_FL_PUNCH_HOLE, FALLOC_FL_COLLAPSE_RANGE, FALLOC_FL_ZERO_RANGE) are denied.

Operations that legitimately only append — POSIX `write` with O_APPEND, `RWF_APPEND` writes — work normally. The kernel makes sure the data lands at end-of-file.

The use case: log files that should be append-only. A logger holds a handle with `FILE_APPEND_DATA` but not `FILE_WRITE_DATA`; it can write log lines but cannot rewrite earlier ones. Even if the logger is compromised, the existing log entries are safe from modification through this handle.

Append-only is the FACS expression of the "secure log" pattern. It exists at the right-mask level, not as a separate file attribute.

## sticky bit — no effect under FACS

The traditional Unix sticky bit on a directory restricts file deletion within: only the owner of a file (or the owner of the directory) may unlink files in a sticky-bit directory. This is what makes `/tmp` work.

Under FACS, **the sticky bit has no effect**. Deletion is gated by `FILE_DELETE_CHILD` on the parent directory's SD. The sticky bit's semantics are not encoded.

The reason: FACS's access model is comprehensive enough that the sticky bit is unnecessary. A `/tmp` directory's DACL grants the directory's `FILE_DELETE_CHILD` to the appropriate principals (typically just the owner or the file owner, mediated through OWNER RIGHTS); this is the same effect the sticky bit had, expressed in the DACL.

For `/tmp` specifically, the Peios-default DACL provides equivalent semantics: each user can create files in `/tmp` (FILE_ADD_FILE granted to Authenticated Users), each file's owner can delete their own (FILE_DELETE_CHILD inherited per-file from OWNER RIGHTS ACEs), but a user cannot delete another user's files.

A directory whose mode includes the sticky bit (visible to `ls -l` as a `t`) is informational; the mode is derived from the SD by FACS. The bit can be set but has no enforcement consequence.

## POSIX ACLs — replaced by KACS

The Linux POSIX ACLs (set via `setfacl` and stored as `system.posix_acl_access` and `system.posix_acl_default` xattrs) are **not honoured by FACS**. They are replaced by KACS DACLs.

Specifically:

- Writes to `system.posix_acl_access` and `system.posix_acl_default` xattrs are unconditionally denied.
- Existing POSIX ACL xattrs on files (carried over from a pre-Peios system, say) are ignored. FACS uses the KACS DACL exclusively.

The kernel will not silently translate POSIX ACLs to KACS DACLs. Migrating from a Linux system with POSIX ACLs requires re-writing the SDs in KACS form. Tools that can do this conversion exist in the migration tooling; the kernel does not do it on the fly.

## fchown — gated on WRITE_OWNER

The legacy `fchown` syscall changes a file's owner UID/GID. Under FACS, the family is permitted but gated on SD rights:

- `fchown()` succeeds only if the fd's granted mask includes `WRITE_OWNER`; otherwise `-EACCES`.
- `chown()` and `lchown()` run a fresh access check requiring `WRITE_OWNER` on the file's SD.

The Linux uid/gid change does not alter the SD's owner SID. Changing the *real* owner goes through `kacs_set_sd` with `OWNER_SECURITY_INFORMATION` — the KACS semantics for ownership (covered in [Managing file security](/peios/security-fundamentals/file-access/managing-file-security.md)) replace the legacy mode-and-owner duo.

The kernel surfaces the file's owner SID's projected UID as the result of `stat`-style queries, so `ls -l` shows a UID. But the UID is derived from the owner SID; setting it via `fchown` is not the way.

## NFS — dual authority

NFS client mounts are a special case. The underlying filesystem is on a remote server; the server enforces its own access control independent of what the client thinks. FACS on the client side cannot reach into the server's authorisation; it can only express its local view.

The pattern Peios uses: NFS client mounts are configured with the `synthesize_ephemeral` [mount policy](/peios/using-peios/mount-policies/overview.md). The client synthesises a local SD for each file accessed (per the mount template), runs AccessCheck locally, and either authorises the operation or denies it. If authorised, the operation is forwarded to the server, which may then deny it for its own reasons.

The consequence: a locally-authorised open can still produce I/O errors when the server refuses. A caller may successfully `open(O_RDONLY)` against an NFS file (FACS's local synthesis decided to grant) but the `read()` returns `-EACCES` from the server.

This is "dual authority": the client and the server both have a say, and both have to permit. The client's denial is final on the client side; the server's denial is final on the server side. There is no single source of truth for what is allowed.

> [!WARNING]
> **Don't trust FACS results on NFS for security purposes.** The local FACS decision is "the client will let this go forward"; the server may still refuse. If you need a security guarantee, the server's access control is what matters.

Other implications:

- **Expect I/O errors from server-side denial.** A successful open does not guarantee a successful read. Handle `EACCES` (or `EIO`) from the operation, not just from the open.
- **The synthesised SD is local.** Changes to the file's actual SD on the server are not visible to FACS's local synthesis. Tools that want to inspect the real SD need to query the server.

NFS server mounts (where Peios is the server) work the other direction: FACS enforces the SD on the local files; the protocol exposes it. The remote client's view of what is allowed is whatever the protocol negotiates, which may be limited by NFS-protocol-level restrictions but is ultimately decided by FACS.

## `/proc` and `/sys`

`/proc` and `/sys` are kernel-managed pseudo-filesystems. They are not FACS-managed in the standard sense — their mount policy is **unmanaged**, which means the FACS handle model does not apply.

What does happen:

- File operations under `/proc/<pid>/*` route through the process's PSB / process SD checks (the two-check rule from PIP). Access is decided per-operation against the target process.
- `/sys/kernel/security/kacs/*` files have explicit SDs on them (set up by the kernel) and are accessed via the kernel's own check logic.
- General `/proc` files (`/proc/uptime`, `/proc/cpuinfo`) are world-readable by convention.
- `/sys` writes are restricted to `BUILTIN\Administrators` and `SYSTEM` by hardcoded rule.

The result: operations on `/proc` and `/sys` work, but they don't go through the FACS handle model. The cached mask on the fd from opening one of these files is meaningless; the per-operation check is what gates access.

This is why `cat /proc/self/status` produces output without any apparent FACS involvement — the access is granted by the kernel's `/proc`-specific rules, and the kernel just makes data available.

The mount-policy classes are covered in [Mount policies](/peios/using-peios/mount-policies/overview.md). `unmanaged` is the special class for kernel-managed filesystems; it cannot be set via the public ABI.

## Whiteouts in renameat2

`renameat2(RENAME_WHITEOUT)` — the Linux-specific flag for creating a "whiteout" entry as part of a rename (used by overlay filesystems) — is **supported** on FACS-managed filesystems. The rename and the whiteout it leaves behind happen as one atomic operation, exactly as on stock Linux.

A whiteout is a `chrdev(0,0)` sentinel that overlay filesystems drop at the old name to mask a file in a lower layer. FACS authorises it the same way it authorises any new node: you need FILE_ADD_FILE on the source directory (where the whiteout lands), in addition to the usual rename rights. If you lack that right the whole rename is denied before anything is created, and the whiteout — like every freshly created node — inherits a security descriptor from its parent directory and is recorded in the audit trail.

This matters if you run overlayfs or union-style filesystems on FACS-managed storage: they work without the partial-support caveats earlier previews carried.

## Summary table

The special cases at a glance:

| Case | What is different |
|---|---|
| O_PATH | No cached mask; data operations denied; live check for SD ops |
| exec via execveat | Live AccessCheck plus Linux `+x` mode bit |
| Append-only handles | Write-at-end only; mmap-shared-writable and truncate denied |
| Sticky bit | No effect — the DACL is the gate |
| POSIX ACLs | Replaced by KACS; xattr writes denied |
| fchown / fchmod | Gated on WRITE_OWNER / WRITE_DAC; the mode bits are inert — the SD is the truth |
| NFS client | Dual authority; local FACS plus remote enforcement |
| `/proc` and `/sys` | Unmanaged mount policy; per-operation check by kernel-specific rules |
| renameat2 RENAME_WHITEOUT | Supported; atomic, needs FILE_ADD_FILE on the source directory, whiteout inherits an SD |

Each is a deliberate decision. Some reflect Linux compatibility (POSIX ACLs replaced); some reflect security policy (exec dual gate, append-only enforcement); some reflect the model's limits (NFS dual authority). Knowing them ahead of time keeps them from being surprises.

## Where to go next

For the model these cases are edges of, read [The handle model](/peios/security-fundamentals/file-access/the-handle-model.md).

For how a mount's policy decides whether FACS applies at all, read [Mount policies](/peios/using-peios/mount-policies/overview.md).

For the SD read/write syscalls referenced throughout, read [Managing file security](/peios/security-fundamentals/file-access/managing-file-security.md).
