Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
Peios

Peios Kernel

The Peios kernel as it is built: the KMES event subsystem, the KACS access control system, the stratafs layered filesystem, and the LCS registry.

Single-page view · as markdown

1.1 Overview

Peios / Advanced Peios / PKM / Introduction

The Peios kernel is a Linux kernel with Peios compiled into it. Peios contributes three things to the tree it is built from: a patch series of around fifty patches against existing kernel files, a new security/pkm subtree, and a new fs/stratafs subtree. None of it is loadable. CONFIG_SECURITY_PKM and CONFIG_STRATAFS_FS are both boolean options, so what they build is linked into vmlinux; there is no module to insert and none to unload.

security/pkm builds as PKM, the Peios Kernel Module — the name predates the decision to build in, and the subsystem still carries it. PKM registers as a Linux Security Module, provides the syscalls in the PKM range, and holds three of the four subsystems described here. The fourth, stratafs, is an ordinary in-tree filesystem that sits beside PKM rather than inside it, and reaches PKM through a kernel-private header that exports no symbols to modules.

The four subsystems are peers.

KMES, the Kernel Mediated Event Subsystem, is the sole event emission path in Peios. Kernel subsystems and userspace processes alike emit events exclusively through KMES; it stamps each event with trusted metadata, buffers it in per-CPU ring buffers, and delivers it to userspace consumers through shared memory.

KACS, the Kernel Access Control System, is the security core: SIDs, tokens, security descriptors, privileges, impersonation, process protection, binary signature verification, and the AccessCheck algorithm that ties them together. KACS also projects Peios identities onto Linux credentials so that unmodified Linux subsystems make decisions consistent with Peios policy.

stratafs is the layered filesystem. It composes an ordered stack of strata — ordinary directories, independently owned — into a single mounted tree, resolving each name in the highest-precedence stratum that holds it, routing each modification to the stratum that will accept it, and copying an object up into the designated create stratum when its own stratum will not. It stores nothing of its own, and delegates every access decision to KACS.

LCS, the Layered Configuration Subsystem, is the kernel half of the Peios registry. It owns the data model — hives, keys, values, and the precedence-ordered layers the name refers to — along with access control, watches, transactions, and the syscall and ioctl surface through which processes read and write configuration. It holds no storage of its own, delegating that to userspace sources over the Registry Source Interface.

This manual describes each subsystem in its own chapter, in the order above. They are peers, but not independent: KMES stamps events with identities it obtains from KACS, KACS emits its audit trail through KMES, LCS enforces access with KACS security descriptors, and stratafs consults KACS for every delegation decision. Cross-references between chapters mark these seams.

1.1.1 What this manual is #

This is a technical reference manual: an exhaustive description of the kernel as it is actually built. It documents observed behaviour — formats, algorithms, limits, failure modes — in plain indicative prose. It is not a standard, and it makes no conformance demands.

The contracts the kernel shares with other parties are specified elsewhere, in the Peios Core Specification Anthology: the binary structures that cross subsystem boundaries (GUIDs, SIDs, security descriptors) in PCDS, and the protocols the kernel speaks with userspace services and the formats they exchange — the registry source interface, the registry backup format, the event stream consumer protocol — in PSPK. Where a chapter touches one of those contracts, it references the specification rather than restating it, and describes only what this kernel adds: how the contract is implemented, and the behaviour on this side of the boundary.

Constants, ABI tables, and catalogues are collected in appendices at the end of the chapter they belong to, so that a chapter's reference material sits beside the prose that explains it.

2.1 Overview

Peios / Advanced Peios / PKM / KMES

The Kernel Mediated Event Subsystem is the sole event emission path in Peios. Kernel subsystems and userspace processes alike emit events exclusively through KMES — there is no alternative path. KMES stamps each event with trusted metadata at emission time, buffers it in per-CPU shared memory ring buffers, and delivers it to userspace consumers that map those buffers directly. It does not persist, index, or query events, and it imposes no schema or naming convention on them; those are consumer concerns, handled by eventd.

KMES serves a similar role to ETW in the Windows kernel and auditd in Linux, but is compatible with neither at the wire, format, or API level. The design — structured events with a fixed binary header and a msgpack payload, shared memory delivery, one emission path for kernel and userspace — was chosen for unified observability with kernel-trusted metadata.

The consumer-facing contract — the event header layout, the mapped ring buffer regions, and the protocols a consumer follows to drain, sleep, and survive buffer swaps — is specified in PSPK's KMES event stream chapter. This chapter describes the kernel side: how events are constructed and stamped (§2.2), the in-kernel emission API (§2.3), the syscall surface (§2.4), how the ring buffers are organised and written (§2.5), self-configuration through the registry (§2.6), and behaviour under failure (§2.7).

2.1.1 Terminology #

An event is an indivisible record: a packed binary header carrying KMES-intrinsic metadata, followed by a msgpack-encoded payload whose structure is defined by the emitter. Header and payload are produced, stored, and consumed together as one contiguous byte sequence; neither is meaningful alone. KMES treats the payload as opaque.

The stamp fields are the header fields KMES populates itself at emission time: the timestamp, sequence number, CPU identifier, and origin class, plus three identity GUIDs captured from KACS — the effective token GUID (the token governing the emitting thread's access rights, which is the impersonation token when the thread is impersonating), the true token GUID (the process's primary token, regardless of impersonation), and the process GUID (assigned by KACS at fork and unchanged across exec). The null GUID — sixteen zero bytes — stamps an identity field whose value is unavailable.

The sequence number is a per-CPU, per-boot monotonic 64-bit counter. Each CPU counts independently; the counter starts at zero when PKM loads and is incremented before its value is taken, so the first event on each CPU carries sequence number 1 and sequence 0 is never assigned. A gap in one CPU's sequence indicates lost events. The pair (cpu_id, sequence) uniquely identifies an event within a boot; there is no global sequence.

The origin class is a header byte identifying the emission path: userspace (0), KMES itself (1), KACS (2), or LCS (3). Values 4–255 are unassigned.

The event type is a length-prefixed UTF-8 string in the header identifying the kind of event. KMES imposes no structure on it and compares nothing against it; types are consumer vocabulary.

A ring buffer is a per-CPU shared memory region — producer metadata page, consumer metadata page, and a data region — created and managed by KMES and mapped by consumers. A consumer is a userspace process that maps one or more ring buffers and drains events from them, typically with one thread per CPU. Boot-time ring buffers are the ordinary per-CPU buffers created at module load using compiled-in defaults; they are the live consumer-facing buffers from the first instant, not a separate class.

2.2 Event Model

Peios / Advanced Peios / PKM / KMES

2.2.1 Structure #

An event is a packed binary header followed immediately by its msgpack payload, written and delivered as a single contiguous byte sequence with no padding or alignment gaps anywhere. The header is never represented as a C struct in the kernel; it is serialised field by field, in order, directly into the ring buffer. The field-by-field layout — offsets, sizes, and endianness — is part of the consumer contract and is defined in the PSPK event stream specification. In summary: all fields before the event type string sit at fixed offsets, the event type string begins at offset 77 with its u16 length at offset 75, the header is exactly 77 + type_len bytes, the payload occupies the bytes from header_size to event_size, and the next event begins at offset event_size from the start of the current one. All multi-byte header integers are little-endian; the identity GUIDs are copied as opaque 16-byte values.

event_size, header_size, and type_len are structural fields computed by KMES during construction. The emitter supplies only the event type string and the payload, and KMES copies both verbatim.

2.2.2 Intrinsic stamps #

KMES populates four intrinsic stamp fields at emission time, unconditionally; the emitter cannot supply them.

  • timestamp — wall clock time (CLOCK_REALTIME, via ktime_get_real_ns()) at the moment KMES accepts the event, in nanoseconds since the Unix epoch.
  • sequence — the emitting CPU's per-boot counter, incremented and then read, so the first event on each CPU gets 1.
  • cpu_id — the CPU on which the ring buffer write occurs, which identifies the per-CPU buffer holding the event.
  • origin_class — for syscall emission, set unconditionally to 0 (userspace); the caller cannot influence it. For kernel emission, the value the calling subsystem passed, written to the header without validation — kernel emitters are trusted to pass an assigned value.

The timestamp is captured before the sequence number is assigned, so two events with the same timestamp on the same CPU are ordered by sequence.

2.2.3 Identity stamps #

The three identity GUIDs are captured by calling KACS accessors during the preemption-disabled ring buffer write phase, after the sequence number is taken and before the header is built. All three accessors are safe with preemption disabled — each is a handful of pointer dereferences and a 16-byte copy, with no allocation and no sleeping. The stamps therefore reflect the thread's identity at the moment of the ring buffer write, not at syscall entry.

  • kacs_effective_token_guid() reads the thread's subjective credentials (current_cred()), so during impersonation — which installs the impersonation token via override_creds() — it yields the impersonation token's GUID. Otherwise it equals the true token GUID.
  • kacs_primary_token_guid() reads the real credentials (current_real_cred()), which impersonation leaves untouched, so it always yields the process's primary token GUID.
  • kacs_process_guid() reads the process GUID KACS assigned when the process's security state was created at fork. Threads created with CLONE_THREAD share the process state, and no exec path writes the field, so the GUID is stable for the process's lifetime.

All three accessors return the null GUID when there is no task context (!current or not in_task()), and the token accessors also return it when the token cannot be resolved. A fully null identity triple therefore indicates emission before KACS initialisation, from a kernel thread, or from interrupt or softirq context. No event is ever stamped with the identity of a task that merely happened to be interrupted.

For batch emission, the timestamp and the three identity GUIDs are captured once, before the per-event loop, and shared by every event in the batch: the batch executes with preemption disabled on one CPU, so the emitting thread's identity cannot change mid-batch. Each event still receives its own sequence number.

2.2.4 Ordering #

Cross-CPU ordering is by timestamp. Events with identical timestamps from different CPUs were genuinely concurrent and have no defined relative order. Within one CPU, sequence is the reliable ordering primitive, monotonic even across wall-clock discontinuities; events with identical timestamps on the same CPU are ordered by sequence.

2.2.5 Payload #

The payload is a single msgpack value. KMES neither interprets nor modifies it — buffering and delivery are content-blind — and it performs no payload validation at all for kernel emitters, which are trusted callers inside PKM.

Payloads arriving through the syscall interface are validated before acceptance by an iterative (non-recursive) msgpack walker implemented in Rust, operating on the kernel's staged copy of the payload:

  • The payload is exactly one well-formed top-level msgpack value. Trailing bytes after it are rejected, as is the never-used 0xc1 type byte. A zero-length payload is rejected — an empty byte sequence is not a msgpack value — so syscall events always carry a payload. (Kernel emitters can emit header-only events with event_size == header_size.)
  • Nesting depth is bounded by the configured MaxNestingDepth (§2.6). Depth counts from 1 at the top-level value; each child of an array or map sits one deeper than its container. A non-empty container at the maximum depth is invalid, because its children would exceed the limit; an empty array or map at the maximum depth is valid, consuming no depth. Map keys and values each occupy a child slot, so a map contributes twice its entry count of children.
  • The walker's own stack is 256 frames, matching the upper bound of the MaxNestingDepth range; a configured depth outside 1–256 causes every payload to be rejected rather than any to be waved through.
  • Length prefixes inside msgpack are big-endian, per the MessagePack specification — the one big-endian ingredient in an otherwise little-endian event.

A rejected payload fails the syscall with EINVAL and nothing is written to the ring buffer.

The event type string is validated as UTF-8 on the syscall path by the same staged-copy pass; kernel emitters' type strings are trusted and copied as given. Types are compared by consumers as raw bytes — no case folding or normalisation is applied anywhere.

2.2.6 Size limits #

Three structural bounds apply to every event, and one configurable policy bound applies to syscall emitters only:

  • The event type length fits the header's u16 type_len field and is nonzero. Syscall emitters cannot express an overlong type — the ABI length field is already u16 — but the kernel emission API takes a size_t and enforces the bound itself.
  • The total event size (header + payload) fits a u32, checked with overflow-safe arithmetic on the declared lengths.
  • The total event size does not exceed 50% of the per-CPU ring buffer capacity — a fixed ratio, not configurable, protecting a CPU's event history from a single giant event. An event of exactly half the capacity is accepted. Capacity is always a power of two, so the halving is exact.
  • Syscall events are additionally bounded by the registry-configurable MaxEventSize (§2.6), rejected with ENOSPC when exceeded. Kernel emitters are exempt from this policy limit.

2.3 Emission API

Peios / Advanced Peios / PKM / KMES

The emission API is the internal kernel interface through which PKM subsystems emit events — an ordinary function call inside the module, not a syscall. Userspace emission goes through the syscall interface (§2.4). There are two entry points: single emission (pkm_kmes_emit_kernel) and batch emission (pkm_kmes_emit_kernel_batch). Both return nothing: kernel emission is fire-and-forget, and the emitting subsystem is never notified of a drop.

2.3.1 Single emission #

A kernel emitter passes an origin class, an event type (pointer and length), and a payload (pointer and length). It does not choose a CPU or buffer: KMES writes the event to the ring buffer of the CPU the calling code is executing on.

The entire emission path runs with preemption disabled — from before the current CPU is determined until after the ring buffer write — which guarantees the emitting thread cannot migrate mid-write and preserves the single-writer-per-buffer invariant. For kernel emitters this covers the full path, timestamp capture through ring write; the payloads are small and trusted, and the non-preemptible window is a few hundred nanoseconds.

Construction proceeds in order: capture the wall clock timestamp; increment the CPU's sequence counter and take the new value; capture the three identity GUIDs from KACS; build the packed header; write header and payload contiguously into the ring. The ordering consequences of these steps are described in §2.2.

KMES trusts kernel emitters. It does not validate the origin class, the type string's encoding, or the payload — only the structural checks below run. A null event-type pointer is not checked on the single-emission path; passing one faults.

2.3.2 Structural checks and drops #

Every kernel emission is checked for: nonzero event type length; type length within u16; total event size within u32 (overflow-checked); and total event size within 50% of the per-CPU ring capacity.

A failing event is not written — but its sequence number has already been consumed, so the drop is visible to consumers as a gap in that CPU's sequence, and an internal per-CPU dropped-event counter is incremented. That counter also aggregates events discarded by overwrite when the buffer wraps; it is not exposed in the ring buffer metadata and is readable only by the KUnit test harness.

Two further situations discard kernel events entirely outside this accounting:

  • Before KMES initialisation completes, emission is a silent no-op: no sequence number is consumed, no counter is incremented, and no gap is visible. Events emitted in this window are simply gone.
  • If the ring's recorded CPU identity does not match the executing CPU, single emission treats it as a structural drop (sequence consumed, counter incremented), while both batch paths return early without consuming anything.

2.3.3 Ring buffer full #

Each per-CPU buffer is circular. When it is full, KMES overwrites the oldest events to make room; the write position advances unconditionally, and emission never blocks and never fails from buffer pressure. Consumers detect the overwritten events as sequence gaps, and a consumer whose read position has been overtaken re-anchors to the oldest surviving event, as specified in the PSPK event stream chapter. The overwrite mechanics are described with the write protocol in §2.5.

2.3.4 Batch emission #

The batch API emits multiple events in one operation: an origin class applied to the whole batch, an array of event descriptors (type pointer and length, payload pointer and length each), and a count. There is no upper bound on the kernel batch count — unlike the syscall batch, which caps at 256 entries — so the non-preemptible window is bounded only by the caller's restraint.

The batch executes as one preemption-disabled section:

  1. One wall clock timestamp is captured; every event in the batch shares it.
  2. The three identity GUIDs are captured once and shared.
  3. Each event, in order: structural checks, sequence assignment, header build, ring write. The batch structural check is slightly stronger than the single-emission one — it also rejects a null type pointer, a null payload pointer with a nonzero length, and a header size beyond u32. A failing event is dropped exactly as in single emission (sequence consumed, gap visible, counter incremented) and the batch continues with the next event — kernel emitters are trusted, and an individual structural failure indicates a kernel bug rather than hostile input. This differs deliberately from the syscall batch, which stops at the first failure so the untrusted caller learns which entry was bad.
  4. After the loop, provided at least one event was actually written, the new tail position and then the new write position are published with release stores — one publication for the whole batch — and the consumer wake flag is checked once, incrementing the futex counter if a consumer is asleep. A batch in which every event failed publishes nothing and performs no wake check.
  5. Preemption is re-enabled, and only then is the futex wake syscall work performed, outside the non-preemptible window.

Deferring publication gives batch atomicity: consumers observe either none of the batch or all of it, since the data is fully written before the single write_pos release store. During the batch, the overwrite check runs against an internal running write offset — the consumer- visible write_pos stays untouched until the end. The tail position is likewise kept local and published only at the end.

2.3.5 Write atomicity #

Individual event writes are atomic from the consumer's perspective: an event's bytes are fully written into the data region before the write_pos release store makes them reachable, so a consumer bounded by write_pos can never observe a partially written event. The memory-ordering contract this rests on is part of the PSPK event stream specification; the kernel-side implementation of the write protocol is described in §2.5.

2.4 Syscall Interface

Peios / Advanced Peios / PKM / KMES

KMES exposes three syscalls in the PKM syscall range (1090–1099): kmes_emit (1090) emits a single event from userspace, kmes_attach (1091) attaches the caller as a consumer of one per-CPU ring buffer, and kmes_emit_batch (1092) emits multiple events in one operation. All three follow the standard Linux convention — they return −1 and set errno on failure — and their numbers, entry struct layout, error tables, and privilege masks are collected in §2.A.

Before KMES initialisation completes, all three syscalls fail with ENOMEM. Before KACS initialisation, the emit syscalls fail closed with EPERM, since privilege checks cannot be performed.

2.4.1 kmes_emit #

Emits one event. The origin class is set to 0 (userspace) unconditionally — the caller cannot choose it — and the event is written to the ring buffer of the CPU the calling thread is executing on at write time.

2.4.1.1 Privilege gate #

The caller's effective token has to hold SeAuditPrivilege, enabled; otherwise the syscall fails with EPERM. A successful gate records SeAuditPrivilege as used on the token, as a KACS standalone privilege gate; a failed gate records nothing. If recording the used state itself fails, the syscall also fails with EPERM.

2.4.1.2 Rate limiting #

Callers without enabled SeTcbPrivilege are rate limited per process by a token bucket: the refill rate and the burst capacity both equal the configured MaxEmitRatePerProcess (§2.6). The bucket is allocated together with the process's KACS security state at fork, initialised to full capacity, and freed when the process's security state is released at exit. Refill is computed against the monotonic clock, so wall-clock jumps do not affect it. When MaxEmitRatePerProcess changes at runtime, the new rate and capacity take effect immediately — rates are read live on every operation — and each bucket's current token count is clamped down to the new capacity.

A token is reserved up front and refunded if the syscall subsequently fails, so validation failures cost nothing; the refund is clamped to capacity, which means a refund landing just after a rate decrease can forfeit the token. An empty bucket fails the reserve with EAGAIN, consuming nothing. Callers holding enabled SeTcbPrivilege bypass the bucket entirely, and the exemption records SeTcbPrivilege as used.

Rate state is per-process rather than per-SID: per-SID limiting would penalise unrelated services sharing a SID (LocalService, for instance). A process that forks to reset its limit is bounded by RLIMIT_NPROC.

2.4.1.3 Validation #

Validation runs in order and stops at the first failure; the errno reflects the first failing check.

  1. The privilege gate and rate reservation, above.
  2. event_type_len is nonzero — EINVAL otherwise.
  3. The declared total event size (77 + type_len + payload_len) is computed from the length fields alone, without dereferencing either userspace pointer, with overflow-checked arithmetic — overflow is EINVAL.
  4. The declared size is within MaxEventSize — ENOSPC otherwise.
  5. The declared size is within 50% of the ring capacity — ENOSPC otherwise. At this stage the check runs against the first live ring's capacity; it is repeated against the actual target CPU's ring inside the write phase, so a capacity swap racing the syscall can surface ENOSPC after all other validation has passed.
  6. The event type and payload are copied into a kernel staging buffer (EFAULT if a pointer is inaccessible, ENOMEM if allocation fails). Everything after this point — validation and the ring write — operates on the kernel copy, closing the TOCTOU window in which userspace could rewrite the payload after validation.
  7. The event type is validated as UTF-8 — EINVAL otherwise.
  8. The payload is validated as msgpack within MaxNestingDepth (§2.2) — EINVAL otherwise.

2.4.1.4 Preemption #

Validation runs with preemption enabled — the userspace copies can fault, and msgpack validation of a large payload takes microseconds. Preemption is disabled only around the ring buffer write: determining the CPU, stamping, writing, publishing write_pos, and checking need_wake. The cpu_id and identity GUIDs therefore reflect the thread's state at write time, not at syscall entry. On success the syscall returns 0 and the event is immediately visible to consumers.

2.4.2 kmes_emit_batch #

Emits up to 256 events in one call, sharing the privilege check, the timestamp, the identity capture, and the single write_pos publication across the batch. The 256-entry cap bounds the preemption-disabled write window to roughly 50–100 microseconds for typical event sizes.

The caller passes an array of 32-byte entry descriptors (layout in §2.A; the descriptor padding bytes are documented as reserved-must-be-zero in the ABI header but are not validated), a count, and an emitted_out pointer.

Processing order:

  1. The SeAuditPrivilege gate, as for kmes_emit.
  2. count is within 1–256 — EINVAL otherwise.
  3. count tokens are reserved from the rate bucket in one critical section, so concurrent threads cannot both pass the check — EAGAIN if unavailable, and nothing is emitted. SeTcbPrivilege exempts as before.
  4. Zero is stored to *emitted_out before any per-entry work — EFAULT if unwritable, with nothing emitted.
  5. The descriptor array is copied from userspace (EFAULT/ENOMEM).
  6. Each entry, in order, goes through the same staging pipeline as kmes_emit — declared-size arithmetic, MaxEventSize, 50% capacity, userspace copy, UTF-8, msgpack. Staging stops at the first failing entry.
  7. The validated prefix is emitted in one preemption-disabled write phase: one timestamp, one identity capture, a sequence number per event, origin class 0 throughout, and a single deferred publication that makes the whole prefix visible atomically.
  8. Unused tokens (count minus events emitted) are refunded — only events actually emitted are charged.

On full success the syscall returns 0 and writes count to *emitted_out. If entry N fails, entries 0 through N−1 are emitted, N is written to *emitted_out, and the syscall returns −1 with the errno of the failing entry. Failed entries never consume sequence numbers, so batch validation failures leave no consumer-visible gap. The final emitted_out store is a second write to userspace; if it faults, the syscall reports EFAULT even though the prefix was already emitted.

Every staged entry is held in kernel memory simultaneously until the write phase completes, so a batch's transient allocation is bounded by count × MaxEventSize — up to 1 GB at the maximum settings — rather than by a single event.

2.4.3 kmes_attach #

Attaches the caller as a consumer of one per-CPU ring buffer, returning a file descriptor. The consumer contract built on this fd — the mapped region layout, the drain and notification protocols, and the re-attach protocol across buffer swaps — is specified in the PSPK event stream chapter; the TRM side of the mechanics is §2.5.

The caller's effective token has to hold SeSecurityPrivilege, enabled — EPERM otherwise — and a successful gate records SeSecurityPrivilege as used. cpu_id is a logical CPU index using the same numbering as the ring metadata and event headers.

The slot array is allocated at KMES initialisation and sized by nr_cpu_ids, then filled by walking for_each_possible_cpu. Those two quantities are not the same thing: the array size bounds a valid cpu_id, while the ring count is however many of those slots got a ring. They agree only when the possible-CPU mask is dense. An index at or beyond the array size fails with EINVAL; so does an index inside it whose slot holds no live ring.

A consumer learns the array size by calling kmes_attach with cpu_id set to KMES_ATTACH_QUERY_SLOTS (0xFFFFFFFF). The call takes the same privilege gate, writes the slot count through capacity, returns 0, and opens no descriptor. Enumeration then walks 0 to slots-1 and skips the indexes that answer EINVAL.

Counting up until the first EINVAL — which is what this interface used to ask for — is wrong on a sparse mask: it stops at the first hole, and every ring above it becomes permanently unreachable, filling and overwriting with no consumer able to attach.

CPUs that were possible but offline at initialisation have rings and are attachable; hotplug beyond the initial set is not handled (§2.7).

On success the current ring capacity is written to *capacity — the consumer computes its mmap size as 8192 + 2 × capacity — and the fd is returned. The fd is opened O_RDWR | O_CLOEXEC and supports exactly two operations: mmap() and close(). The fd is installed before the capacity write-back; if that write faults, the fd is closed again and the syscall returns EFAULT, but another thread of the process can have observed the fd in the interim.

Repeated attaches to the same CPU are permitted and return a new fd each time; all fds for one CPU share the same ring — producer metadata, consumer metadata, and data region — so multiple direct consumers can drain one buffer concurrently, each keeping its own read position in its own memory. KMES stores no per-consumer state: the fd's private data is a reference to the ring, nothing more. When events arrive and need_wake is set, KMES increments that buffer's futex counter and wakes all waiting threads.

2.5 Ring Buffers

Peios / Advanced Peios / PKM / KMES

2.5.1 Organisation #

KMES maintains one ring buffer per logical CPU, created for every CPU in the kernel's possible-CPU set when the module initialises — before LCS exists, using the compiled-in default capacity — and buffering events from that first instant. These boot-time buffers are ordinary buffers: same layout, same overwrite semantics, same generation model, immediately attachable. If LCS never becomes available they simply remain the live buffers indefinitely.

Each ring is an independent, reference-counted object holding its capacity, generation, sequence counter, write and tail positions, futex counter, dropped-event counter, two metadata pages, and the data region. There is no shared state between rings on the write path: each CPU writes only to its own ring, using plain non-atomic fields under preemption disablement, and the only ordering machinery is a set of release stores at publication points. Fds taken by consumers hold references, so a ring — including a superseded generation — survives until the last consumer releases it.

The data region is a vzalloc allocation of exactly capacity bytes, zeroed once at creation and never scrubbed afterwards; overwritten regions retain stale bytes, which is why the consumer contract forbids reading beyond an event's event_size. Capacity is always a power of two — enforced at every entry point — so position wrap is a bitwise AND with capacity - 1. The permitted range is 64 KB to 256 MB, with a 4 MB default.

2.5.2 The two producer metadata pages #

Producer metadata exists twice. The kernel writes its working copy to a private page allocated with the ring, and mirrors every store to a second, consumer-visible page. The consumer-visible page is shmem-backed and allocated lazily at the first kmes_attach for the ring; shmem backing is what makes the notification futex work, since a shared (inode-keyed) futex needs a page-backed mapping. Every producer store — write_pos, tail_pos, futex_counter, generation — is a release store performed to both pages; the static fields are initialised at ring creation and re-stamped when the shared page appears. The mmap handler exposes only the shared page. The field offsets on the page are part of the consumer contract, defined in the PSPK event stream chapter.

2.5.3 Wrap handling #

The consumer's data region is double virtual mapped — the same physical pages appear twice consecutively — so a consumer reads an event that crosses the physical end of the buffer as one contiguous byte sequence. The producer side has no such mapping: kernel writes into the vzalloc region are wrap-aware, splitting a byte-range copy that crosses the boundary into two memcpy calls and masking scalar stores per byte. The contiguity guarantee is a property of the consumer's view, produced by the mmap layout rather than by the writer.

2.5.4 Write protocol #

Each ring has exactly one writer — its CPU — and the write path takes no locks and performs no cross-CPU atomics. For a single kernel emission: capture the timestamp; take the next sequence number; if the event fails a structural check, count the drop and stop (§2.3); otherwise make room, write the event at write_pos & (capacity - 1), publish the new write_pos (old value plus event size) with a release store, and check need_wake. The release store is what makes the event atomic from the consumer's side: the bytes are complete before the position that makes them reachable moves.

Making room is the overwrite walk. While the live span (write_pos - tail_pos) plus the incoming event exceeds capacity, KMES reads the event_size of the event at the tail and advances tail_pos past it, counting each overwritten event in the internal dropped-event counter, and publishes the advanced tail with a release store before the new data lands on top of it. Walking the tail is sequential and possibly cache-cold — the tail can be megabytes from the write position — a cost accepted in preference to maintaining an index of event offsets on the hot write path.

The walk carries a corruption guard: if the size field read at the tail is zero, larger than the capacity, or larger than the live span, KMES abandons the walk and resynchronises by jumping tail_pos straight to write_pos, discarding the entire surviving window in one step (the discarded span is not itemised in the drop counter) and emitting a tracepoint.

During batch writes the running write and tail positions are kept in locals; nothing is published until the batch ends, when the tail and then the write position get one release store each, followed by a single need_wake check — provided at least one event was written. Consumers therefore observe a batch atomically, and see one tail transition per batch rather than one per overwritten event.

2.5.5 Notification #

The wake path reads the consumer page's need_wake byte — the single consumer-writable byte KMES ever reads, treated as a boolean and trusted for nothing else. If it is zero, notification costs that one read. If set, KMES increments the futex counter with a release store and wakes every thread waiting on it. The futex is a shared, inode-keyed futex on the shmem producer page at the counter's offset — a consequence a consumer must match: a FUTEX_PRIVATE_FLAG wait on the mapped address is never woken. Before any consumer has attached, the shared page does not exist and the wake is skipped entirely, though the private counter still advances.

The futex_wake call itself is issued after preemption is re-enabled, outside the write window; only the counter increment happens inside it. Waking a thread that is already awake is a harmless no-op, which is also why the consumer's relaxed clearing of need_wake is safe.

2.5.6 Capacity swaps #

A valid BufferCapacity configuration change replaces every ring. New rings are allocated first, at the old generation plus one, fully initialised before any CPU can see them. The switch itself runs under stop_machine: with every CPU quiesced, each ring's surviving events are migrated to its replacement, the per-CPU live pointers are switched, and each old ring's published generation is bumped to the new value — signalling consumers on the old mapping to re-attach. If an old ring's need_wake is set, its futex counter is bumped inside the quiesced section and the wake is issued after it, so consumers asleep on a dead generation do not sleep forever.

Migration copies the surviving span in sequence order, re-compacted contiguously from position zero: the new ring starts with tail_pos = 0 and write_pos equal to the bytes copied, and the sequence and dropped-event counters carry over, so sequence numbers are continuous across a swap. When the new capacity is smaller than the surviving span, the oldest events are skipped from the tail forward until the suffix fits — loss is bounded to the oldest prefix, traced but not counted as drops. Old positions are meaningless in the new ring; a consumer re-locates by sequence number, per the PSPK protocol.

If allocating the new rings fails, the old rings stay live at their size, no generation changes, and the failure is reported through a KMES_BUFFER_SWAP_FAILED event (§2.6). A migration abort — a corrupt size field encountered inside the quiesced section — abandons the swap the same way but emits no event. There is no automatic retry; the next configuration write or reboot tries again. A superseded generation's pages stay valid for as long as any consumer keeps them mapped, so during and after a swap old and new rings coexist until the last old fd closes.

2.6 Self-Configuration

Peios / Advanced Peios / PKM / KMES

KMES reads four operational parameters from the registry under Machine\System\KMES\. Compiled-in defaults carry it from module load until LCS becomes available; from then on a persistent kernel-internal watch keeps it current. The key names, types, defaults, and ranges are in §2.A.

At no point does KMES wait for configuration. The defaults are always sufficient, and if LCS never appears KMES runs on them indefinitely.

2.6.1 Reading and validating #

Value names are matched with LCS's value-name comparison rules — Unicode Simple Case Folding, case-preserving and case-insensitive. Names in the subtree that do not fold to one of the four canonical names are unknown keys: they are counted and ignored.

A REG_DWORD value carries exactly four little-endian payload bytes and a REG_QWORD exactly eight. A value whose type tag is right but whose payload length is wrong is not a malformed number — it is classified as a wrong-type value, and reported as such.

Values are never clamped or silently corrected. A value outside its range, of the wrong type, of the wrong payload length, absent, or (for BufferCapacity) not a power of two is rejected outright and the previously active value is retained — the compiled-in default, or the last accepted value. The registry write itself succeeds, because the source does not enforce kernel semantics; the registry therefore shows what was written while the event log shows what KMES is actually using. Validation happens twice: once when the change plan is built, and again in C before the plan is applied, so an out-of-range field reaching the second gate fails the whole application with EINVAL.

Applying a plan is all or nothing, and the capacity swap runs first. A BufferCapacity change that cannot be applied therefore also prevents MaxEventSize, MaxNestingDepth, and MaxEmitRatePerProcess from being applied in the same pass, even though those three are valid and would otherwise take effect immediately for subsequent syscalls. A MaxEmitRatePerProcess change additionally reconfigures every live rate bucket, clamping any bucket holding more tokens than the new capacity (§2.4).

A valid BufferCapacity different from the current one triggers a ring buffer swap (§2.5).

2.6.2 Self-configuration events #

KMES reports its own configuration handling through KMES, with origin class 1. These events are best-effort diagnostics: emission runs before the configuration is applied and its result is discarded, so a failed emission neither rolls back a valid application nor activates an invalid value. Each event's payload is built by a small in-kernel msgpack writer into a 768-byte buffer; a payload that would exceed it is silently skipped.

KMES_SELF_CONFIG_INVALID reports one missing or invalid value. Its payload is a msgpack map of exactly nine keys, in order: configuration_parent_path (always Machine\System\KMES), configuration_name (the canonical name), expected_type, expected_min and expected_max (from the key's definition), received_kind (one of missing, wrong_type, u32_out_of_range, u64_out_of_range — a malformed payload length reports wrong_type), received_type (the actual registry type code for a wrong-type value, nil otherwise), received_value (the numeric value for an out-of-range value, nil otherwise), and retained_value (the value KMES continues to use, read before any part of the plan was applied).

One read reports at most four of these events, which is exactly the number of configuration keys. A plan that would need more is rejected before anything is applied, and the entire configuration read is abandoned.

On a first boot where the KMES key exists but is empty, all four keys are missing, so the read emits four KMES_SELF_CONFIG_INVALID events and retains all four defaults.

KMES_BUFFER_SWAP_FAILED reports a valid BufferCapacity change that could not be applied because replacement rings could not be allocated. Its payload is a three-key map: requested_capacity, retained_capacity, and errno — the last carrying the positive value of ENOMEM as an unsigned integer. It is emitted only for allocation failure; a swap abandoned because migration hit a corrupt size field produces no event.

2.6.3 Bootstrap and watching #

  1. PKM loads. KMES initialises with compiled-in defaults and creates per-CPU rings at the default capacity. They are live immediately.
  2. The first Machine-hive source registers, making LCS usable. KMES enumerates every value under Machine\System\KMES\.
  3. Valid values are applied. A BufferCapacity differing from the current one drives a swap; a matching or absent one changes nothing.
  4. KMES arms a persistent watch on the key through LCS's internal watch mechanism — a kernel-internal registration, not a userspace fd-based watch. Delivery is filtered to value-set and value-deleted notifications on the key itself, so changes in keys below Machine\System\KMES do not trigger a re-read.
  5. If the key does not exist yet, the fallback watch is armed on the Machine hive root and fires on subkey creation at any depth. When it fires, KMES re-runs the whole bootstrap: discover the key, read it, and re-arm the targeted watch. Deleting the key afterwards does not re-arm the fallback.
  6. On subsequent changes — administrator edit, or a Group Policy push at a higher-precedence layer — the watch fires and KMES re-reads, validates, and applies or rejects.

2.6.4 Access to the configuration #

The configuration keys inherit the Machine hive root security descriptor, which grants KEY_ALL_ACCESS to SYSTEM and Administrators and KEY_READ to Authenticated Users, so unprivileged processes cannot change KMES's operational parameters. Enforcement is LCS's, not KMES's — KMES reads values that LCS has already decided the caller was entitled to write. Domain policy at a higher-precedence layer provides defence against a compromised local administrator, since creating a layer above precedence 0 requires SeTcbPrivilege.

The boot-time capacity is the compiled-in default and is not separately configurable: making it so would need a channel to deliver a value to the kernel before the registry exists. Once LCS is available, capacity changes go through the ordinary swap.

2.7 Failure Modes

Peios / Advanced Peios / PKM / KMES

KMES has no external trust boundary on the write path: kernel emitters are trusted, and userspace emitters are validated at the syscall boundary (§2.4). Its failure semantics are correspondingly simpler than a subsystem like LCS that spans the kernel-userspace boundary in both directions.

2.7.1 Ring overrun #

When events are emitted faster than consumers drain them, the buffer fills and KMES overwrites the oldest events. The write path is never blocked and emission never fails from buffer pressure. Consumers see the loss as gaps in the per-CPU sequence, and a consumer whose read position has been overtaken re-anchors to the oldest surviving event.

Overrun is a normal operating condition under load, not an error: the system degrades by keeping recent events, losing old ones, and telling consumers that it did.

Two bulk-loss cases sit outside that model. The tail resynchronisation guard (§2.5) can discard an entire surviving window at once when a size field reads back implausibly, and a shrinking capacity swap drops the oldest events that do not fit the new ring. Neither itemises the discarded events in the drop counter, though both are traced.

2.7.2 Event drop #

An event is dropped without reaching the buffer when a structural limit is exceeded — an event type length that cannot be encoded in the header's u16 field, or an event larger than half the ring capacity — and, for syscall emitters only, when the event exceeds MaxEventSize or the payload fails msgpack validation.

The two paths differ in what a consumer sees. For kernel emitters the sequence number is consumed before the structural checks run, so the drop appears as a sequence gap; the emitting subsystem is not notified, since emission is fire-and-forget. For syscall emitters validation completes before the write phase, so no sequence number is consumed and no gap appears — the drop is visible only to the caller, as the syscall's error return.

Events emitted before KMES initialisation completes are discarded with no sequence consumed and no counter incremented, and are therefore invisible to consumers in both ways.

The internal per-CPU dropped-event counter aggregates structural drops and overwrite losses together. It is not exposed in the ring metadata and is reachable only through the KUnit test interface.

2.7.3 Consumer crash #

A crashed consumer's mappings are cleaned up by the ordinary kernel path when its file descriptors close on process exit, and the rings themselves are reference-counted, so they survive until the last reference goes. KMES is unaffected and keeps writing regardless of whether any consumer is attached — a system with no consumers behaves identically to one with consumers, events simply being stamped, buffered, and eventually overwritten. A restarted consumer re-attaches and sees every surviving event, with the outage visible as a sequence gap.

2.7.4 Buffer swap failure #

If replacement rings cannot be allocated, the existing rings stay live at their current size, the configuration change is not applied, no generation changes, and consumers are unaffected. A KMES_BUFFER_SWAP_FAILED event records the requested and retained capacities (§2.6). KMES does not retry; the next configuration write or a reboot triggers another attempt.

2.7.5 LCS unavailable #

If no source ever registers, KMES runs indefinitely on compiled-in defaults with the boot-time rings live and the configuration watch never armed. This is a valid operating mode, not a failure — the only consequence is that the parameters cannot be tuned.

2.7.6 Clock discontinuity #

Timestamps come from CLOCK_REALTIME, so an NTP adjustment can move them forward or backward and consumers sorting by timestamp will see an apparent reordering. Sequence numbers are unaffected: they are independent monotonic counters, never derived from the clock, and remain the reliable ordering primitive within a CPU. KMES neither detects nor compensates for discontinuities. Cross-CPU ordering near a jump is best-effort — an inherent cost of wall-clock timestamps, accepted for their readability and cross-boot comparability. Rate limiting is immune, being driven by the monotonic clock.

System suspend and hibernate are special cases of the same thing. Ring contents survive suspend to RAM, and are restored from the hibernate image on resume; in both cases the clock jumps forward by the sleep duration, so consumers see a wall-clock gap with no sequence gap.

2.7.7 CPU topology #

The set of rings is fixed at initialisation from the kernel's possible-CPU set, and topology changes are not handled dynamically.

A CPU that was possible but offline at initialisation already has a ring: if it comes online later, its events go to that ring, and consumers can attach to it throughout. A CPU whose logical index was outside the possible-CPU set has no ring, and kmes_attach rejects that index; KMES neither creates rings for such CPUs nor publishes topology-change notifications. A CPU taken offline through cpu_online keeps its ring, and a consumer draining it simply sleeps indefinitely, unable to distinguish a quiet CPU from a departed one. Hot-add is the realistic case — hypervisors adding vCPUs to a running guest — while hot-remove is rare outside mainframes.

Anything that fixes this is additive: a topology-change notification, whether a generation bump meaning "re-enumerate", a dedicated descriptor, or a status field in the metadata page, fits the existing attach-per-CPU design without changing the ring format, the event format, or the emission API.

One structural caveat applies to attach discovery. The index bound is the count of rings successfully created, while rings are indexed by logical CPU id. On a system whose possible-CPU mask has holes, the two differ: the "attach with incrementing indexes until EINVAL" loop stops early, and rings at high logical indexes are unreachable.

Each logical CPU — each hardware thread under SMT — gets its own ring, so ring memory scales with threads rather than cores: at the 4 MB default a 64-core, 128-thread machine holds 512 MB of ring buffers. This follows from events being emitted per logical CPU and cpu_id naming the logical CPU.

2.7.8 Memory bounding #

Ring memory in steady state is num_cpus × BufferCapacity for the fixed CPU count fixed at initialisation, plus two metadata pages per CPU and one shmem page per ring that has ever been attached. During a capacity swap old and new rings coexist until every mapping of the old generation is released.

Transient allocation during emission is bounded by the event size for a single emit and freed as soon as the event is written. A batch is different: every entry is staged in kernel memory simultaneously, so a batch holds up to count × MaxEventSize — 256 entries at a 4 MB maximum event size — until its write phase completes.

Each kmes_attach creates one file descriptor, bounded by RLIMIT_NOFILE and by the SeSecurityPrivilege requirement. No KMES-specific global memory cap exists; the capacity configuration and standard Linux resource limits are the bounds.

2.7.9 Allocation and timing choices #

The data region is a plain vzalloc allocation of 4 KB pages, and the mmap handler inserts pages one at a time, so hugepage backing is not available to it. The difference is substantial for TLB coverage: a 4 MB ring needs 1024 standard pages against 2 hugepages, and because the double mapping doubles the virtual range, 2048 standard pages against 4 hugepages. Allocation is not NUMA-aware: pages come from the general allocator with no attempt to place a ring on the node local to its CPU, so writes on a CPU whose ring landed on a remote node cross the interconnect — roughly 100-150 ns against about 70 ns for a local node. Timestamps use the full ktime_get_real_ns() rather than ktime_get_real_fast_ns(), which avoids the timekeeper seqlock and costs roughly 15-25 ns less per event at the price of being up to one tick stale when a timer interrupt is updating the timekeeper concurrently. Headers are built field by field on every event with no precomputed per-CPU template, msgpack validation is scalar, and each staged syscall event takes its own kvmalloc rather than drawing on a per-CPU staging buffer.

None of these choices affects the ring format, the event header, or the consumer protocol.

Appendix 2.A KMES ABI Reference

Peios / Advanced Peios / PKM / KMES

Every name, value, offset and size in this appendix is generated from pkm/uapi/pkm/kmes.h by pkm/tools/gen-kmes-abi.py, with struct layouts measured by compiling a probe against the real header. Regenerate it whenever the ABI changes; do not edit it by hand. The names here are the ones a program actually compiles against.

What a compiler cannot measure -- the error vocabulary of each syscall, the privilege each requires by name, what the configuration keys do, and the implementation bounds that are not in the header -- is in the notes appendix, §2.B, which this generator does not touch.

2.A.1 Syscall numbers #

Signatures are read from the SYSCALL_DEFINE sites in pkm/kmes/.

NumberConstantSignature
1090SYS_KMES_EMITkmes_emit(const char __user *event_type, u16 event_type_len, const void __user *payload, u32 payload_len)
1091SYS_KMES_ATTACHkmes_attach(unsigned int cpu_id, u64 __user *capacity)
1092SYS_KMES_EMIT_BATCHkmes_emit_batch(const struct kmes_emit_entry __user *entries, u32 count, u32 __user *emitted_out)

2.A.2 Structure layouts #

Offsets and sizes are measured, not declared.

2.A.2.1 struct kmes_emit_entry #

Total size 32 bytes.

OffsetSizeTypeField
08__u64event_type
82__u16event_type_len
106__u8[6]_pad0
168__u64payload
244__u32payload_len
284__u8[4]_pad1

2.A.3 Constants #

Grouped as the header groups them.

Event origin class — kmes_event_header.origin_class.

ConstantValue
KMES_ORIGIN_USERSPACE0
KMES_ORIGIN_KMES1
KMES_ORIGIN_KACS2
KMES_ORIGIN_LCS3

Ring-slot discovery.

Ring slots are indexed by logical CPU id and the array is sized by the kernel's nr_cpu_ids, so a slot inside the array holds no ring when that CPU is not possible. Counting up from 0 until SYS_KMES_ATTACH returns -EINVAL therefore stops at the first hole and misses every ring above it, leaving those CPUs' events permanently unreachable.

Call SYS_KMES_ATTACH with cpu_id KMES_ATTACH_QUERY_SLOTS to learn the slot count instead. It writes the count through the capacity argument, returns 0, and opens no descriptor. Enumerate 0 .. count-1 and treat -EINVAL as "this slot holds no ring", not as the end of the array.

The sentinel is outside the index space for good: nr_cpu_ids is bounded by CONFIG_NR_CPUS, which cannot reach 2^32-1.

ConstantValue
KMES_ATTACH_QUERY_SLOTS0xFFFFFFFF

Largest entry count a single SYS_KMES_EMIT_BATCH call accepts.

ConstantValue
KMES_BATCH_MAX_ENTRIES256

Runtime configuration registry location and keys.

Type values match the LCS REG_* constants: REG_DWORD is 4 and REG_QWORD is 11. They are repeated here so <pkm/kmes.h> remains standalone.

ConstantValue
KMES_CONFIG_ROOT_HIVE"Machine"
KMES_CONFIG_ROOT_SYSTEM_KEY"System"
KMES_CONFIG_ROOT_KMES_KEY"KMES"
KMES_CONFIG_KEY_BUFFER_CAPACITY"BufferCapacity"
KMES_CONFIG_KEY_MAX_EVENT_SIZE"MaxEventSize"
KMES_CONFIG_KEY_MAX_NESTING_DEPTH"MaxNestingDepth"
KMES_CONFIG_KEY_MAX_EMIT_RATE_PER_PROCESS"MaxEmitRatePerProcess"
KMES_CONFIG_TYPE_REG_DWORD4
KMES_CONFIG_TYPE_REG_QWORD11
KMES_CONFIG_BUFFER_CAPACITY_TYPE11
KMES_CONFIG_BUFFER_CAPACITY_DEFAULT4194304
KMES_CONFIG_BUFFER_CAPACITY_MIN65536
KMES_CONFIG_BUFFER_CAPACITY_MAX268435456
KMES_CONFIG_MAX_EVENT_SIZE_TYPE4
KMES_CONFIG_MAX_EVENT_SIZE_DEFAULT65536
KMES_CONFIG_MAX_EVENT_SIZE_MIN1024
KMES_CONFIG_MAX_EVENT_SIZE_MAX4194304
KMES_CONFIG_MAX_NESTING_DEPTH_TYPE4
KMES_CONFIG_MAX_NESTING_DEPTH_DEFAULT32
KMES_CONFIG_MAX_NESTING_DEPTH_MIN4
KMES_CONFIG_MAX_NESTING_DEPTH_MAX256
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_TYPE4
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_DEFAULT10000
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_MIN100
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_MAX1000000

Privilege requirements.

Values mirror the corresponding KACS privilege bits while keeping this header standalone.

ConstantValue
KMES_EMIT_REQUIRED_PRIVILEGE0x0000000000200000 (1ULL << 21)
KMES_ATTACH_REQUIRED_PRIVILEGE0x0000000000000100 (1ULL << 8)

On-wire event header.

Every event in a ring begins with a fixed 77-byte header, followed by event_type_len bytes of type string and then the msgpack payload. Events abut at event_size stride, so a header is not generally aligned; it crosses the ABI as raw bytes, not a C struct. Its fields, in order:

__u32  event_size            total event byte length (header + type + payload)
__u32  header_size           byte offset from the event start to the payload
__u64  timestamp_ns
__u64  sequence
__u16  cpu_id
__u8   origin_class          one of KMES_ORIGIN_* above
__u8   effective_token_guid[16]
__u8   true_token_guid[16]
__u8   process_guid[16]
__u16  event_type_len        length of the type string following the header

The three GUIDs are 16-byte Microsoft GUID binary values (Data1/Data2/Data3 little-endian, Data4 raw), captured from KACS at emission time; the null GUID (16 zero bytes) means identity was unavailable (KACS not initialised, or no process context). KMES copies them opaquely.

Read each field at its KMES_EVENT_*_OFFSET below. header_size locates the payload: it is KMES_EVENT_HEADER_BASE_SIZE + event_type_len. A future revision may grow the header, so consumers must use header_size, not the end of the type string, to find the payload.

ConstantValue
KMES_EVENT_SIZE_OFFSET0
KMES_EVENT_HEADER_SIZE_OFFSET4
KMES_EVENT_TIMESTAMP_NS_OFFSET8
KMES_EVENT_SEQUENCE_OFFSET16
KMES_EVENT_CPU_ID_OFFSET24
KMES_EVENT_ORIGIN_CLASS_OFFSET26
KMES_EVENT_EFFECTIVE_TOKEN_GUID_OFFSET27
KMES_EVENT_TRUE_TOKEN_GUID_OFFSET43
KMES_EVENT_PROCESS_GUID_OFFSET59
KMES_EVENT_TYPE_LEN_OFFSET75

Byte width of each identity GUID in the event header.

ConstantValue
KMES_EVENT_GUID_SIZE16

Byte size of the fixed event header — the offset at which the type string begins.

ConstantValue
KMES_EVENT_HEADER_BASE_SIZE77

Ring-buffer metadata layout.

An attached ring is mmap'd as:

page 0          producer metadata (read-only to the consumer)
page 1          consumer metadata (read-write)
ring data       the event bytes, mapped twice back-to-back so an event
                that wraps the buffer end is still contiguous.

The producer metadata page begins with KMES_RING_MAGIC.

ConstantValue
KMES_RING_MAGIC"KMESRING"
KMES_RING_VERSION1
KMES_METADATA_PAGE_SIZE4096
KMES_METADATA_TOTAL_SIZE8192
KMES_MAPPING_PRODUCER_OFFSET0
KMES_MAPPING_CONSUMER_OFFSET4096
KMES_MAPPING_DATA_OFFSET8192

Field offsets within the producer metadata page.

ConstantValue
KMES_PRODUCER_MAGIC_OFFSET0
KMES_PRODUCER_VERSION_OFFSET8
KMES_PRODUCER_CPU_ID_OFFSET12
KMES_PRODUCER_CAPACITY_OFFSET16
KMES_PRODUCER_DATA_OFFSET_OFFSET24
KMES_PRODUCER_GENERATION_OFFSET32
KMES_PRODUCER_WRITE_POS_OFFSET64
KMES_PRODUCER_TAIL_POS_OFFSET72
KMES_PRODUCER_FUTEX_COUNTER_OFFSET128

Field offset within the consumer metadata page.

ConstantValue
KMES_CONSUMER_NEED_WAKE_OFFSET0

2.A.4 Tracepoint diagnostic codes #

From uapi/pkm/trace.h. These are a diagnostic contract for ftrace, perf and eBPF consumers, letting a tool decode a kmes: event's reason, op or state field without recompiling against a specific kernel. No KMES syscall accepts or returns them, and values are append-only.

kmes_drop reason — why the KMES ring machinery lost an event.

RING_FULL is a normal overwrite; TAIL_RESYNC is the silent corruption- recovery path that discards ALL pending events; VALIDATE is a kernel- emit size/type reject at the ring boundary; BATCH_STRUCT_INVALID is a per-entry structural reject in the kernel batch path. Emitted by kmes:kmes_drop. No event payload bytes.

ConstantValueNotes
KMES_DROP_RING_FULL0capacity overwrite; oldest event dropped
KMES_DROP_TAIL_RESYNC1corrupt ring header; pending events silently discarded
KMES_DROP_VALIDATE2single kernel-emit size/type reject
KMES_DROP_BATCH_STRUCT_INVALID3kernel-batch entry structurally invalid

kmes_swap reason — a bounded ring capacity swap lifecycle marker.

BEGIN and COMPLETE/FAILED are whole-topology (cpu field is U16_MAX); MIGRATE_SKIP is per-CPU and carries the skipped byte count in ret. Emitted by kmes:kmes_swap.

ConstantValueNotes
KMES_SWAP_BEGIN0capacity change accepted, rings allocating
KMES_SWAP_COMPLETE1swap committed across all CPUs
KMES_SWAP_MIGRATE_SKIP2shrink: old event too large, skipped (ret=bytes)
KMES_SWAP_FAILED3swap aborted; ret is the errno

kmes_rate reason — the per-process token-bucket backpressure signal.

THROTTLE is an -EAGAIN emit rejection; RECONFIGURE marks an admin rate change clamping all buckets. Emitted by kmes:kmes_rate.

ConstantValueNotes
KMES_RATE_THROTTLE0emit denied -EAGAIN; tokens < requested
KMES_RATE_RECONFIGURE1max emit rate reconfigured for all buckets

kmes_wake reason — consumer wakeup machinery.

NOTE arms a pending wake; FUTEX is the actual futex wake of blocked consumers. Emitted by kmes:kmes_wake.

ConstantValueNotes
KMES_WAKE_NOTE0wake armed; futex counter incremented
KMES_WAKE_FUTEX1blocked consumers woken

kmes_ring_lifecycle reason — a generation-stable ring object transition.

ret is the outcome. Emitted by kmes:kmes_ring_lifecycle.

ConstantValueNotes
KMES_RING_ALLOC0ring backing allocated (or -ENOMEM)
KMES_RING_FREE1ring backing released
KMES_RING_PRODUCER_PAGE2producer shmem/meta page attached
KMES_RING_CONSUMER_FD3consumer anon-inode fd created

kmes_ingress_reject reason — why an emit request was rejected before the ring.

OVER_MAX/OVER_CAP_HALF/SIZE_OVERFLOW are declared-size rejects; EMIT_OVERSIZE is a staged event too large at ring-write time; BATCH_PARTIAL marks a batch that validated fewer entries than requested. Emitted by kmes:kmes_ingress_reject.

ConstantValueNotes
KMES_INGRESS_OVER_MAX0event_size exceeds configured max_event_size
KMES_INGRESS_OVER_CAP_HALF1event_size exceeds ring_capacity/2
KMES_INGRESS_SIZE_OVERFLOW2declared header/event size overflow
KMES_INGRESS_EMIT_OVERSIZE3staged event exceeds live capacity/2 at emit
KMES_INGRESS_BATCH_PARTIAL4batch staged fewer entries than requested

kmes_validate reason — the C-boundary result of the Rust staged-event validator.

The Rust side collapses its structural checks into one nonzero return; only visible type/payload lengths and ret are recorded here. Emitted by kmes:kmes_validate.

ConstantValueNotes
KMES_VAL_EINVAL0Rust msgpack/structural validation rejected

Appendix 2.B KMES ABI Notes

Peios / Advanced Peios / PKM / KMES

§2.A is generated from pkm/uapi/pkm/kmes.h and holds only what a compiler can measure. This appendix holds the rest.

The split is structural rather than editorial. gen-kmes-abi.py overwrites §2.A wholesale on every run, so anything written there is lost the next time the ABI changes.

Layouts that form part of the consumer contract — the event header, the producer and consumer metadata pages, and the mapped region — are specified normatively in the PSPK event stream specification. §2.A gives their offsets as the header defines them; the specification governs.

2.B.1 Syscall parameters #

SyscallParameterTypeMeaning
kmes_emitevent_typeconst char *Event type string.
event_type_lenu16Its length in bytes.
payloadconst void *MessagePack payload.
payload_lenu32Its length in bytes.
kmes_emit_batchentriesstruct kmes_emit_entry *Array of event descriptors.
countu32Number of entries, 1 to KMES_BATCH_MAX_ENTRIES.
emitted_outu32 *Receives the number of events emitted.
kmes_attachcpu_idunsigned intRing slot index, or KMES_ATTACH_QUERY_SLOTS to query the slot count.
capacityu64 *Receives the ring buffer capacity in bytes.

kmes_emit and kmes_emit_batch return 0 on success; kmes_attach returns a file descriptor. All three return -1 and set errno on failure.

The PKM syscall range is 1090–1099; KMES uses the first three.

2.B.2 Privilege requirements #

kmes.h gives these as bit masks so it can stand alone. The names belong to the KACS privilege catalogue, and the bit index is what the two agree on.

OperationRequired privilegeBit
kmes_emit, kmes_emit_batchSeAuditPrivilege21
Rate-limit exemption on bothSeTcbPrivilege7
kmes_attachSeSecurityPrivilege8

Holding a privilege is not enough: it must be enabled, and KMES marks it used before proceeding. A failure to record the used state is itself an EPERM, because an unrecorded privilege use is an audit gap.

SeTcbPrivilege is checked but not required — an emitter that holds it enabled is exempt from the per-process rate limit, and one that does not is throttled.

2.B.3 Implementation bounds #

These are properties of the implementation rather than of the ABI, so they are not in kmes.h and a program must not compile against them. They bound what the interface will accept.

QuantityValueWhere
Maximum event size, structural50% of ring capacitykmes/kmes.c
MessagePack validator nesting stack256kmes/kmes_validate.rs
Self-configuration payload buffer768 byteskmes/kmes.c
Self-configuration audit intents per read4kmes/kmes.h
Self-configuration parameter name64 byteskmes/kmes.h

The structural 50% bound is independent of MaxEventSize and applies to kernel emitters too. An event may satisfy the configured maximum and still be refused because the ring is small.

The validator's 256-frame stack is why MaxNestingDepth has a maximum of 256. The two bounds are enforced independently: the configuration range check refuses a larger value at apply time, and the validator refuses every event outright if it is somehow handed one, rather than silently accepting nesting it cannot track.

2.B.4 Configuration keys #

Registry path Machine\System\KMES\. The type codes, defaults and ranges are in §2.A; what each key does is here.

KeyEffect
BufferCapacityPer-CPU ring size in bytes. Must be a power of two. Changing it swaps every ring; see §2.6.
MaxEventSizeLargest event a syscall emitter may produce.
MaxNestingDepthDeepest MessagePack container nesting the validator will accept.
MaxEmitRatePerProcessToken-bucket rate, events per second, per process.

MaxEventSize, MaxNestingDepth and MaxEmitRatePerProcess apply only to syscall emitters. A kernel emitter is not rate-limited and its payload is not parsed as MessagePack, but it is still checked structurally — non-empty type string within PKM_KMES_MAX_KERNEL_TYPE_LEN, a payload pointer if the length is non-zero, no size arithmetic overflow, and the 50% bound. A kernel event that fails is dropped with KMES_DROP_VALIDATE, not emitted.

A key that is missing, of the wrong type, or out of range does not fail the read: the previous value is retained and the disagreement is recorded as an audit intent. At most four such intents are carried out of one read, so a configuration with five bad keys reports four.

2.B.5 Error codes #

2.B.5.1 kmes_emit #

ErrnoCondition
EPERMSeAuditPrivilege not held or not enabled, or recording its used state failed.
EAGAINPer-process rate limit exceeded.
EINVALZero event type length, event type not valid UTF-8, declared size arithmetic overflowed, payload not valid msgpack, or nesting depth over MaxNestingDepth.
EFAULTEvent type or payload pointer inaccessible.
ENOSPCEvent exceeds MaxEventSize or 50% of ring capacity.
ENOMEMStaging buffer allocation failed, or KMES not initialised.

2.B.5.2 kmes_emit_batch #

ErrnoCondition
EPERMAs kmes_emit.
EAGAINFewer than count tokens available.
EINVALcount is 0 or over KMES_BATCH_MAX_ENTRIES, or the failing entry hit one of kmes_emit's EINVAL conditions.
EFAULTemitted_out, the entry array, or the failing entry's type or payload pointer inaccessible.
ENOSPCThe failing entry exceeds MaxEventSize or 50% of ring capacity.
ENOMEMKernel allocation failed, or KMES not initialised.

2.B.5.3 kmes_attach #

ErrnoCondition
EPERMSeSecurityPrivilege not held or not enabled, or recording its used state failed.
EINVALcpu_id at or beyond the ring array size, or its slot holds no live ring.
EFAULTcapacity pointer inaccessible.
ENOMEMKernel allocation failed, or KMES not initialised.

A KMES_ATTACH_QUERY_SLOTS call takes the same EPERM, EFAULT and ENOMEM conditions and cannot return EINVAL.

The ring array is sized by nr_cpu_ids, not by the number of rings allocated. On a machine with a sparse possible-CPU mask the two differ, and a slot inside the array with no live ring returns EINVAL exactly as an index beyond the array does. A consumer therefore enumerates against the slot count from KMES_ATTACH_QUERY_SLOTS and skips the EINVAL slots rather than stopping at the first one; see §2.4.

2.B.6 Build configuration #

KMES is built by CONFIG_SECURITY_PKM, a boolean option, so it is linked into vmlinux rather than loaded. CONFIG_RUST=y is required: the MessagePack validator is Rust. The whole subsystem is staged into the kernel tree as security/pkm/kmes by pkm/kernel/stage-sources.sh, which also stages <trace/events/kmes.h> so the tracepoints resolve.

The three syscall numbers are added to the syscall table by kernel/patches/arch/syscall-table-pkm.patch, which patches both arch/x86/entry/syscalls/syscall_64.tbl and the copy of it that ships under tools/perf/. They are registered common, so they are reachable from the x32 ABI as well as from x86-64.

CONFIG_SECURITY_PKM_KUNIT compiles in the in-kernel test harness.

3.1 Overview

Peios / Advanced Peios / PKM / KACS

The Kernel Access Control System is the security core of Peios: an LSM within PKM providing identity-based access control in the Linux kernel. It is the sole identity-based authorization mechanism for managed objects. Every identity-based decision — a file open, a registry read, an IPC connection, a signal, a token operation — passes through one evaluation function, AccessCheck.

This chapter covers tokens (§3.2), the Process Security Block (§3.3), privileges (§3.4), impersonation (§3.5), binary signature verification (§3.6), Process Integrity Protection (§3.7), the AccessCheck algorithm in full (§3.8), file enforcement (§3.9), and how Peios identity is projected onto the Linux credential model (§3.10).

Two bodies of material that KACS owns conceptually live elsewhere in the documentation. The binary structures — SIDs, security descriptors, ACLs, ACEs, access masks, conditional ACE bytecode — are specified in PCDS, because userspace tooling constructs and interprets them and has to agree with the kernel byte for byte. The signing format a third party would use to sign a binary with a PIP level is specified in PSPK; this chapter describes only the verification side. What remains here is the kernel's own behaviour: how it holds identity, and how it decides.

3.1.1 Terminology #

A token is a per-thread identity object held in the kernel's credential structure, carrying a user SID, group SIDs, a privilege bitmask, an integrity level, an impersonation level, and metadata. Identity fields — SIDs, type, integrity level — are immutable; policy fields — enabled privileges, enabled groups, default owner, group and DACL — are atomically adjustable. Every thread has a token, and there is no such thing as a null token.

A primary token defines a process's baseline identity and is inherited on fork, reached through task->real_cred. An impersonation token temporarily overrides it for access decisions on one thread only, reached through task->cred.

A LogonSession is a kernel object representing one authentication event: a session ID, a logon type, a user SID, an authentication package, a logon time, and a logon SID. Tokens reference their session by ID.

A privilege is a system-wide right carried on a token. Some influence AccessCheck; others gate a standalone operation.

An impersonation level controls how far an identity can travel: Anonymous, Identification, Impersonation, or Delegation.

An integrity level is a vertical trust classification on tokens and objects. Numerically it is the mandatory label SID's single sub-authority compared as an unsigned integer, so any S-1-16-<n> with exactly one sub-authority is valid. In practice five standard levels form a strict total order — Untrusted (0), Low (4096), Medium (8192), High (12288), System (16384) — and non-standard values such as S-1-16-8448 appear only in SDs authored for Windows interop. Mandatory Integrity Control is the constraint evaluated before the DACL, blocking write access, and optionally read and execute, when the caller's level is below the object's label.

Process Integrity Protection is a two-dimensional trust model — type against trust level — protecting processes and objects from insufficiently trusted callers. Unlike MIC, it revokes rights that a privilege would otherwise have granted.

The Process Security Block is a per-process structure carrying PIP identity, process mitigations, and process restrictions. It is never affected by impersonation, which is the point of keeping it separate from the token.

FACS, the File Access Control Shim, is the part of KACS that replaces Linux DAC with security-descriptor evaluation on files. It enforces the handle model: AccessCheck runs at open time and the granted mask is cached on the file description, with later operations checked against the cached mask.

An object type is the category of a protected resource. Each defines a GenericMapping table translating generic rights into object-specific ones.

The TCB — the components whose correct behaviour is necessary for system security — is the Linux kernel, PKM, and the core trusted userspace daemons: peinit, authd, and loregd.

3.1.2 Relationship to MS-DTYP #

KACS is not a port of another system's security model. Tokens, security descriptors, AccessCheck, structured SIDs, and per-thread impersonation were chosen because they solve what Peios needs solved: coherent identity, rich per-object access control, scoped delegation, and integrated audit.

Those same primitives are the ones Active Directory uses, and Peios is built to join AD domains as a first-class member — exchanging security data with domain controllers, authenticating through Kerberos, and enforcing policy distributed by Group Policy. That imposes binary format compatibility, which PCDS specifies: an SD written by a Windows domain controller and replicated through Samba is evaluated by KACS without translation.

Format compatibility does not imply evaluator compatibility in every corner. Given the same token, descriptor, and desired mask, KACS generally reaches the same decision MS-DTYP describes, which is what makes policy authored in an AD environment behave predictably here. Where it deliberately does not, §3.B records every departure and why.

3.2.1 The Token Model

Peios / Advanced Peios / PKM / KACS / Tokens

A token is a kernel object representing a thread's identity and security policy: the user's SID, group memberships, privileges, integrity level, impersonation state, claims, confinement settings, and metadata. Every KACS-mediated access control decision evaluates the thread's effective token.

Every live userspace thread has one. KACS-mediated authorization never evaluates a null token — credentials that are blank, uncommitted, kernel-only, asynchronous, or otherwise outside a meaningful userspace evaluation context may carry no token at all, and an LSM hook that reaches KACS with such a credential fails closed rather than evaluate a meaningless identity.

3.2.1.1 Relationship to Linux credentials #

Tokens are independently allocated, reference-counted kernel objects. A struct cred holds a pointer to the token in its LSM security blob, not the token data itself.

That indirection is architecturally load-bearing. Linux credentials are immutable once committed: after commit_creds() a struct cred cannot be modified. Had token data been embedded in the credential, every token mutation — toggling a privilege, adjusting a group — would have required allocating a whole new credential. Keeping the token behind a pointer lets token-internal mutations use the token's own synchronisation and leave the credential alone.

Several thread credentials within a process may reference one token object through real_cred, and a mutation to a shared token is visible to every thread sharing it. At fork the child receives an independent deep copy, so mutations after fork are invisible across the process boundary.

3.2.1.2 Primary and effective tokens #

The real_cred/cred split on task_struct carries two roles.

real_cred is the task's objective identity, used when other tasks evaluate access to this task. Its LSM blob points at the primary token — the process's baseline identity, inherited from the parent at fork.

cred is the task's subjective identity, used when this task evaluates access to other objects. Its blob points at the effective token: normally the primary token, or an impersonation token installed by a server thread acting for a client.

With no impersonation in play, real_cred and cred are the same credential and resolve to the same token. Impersonation swaps cred to a new credential pointing at a different token; reverting restores cred to real_cred.

3.2.1.3 Evaluation context #

Token evaluation — AccessCheck, privilege checks — happens only where a meaningful subject authority exists. In task context that authority is the effective token reached through current_cred().

Linux credential substitution is authoritative for deferred work. When a kernel path runs under credentials installed by override_creds() or another subjective-credential mechanism, the token pointer in that credential's blob travels with it and is evaluated normally. KACS does not strip authority merely because the current task carries a kernel-thread, workqueue, or io_uring-worker flag — the credential is what counts, not the worker flag.

Authorization already cached on an object handle, such as the granted mask on a file description, continues to use that cached authority for post-open operations. User-originated asynchronous work that has neither captured credentials nor cached handle authority reaches KACS without a token-bearing credential and fails closed.

Kernel-originated infrastructure work running under the boot SYSTEM credential evaluates as SYSTEM. There is no separate worker-flag-based kernel authority identity.

3.2.2 Token Structure

Peios / Advanced Peios / PKM / KACS / Tokens

Token fields fall into three mutability classes. Fixed fields are set at creation and never change — every security-critical identity field is fixed. Adjustable fields can be modified at runtime through the adjustment operations (§3.2.5). One-way fields can be set or tightened but never cleared or loosened.

3.2.2.1 Identity core (fixed) #

FieldTypeDescription
user_sidSIDThe token's primary identity.
user_deny_onlyboolWhen true, the user SID matches only deny ACEs, never allow ACEs. Set at creation by CreateToken or FilterToken. True whenever write_restricted is true.
groupsSID_AND_ATTRIBUTES[]Group memberships. The set of SIDs is fixed at creation; the per-group attribute flags are adjustable.
restricted_sidsSID_AND_ATTRIBUTES[]?Secondary SID list for restricted tokens, null on unrestricted ones. Set at creation by CreateToken or FilterToken. AccessCheck treats the list as presence-based: a restricting SID participates whenever it is present, and neither SE_GROUP_ENABLED nor SE_GROUP_USE_FOR_DENY_ONLY affects restricted-pass matching.
write_restrictedboolWhen true, the restricted SID check applies only to write access. Set at creation by CreateToken or FilterToken.

The logon SID — S-1-5-5-X-Y, tying the token to its LogonSession — is not stored as a token field at all. It is derived from the session on every read, and materialised once in groups carrying SE_GROUP_LOGON_ID, which is where AccessCheck finds it.

The group SID set is fixed at creation — adjustment never adds or removes a SID. Individual groups can be enabled or disabled by modifying SE_GROUP_ENABLED, within two limits: a mandatory group (SE_GROUP_MANDATORY) cannot be disabled, and a deny-only group (SE_GROUP_USE_FOR_DENY_ONLY) cannot be re-enabled.

A token holds at most 1024 group entries including the kernel-injected logon SID, so CreateToken accepts at most 1023 caller-supplied groups.

3.2.2.2 Token type (fixed) #

FieldTypeDescription
token_typeenumPrimary or Impersonation.
impersonation_levelenumAnonymous, Identification, Impersonation, or Delegation. Primary tokens always carry Anonymous.

3.2.2.3 Integrity (fixed) #

FieldTypeDescription
integrity_leveluintNumeric integrity level. Standard values are 0 (Untrusted), 4096 (Low), 8192 (Medium), 12288 (High), 16384 (System), but any unsigned integer is valid and is compared numerically against object labels.
mandatory_policyflagsNO_WRITE_UP (0x0001) and NEW_PROCESS_MIN (0x0002). Per-token MIC enforcement policy, set at creation.

mandatory_policy is immutable: a process cannot change its own MIC constraints at runtime, neither loosening nor tightening them. This is a deliberate departure from MS-DTYP, where the mandatory policy is runtime-modifiable — which reduces MIC to a constraint that stops only processes not actively trying to bypass it. Immutability is what makes MIC a real boundary here, and it is also what allows the impersonation integrity ceiling (§3.5) to be enforced unconditionally.

3.2.2.4 Privileges (adjustable) #

Each privilege on a token has four independent states.

Present — the privilege exists on the token. A present privilege can be removed permanently, but no privilege can be added after creation. Enabled — the privilege is currently active; only present privileges can be enabled or disabled. Enabled by default — the creation-time enabled state, which adjustment can restore. Used — the privilege has been exercised during this token's lifetime; monotonic, and never cleared once set.

The lifecycle runs: present and disabled, to enabled, to used, then optionally disabled, then optionally removed permanently. Removal clears the privilege from the present, enabled, and enabled-by-default states together.

The four states are encoded as four 64-bit bitmasks, one bit per defined privilege, which makes a privilege check constant-time and a multi-privilege operation atomic.

3.2.2.5 Elevation (one-way) #

FieldTypeDescription
elevation_typeenumDefault (non-elevated), Full (elevated), or Limited (filtered).

A token is created as Default. Only KACS_IOC_LINK_TOKENS sets Full or Limited, when a linked pair is established, and once set neither reverts to Default on that token object. The role is sticky: relinking can replace the partner but never converts Full to Limited or the reverse. DuplicateToken and FilterToken produce new token objects whose elevation_type starts again at Default, because a new token is not part of any linked pair.

Linked pairs are associated at the LogonSession level rather than stored on individual tokens; §3.2.6 describes the pairing mechanism.

3.2.2.6 Default object security (adjustable) #

FieldDescription
owner_sid_indexIndex into [user_sid, groups...] selecting the default owner SID for new objects: 0 is the user SID, 1..N are groups[0..N-1]. The referenced SID is the user SID or a group carrying SE_GROUP_OWNER.
primary_group_indexIndex into [user_sid, groups...] selecting the default primary group. The referenced SID is the user SID or any group SID on the token.
default_daclThe DACL applied to objects this token creates when no explicit descriptor is supplied.

Storing indices rather than SID copies keeps the owner and primary group consistent with the group array they name.

3.2.2.7 Metadata (fixed) #

FieldTypeDescription
token_idLUIDUnique identifier for this token instance.
token_guidUUID128-bit identifier for this token instance, generated by the kernel at creation and immutable. Used by KMES and other kernel-internal consumers for identity stamping and event correlation.
auth_idLUIDThe LogonSession LUID, linking the token to the authentication event that produced it.
sourceTOKEN_SOURCEWho minted the token: an 8-character name plus a LUID.
created_attimestampWhen the original token was minted by CreateToken. Copied unchanged by DuplicateToken, FilterToken, and NEW_PROCESS_MIN, so it tracks original minting rather than duplication.
expirationtimestampWhen the token becomes invalid; zero means no expiry. Not enforced by AccessCheck.
originLUIDThe originating LogonSession for derived tokens, such as S4U or network logon.

3.2.2.8 Mutation tracking (adjustable) #

FieldDescription
modified_idCounter incremented on any token adjustment.

Marking a privilege used is audit and accounting state rather than an access-decision input, so it stays monotonic but does not bump modified_id. Setting the elevation type is the one other mutation that leaves the counter alone.

The counter is intended as a cache invalidation key — a modified_id that has changed since a cached decision was taken means the decision is stale. Nothing currently reads it for that purpose: it is maintained on every adjustment and reported through the statistics query class, and no cache anywhere invalidates on it.

3.2.2.9 Interactivity scope (adjustable) #

FieldDescription
interactivity_scope0 for services, which have no interactive environment; 1 and above for interactive and remote user environments. Changing it requires SeTcbPrivilege.

3.2.2.10 Claims and security attributes (fixed) #

FieldTypeDescription
user_claimsCLAIM_ATTRIBUTES[]Name-value pairs from the user's directory object, fed into conditional ACE evaluation.
device_claimsCLAIM_ATTRIBUTES[]Name-value pairs from the machine's directory object.

3.2.2.11 LCS registry credentials (fixed) #

FieldTypeDescription
lcs_scope_guidsGUID[]Ordered private registry scope GUIDs used by LCS private hive routing. LCS checks the list in order before falling back to global hives.
lcs_private_layersstring[]Registry layer names visible to this token even when disabled globally, using LCS layer-name syntax and matching rules.

These are KACS-owned credential material belonging to LCS. They are fixed at creation and copied by duplication and filtering, and attaching them is authorized by the same trusted-minting gate as the rest of CreateToken — only a caller holding SeCreateTokenPrivilege can create a token carrying them.

3.2.2.12 Device identity (fixed) #

FieldTypeDescription
device_groupsSID_AND_ATTRIBUTES[]?The machine's group memberships, for compound identity.
restricted_device_groupsSID_AND_ATTRIBUTES[]?Filtered device groups for restricted tokens.

3.2.2.13 Confinement (fixed) #

FieldTypeDescription
confinement_sidSID?Places the token in a default-deny sandbox; null means unconfined. When set, AccessCheck switches to default-deny and access requires an explicit grant to this SID or to a SID present in confinement_capabilities.
confinement_capabilitiesSID_AND_ATTRIBUTES[]Declared access capabilities for a confined process, empty if none. The attributes field is carried for wire-format uniformity only: AccessCheck treats capability membership as presence-based and consults neither SE_GROUP_ENABLED nor SE_GROUP_USE_FOR_DENY_ONLY when matching confinement SIDs.
isolation_boundaryboolAdds namespace filtering on top of confinement, making objects outside the boundary invisible rather than merely denied. Requires confinement_sid. Settable at creation but not enforced.
confinement_exemptboolEscape hatch: confinement restrictions are not evaluated at all.

ALL_APPLICATION_PACKAGES participates only when it is present in confinement_capabilities. KACS never synthesises it, and equally never rejects an otherwise valid confined token merely because it is present — strict confinement is expressed by the caller omitting it. Deciding which capabilities a package token receives is authd's job and policy tooling's, not the kernel's.

3.2.2.14 Audit (fixed) #

FieldTypeDescription
audit_policyu32Per-token audit overrides as a bitmask, fixed at creation — no adjustment operation exists.
FlagValueDescription
OBJECT_ACCESS_SUCCESS0x0001Audit successful object access.
OBJECT_ACCESS_FAILURE0x0002Audit failed object access.
PRIVILEGE_USE_SUCCESS0x0004Audit successful privilege exercises: the privilege contributed requested bits that survive into the final granted result.
PRIVILEGE_USE_FAILURE0x0008Audit failed privilege exercises: the privilege contributed requested bits during evaluation, but they do not survive into the final result.

The policy is additive. It forces audit events that system-wide policy would not generate, and cannot suppress events that system-wide policy requires. It follows impersonation: when service A impersonates client B and B's token has a category enabled, operations during impersonation are audited under B's identity. The creation default is 0.

3.2.2.15 Credential projection (fixed) #

FieldDescription
projected_uidLinux UID for the user SID, precomputed by authd when the token is minted (PSPU §2); 65534 for the anonymous identity.
projected_gidLinux primary GID, precomputed the same way; 65534 for the anonymous identity.
projected_supplementary_gidsLinux supplementary GIDs, one per group SID, precomputed the same way.

authd computes these at token creation and they are stored on the token; KACS never resolves a SID-to-UID mapping at runtime. Projection reflects all groups regardless of enabled state, so adjusting groups does not trigger recalculation. §3.10 covers how the projection is used.

3.2.2.16 Token security (adjustable) #

FieldDescription
security_descriptorThe token's own SD, controlling who may query, adjust, duplicate, or impersonate it.

3.2.2.17 Internal #

FieldDescription
refcountReference count; the token is freed when the last reference drops. Not exposed to userspace.

3.2.3 Token Lifecycle

Peios / Advanced Peios / PKM / KACS / Tokens

3.2.3.1 Fork and clone #

Every process and thread creation path goes through clone(), and KACS branches on CLONE_THREAD.

Without CLONE_THREAD — fork or vfork — a new process is created and the child receives an independent deep copy of the parent's primary token. If the parent is impersonating, the impersonation token is not inherited: the child's effective credential is set from real_cred. After the fork, mutations to either token are invisible to the other.

With CLONE_THREAD a new thread is created. Threads share the parent's real_cred by reference and therefore share one primary token object, so a privilege adjustment on it is visible to every thread. Each thread keeps independent impersonation state through its own cred. If the cloning thread is impersonating at the moment of the clone, the impersonation token does not become the new thread's primary or effective identity — the new thread starts with the shared primary token as both, and may impersonate independently later.

3.2.3.2 Exec #

The primary token survives execve() unchanged, with the single exception of NEW_PROCESS_MIN below. If the calling thread is impersonating, impersonation is reverted before the new program runs; a new program always starts with the primary token as its effective identity.

Token assignment for services happens between fork and exec: peinit forks, installs the service's token on the child, and the child then execs the service binary.

3.2.3.2.1 NEW_PROCESS_MIN #

When a token's mandatory_policy includes NEW_PROCESS_MIN, the kernel replaces the primary token at exec time if the executable carries a lower integrity label.

  1. The executable's integrity label is read from the mandatory label ACE in its descriptor's SACL. A file with no label is treated as Medium.
  2. If the file's level is lower than the token's, a new token is created following DuplicateToken semantics — new token_id, new token_guid, modified_id initialised to the new token_id, elevation_type reset to Default — with integrity_level set to the file's label. Every other field is copied from the source, and the original token is dropped.
  3. If the file's level is greater than or equal to the token's, nothing happens and the token survives exec unchanged.

The mechanism only ever lowers integrity, so a child's level is always at most its parent's. The flag itself is immutable on the token, which is what prevents a process from opting out before exec.

3.2.3.3 Self-installing a primary token #

KACS_IOC_INSTALL commits a new primary token on the calling process. The operation is process-wide: the kernel replaces the primary token for the entire thread group, not just the calling thread.

A thread that is not impersonating switches both real_cred and cred to the new primary token. A thread that is impersonating has only real_cred replaced, and its active impersonation stays in cred until it reverts or execs — so reverting after an install restores the thread to the new primary token, not the old one.

The calling thread installs immediately. Sibling threads converge in their own context through queued in-kernel credential work, with no atomic all-threads-at-once swap: during a brief transition window some siblings may still observe the old primary token until they run their queued work. No completion barrier is exposed to the caller.

If the installed token's user SID differs from the outgoing primary token's, the process security descriptor is regenerated from the default template for the new token. Otherwise the existing process descriptor is preserved.

3.2.3.4 Bootstrap tokens #

Two tokens are created by PKM during kernel initialisation, before any userspace process exists. Neither involves a syscall — the kernel allocates the objects directly.

The SYSTEM token is hardcoded with user SID S-1-5-18 (Local System); groups S-1-5-32-544 (BUILTIN\Administrators), S-1-1-0 (Everyone), S-1-5-11 (Authenticated Users) and other well-known SIDs; every defined privilege present and enabled; integrity level System; token type Primary; elevation type Default; token source PeiosKrn; projected UID 0; and auth_id set to SYSTEM_LUID. It is assigned to the kernel's init task and inherited by PID 1 at exec.

The Anonymous token is a global singleton with user SID S-1-5-7; Everyone (S-1-1-0) as its only group; no privileges; integrity level Untrusted; logon type Network; token type Impersonation; impersonation level Anonymous; elevation type Default; token source PeiosKrn; and auth_id set to ANONYMOUS_LOGON_LUID. It is effectively immutable, having no privileges and no groups to adjust. kacs_impersonate_peer at Anonymous level references this global object, whereas KACS_IOC_DUPLICATE targeting Anonymous level creates a fresh independent token of the same shape, because the DuplicateToken contract requires a new object.

LUIDValueDescription
SYSTEM_LUID999 (0x3E7)The SYSTEM LogonSession, created at kernel init.
ANONYMOUS_LOGON_LUID998 (0x3E6)The Anonymous LogonSession, created at kernel init for Anonymous impersonation tokens.

LogonSessions created dynamically by authd receive auto-generated LUIDs starting at 1000; 999 and 998 are never assigned dynamically.

The SYSTEM token carries SeBackupPrivilege and SeRestorePrivilege present and enabled at boot. FACS passes backup and restore intent flags into AccessCheck, which grants read and write regardless of file DACLs — subject still to PIP. Once peinit has launched the TCB services and early boot is complete, it disables these on child service tokens through FilterToken.

3.2.3.5 External token replacement #

A privileged process being able to replace the primary token on another running process — peinit downgrading a pre-authd service from SYSTEM to a purpose-built token, for instance — is designed but not built. Nothing in the kernel implements it today.

The design uses task_work_add() to queue a credential swap on each task in the target thread group, each task executing the swap in its own context to preserve RCU safety, with an impersonating thread having only real_cred replaced and its impersonation left intact. The gates would be SeAssignPrimaryTokenPrivilege on the caller's real token, TOKEN_ASSIGN_PRIMARY (0x0001) on the token fd, PROCESS_SET_INFORMATION on the target process's descriptor, and two constraints — the new token's user SID matching the target's current one, and the new token belonging to the same LogonSession — both bypassed by SeTcbPrivilege. The process descriptor gate puts the target's owner in control of who can change its identity, while the SID and LogonSession constraints stop a non-TCB holder of SeAssignPrimaryTokenPrivilege assigning an arbitrary token to a process it can reach. SeTcbPrivilege bypassing both is how peinit would assign tokens with different user SIDs and LogonSessions to child services. Per-thread queuing would leave a brief window with some threads on the new token and some on the old, which is acceptable only because replacement is always a downgrade.

In its absence, the mitigation for pre-authd services is privilege removal: peinit uses FilterToken to copy the SYSTEM token with the dangerous privileges permanently deleted and assigns that filtered token to the service at launch. The service keeps the SYSTEM SID but permanently lacks the ability to exercise those privileges.

3.2.4 Token Creation

Peios / Advanced Peios / PKM / KACS / Tokens

Three operations create tokens, each for a different purpose: CreateToken mints one from nothing, DuplicateToken copies one, and FilterToken produces a strictly weaker copy.

3.2.4.1 CreateToken #

Mints a new token from scratch. The caller supplies the security-meaningful content; the kernel generates the internal bookkeeping and validates the structural invariants. The operation is gated by SeCreateTokenPrivilege.

The caller supplies user_sid, groups with their attributes, privileges as privs_present plus privs_enabled, owner_sid_index, primary_group_index, default_dacl, integrity_level, mandatory_policy, token_type, impersonation_level, auth_id referencing an existing LogonSession, expiration (0 for none), audit_policy, source as a name plus LUID, user_claims, device_claims, lcs_scope_guids, lcs_private_layers, device_groups, restricted_sids, restricted_device_groups, confinement_sid, confinement_capabilities, confinement_exempt, isolation_boundary, write_restricted, user_deny_only, projected_uid, projected_gid, projected_supplementary_gids, origin, and interactivity_scope. The wire format is in §3.A.

Including the well-known implicit groups is the caller's responsibility — Everyone (S-1-1-0), Authenticated Users (S-1-5-11), and whatever else the principal's authentication context implies, such as S-1-5-4 Interactive, S-1-5-6 Service, or S-1-5-15 This Organization. The kernel injects none of these. The logon SID is the sole kernel-generated group.

The kernel generates token_id as a LUID, token_guid, modified_id initialised to token_id, created_at as the current time, elevation_type always Default, the token's own default security descriptor (§3.2.7), and logon_sid, derived from the LogonSession ID as S-1-5-5-{id >> 32}-{id & 0xFFFFFFFF}.

The logon SID is injected into the groups array carrying SE_GROUP_MANDATORY | SE_GROUP_ENABLED_BY_DEFAULT | SE_GROUP_ENABLED | SE_GROUP_LOGON_ID, appended after the caller's groups. Callers do not include it themselves. Because the injected entry is appended, owner_sid_index and primary_group_index are interpreted relative to the caller-supplied groups — 0 for the user SID, 1..N for the caller's groups — and not against the array with the logon SID in it.

Validation covers, in turn: that the caller holds SeCreateTokenPrivilege; that every SID is structurally well-formed; that the owner SID is the user SID or a group carrying SE_GROUP_OWNER, resolved to owner_sid_index; that the primary group SID is the user SID or a group on the token, resolved to primary_group_index; that auth_id references an existing LogonSession; that a Primary token carries impersonation level Anonymous; that user_deny_only is true whenever write_restricted is; that confinement_sid is present whenever isolation_boundary is; that the wire format's elevation_type field is 0, since the kernel always sets Default itself; and that the caller's group count plus the injected logon SID fits the 1024-entry limit.

The optional LCS registry credential extension, when present, has to use the version and layout of §3.A, and carries at most 256 scope GUIDs and at most 256 private layer names, with no nil scope GUID, no duplicate scope GUIDs, no empty or overlong layer names, and no duplicate layer names under LCS case-insensitive matching. Malformed LCS credentials fail the whole call closed.

The kernel does not authenticate the user, look up SIDs in the directory, resolve SID-to-UID mappings, or check that the principal exists at all. Holding SeCreateTokenPrivilege is what makes the caller trusted, and that trust is total.

The call returns a token file descriptor. Since CreateToken takes no desired-access parameter, the returned handle always carries a cached access mask of TOKEN_ALL_ACCESS.

3.2.4.2 DuplicateToken #

Creates an independent copy of an existing token, requiring TOKEN_DUPLICATE access on the source.

Two things may change during duplication. The token type may go from primary to impersonation or the reverse; duplicating to Primary forces impersonation_level to Anonymous. The impersonation level may be chosen freely when the source is a Primary token, but when the source is itself an impersonation token the new level has to be equal to or lower than the source's — an Identification-level token cannot be duplicated up to Impersonation or Delegation.

On the new token, token_id and token_guid are fresh, modified_id is initialised to the new token_id, and elevation_type resets to Default because the copy belongs to no linked pair. token_type and impersonation_level are as the caller specified, within the rules above. The token's own descriptor is a fresh default (§3.2.7): no custom descriptor can be supplied at duplication time, and changing it afterwards means using WRITE_DAC on the new handle.

Everything else is copied from the source: user_sid, user_deny_only, logon_sid; groups with all per-group attributes; restricted_sids and write_restricted; the privilege present, enabled, enabled-by-default and used states; integrity_level and mandatory_policy; auth_id, origin, source, created_at, expiration, audit_policy; interactivity_scope; default_dacl, owner_sid_index, primary_group_index; user_claims, device_claims, device_groups, restricted_device_groups; lcs_scope_guids and lcs_private_layers; confinement_sid, confinement_capabilities, confinement_exempt; and the three projection fields.

One target is not a copy at all. Duplicating to Impersonation at Anonymous level discards the source entirely and returns a fresh token of the boot Anonymous shape — user SID S-1-5-7, Everyone as its only group, no privileges, Untrusted integrity, and LogonSession 998 rather than the source's. None of the copied-field rules above apply to it. Assuming Anonymous is an identity boundary rather than a level change, so the operation constructs the minimal identity instead of narrowing the caller's.

The original token is unaffected.

3.2.4.3 FilterToken #

Creates a restricted copy, requiring TOKEN_DUPLICATE access on the source. Filtering only ever weakens: there is no parameter that grants anything.

It can remove privileges, deleting them permanently from the new token by clearing them from the present, enabled, and enabled-by-default states at once. It can set groups to deny-only, giving them SE_GROUP_USE_FOR_DENY_ONLY so they block access through deny ACEs but never grant it through allow ACEs — permanently, with no way back. It can add restricted SIDs, a secondary list that makes AccessCheck evaluate the DACL twice, granting access only when the normal SIDs and the restricted SIDs independently both pass. And it can enable write-restricted mode, limiting that second evaluation to write operations so reads use the normal list alone; enabling it forces user_deny_only true on the new token.

Input validation is all-or-nothing — a single malformed entry means no token is created. The deny-only list uses zero-based group indices into the source's group array, and a duplicate or out-of-range index is invalid. The restricting SID blob has to parse exactly as the declared packed SID list, with no truncated or trailing bytes. If the source token is already restricted and the intersection of its restricted SID list with the supplied list is empty, the request is invalid and nothing is created.

On the new token, token_id and token_guid are fresh, modified_id is initialised to the new token_id, and elevation_type resets to Default. The privilege states are the source's modified by the removal list, except that used resets to 0 — unlike duplication, which carries it across. groups keeps the source's SIDs with attributes modified per the deny-only list, adding and removing nothing. restricted_sids is the supplied list, or its intersection with the source's when the source was already restricted. write_restricted is sticky: set if requested or if the source had it. user_deny_only is true when write-restricted is enabled and otherwise copied. user_sid, logon_sid, integrity_level, mandatory_policy, token_type and impersonation_level are copied, as are auth_id, origin, source, created_at, expiration, audit_policy, default_dacl, owner_sid_index, primary_group_index, the claims and device group arrays, the LCS credentials, the confinement fields, and the projection fields. The token's descriptor is a fresh default.

3.2.5 Token Adjustment

Peios / Advanced Peios / PKM / KACS / Tokens

A live token's privileges, groups, default object-creation metadata, and interactive session metadata can be adjusted at runtime. These are distinct operations with separate access rights and constraint models. All of them mutate the token object in place using atomic operations, all become visible immediately to every thread sharing the token, and all bump modified_id.

3.2.5.1 AdjustPrivileges #

Requires TOKEN_ADJUST_PRIVILEGES on the token, and has three modes.

Enable and disable flips individual bits in privileges_enabled for privileges present on the token. A privilege that is not present cannot be enabled, and disabling one that is already absent is a no-op. The operation activates existing privileges; it never grants new ones.

Reset to defaults restores privileges_enabled to match privileges_enabled_by_default, returning every privilege to its creation-time state in one operation. It is encoded as a single kacs_priv_entry with luid = 0 and attributes = KACS_PRIV_RESET_ALL_DEFAULTS. The reset touches only privileges_enabled: it does not restore privileges that were removed from privileges_present.

Remove permanently deletes a privilege, clearing its bit in privileges_present, privileges_enabled, and privileges_enabled_by_default together. Removing an already-absent privilege is a no-op. The deletion is irreversible; nothing re-adds a privilege to a token.

Duplicate privilege indices in one request are invalid. The kernel validates every entry before applying any change, so an invalid entry fails the whole operation with no state change at all. The caller receives a report of each adjusted privilege's previous state.

3.2.5.2 AdjustGroups #

Requires TOKEN_ADJUST_GROUPS on the token, and has two modes.

Enable and disable flips SE_GROUP_ENABLED on individual groups, within two constraints. A group carrying SE_GROUP_MANDATORY, SE_GROUP_USE_FOR_DENY_ONLY, or SE_GROUP_LOGON_ID cannot be adjusted in either direction — not enabled, not disabled, whatever its current state; naming one in a request fails the whole call. And the user SID, when it appears in the group list, cannot be disabled, which is the one constraint that is direction-scoped.

Reset to defaults restores every group to its creation-time enabled state. It restores only that state — it does not clear SE_GROUP_USE_FOR_DENY_ONLY from a group that FilterToken marked deny-only afterwards.

Groups are addressed by zero-based index into the token's groups array. A count of 0 is invalid, as is a count above 1024, as are duplicate indices in one request. Reset is encoded as a single kacs_group_entry of { index = 0xFFFFFFFF, enable = 0 }.

The caller receives the previous enabled state of every group as a 1024-bit mask encoded as sixteen 64-bit words in ascending order: word 0 covers group indices 0–63, word 1 covers 64–127, and bit i % 64 of word i / 64 corresponds to group index i. Since group arrays cap at 1024 entries, the mask is complete for any valid token.

3.2.5.3 AdjustInteractivityScope #

Requires TOKEN_ADJUST_INTERACTIVITY_SCOPE on the token and SeTcbPrivilege on the caller's real token. It changes the token's interactivity_scope to a new u32 and nothing else: the field is metadata, and changing it grants or removes no privilege, group, access right, label, claim, default owner, default primary group, default DACL, or any other authorization state.

3.2.5.4 AdjustDefault #

Requires TOKEN_ADJUST_DEFAULT on the token, and covers three fields.

The default DACL applied to new objects is replaced by an RCU pointer swap, with the old DACL freed after a grace period. The owner SID index changes which SID becomes the default owner of new objects, and has to reference the user SID or a group carrying SE_GROUP_OWNER. The primary group index changes the default primary group, and has to reference the user SID or any group SID on the token. Both index updates are atomic.

All three affect future object creation only; existing objects are untouched. None can escalate anything, because the caller is choosing among SIDs already on their own token.

audit_policy is fixed at creation, and no adjustment operation changes it.

3.2.6 Linked Tokens and Elevation

Peios / Advanced Peios / PKM / KACS / Tokens

At logon, authd may create two tokens for one principal and link them. The elevated token, elevation_type = Full, carries the user's complete identity: every group active, every assigned privilege present and enabled. The filtered token, elevation_type = Limited, carries the same user SID with administrative groups set to deny-only and dangerous privileges stripped, produced from the elevated token by FilterToken.

Both tokens belong to the same LogonSession, are both primary tokens, and carry the same user SID. The filtered token is installed as the LogonSession's default; the elevated token exists but is not directly reachable by unprivileged processes.

A token never assigned a linked-pair role has elevation_type = Default. Once KACS_IOC_LINK_TOKENS sets a token object to Full or Limited, that role is sticky on that object. If the pair is later replaced or destroyed the token has no active partner and KACS_IOC_GET_LINKED_TOKEN returns an error, but the token goes on reporting its last assigned elevation type.

3.2.6.1 What KACS does #

KACS provides pairing, storage, and query restriction — three mechanisms, no policy.

Linked pair association registers the pair on the LogonSession, so given either token the system can retrieve its partner. The pairing lives at the LogonSession level and is not stored on the token objects.

Establishing a pair is a TCB operation: the caller holds SeTcbPrivilege on its primary token — an impersonating thread's effective token does not satisfy it — and holds TOKEN_DUPLICATE on both of the token handles being linked. The two handles have to name distinct token objects, and both have to belong to the LogonSession named in the request, which itself has to be published. The ioctl ignores the handle it was issued on entirely; only the two named handles matter.

Elevation type classification puts elevation_type on each token so a consumer can tell which side it is holding.

Identification-level query restriction governs what an unprivileged caller gets when it queries a token's partner: a deep clone at Identification impersonation level. The clone follows DuplicateToken semantics — a new token object, a new token_id, modified_id initialised to it, a fresh default descriptor — except that it preserves the partner's elevation_type, and it is always returned through a TOKEN_QUERY-only handle. The caller can inspect the elevated token but cannot use it for an access decision. A caller holding SeTcbPrivilege receives a full handle to the actual linked token instead.

Returning a copy through TOKEN_QUERY is a deliberate exception to the normal access-right model, where TOKEN_DUPLICATE would be expected. It holds because the returned copy is always Identification-level: functionally a query result rather than a usable token.

3.2.6.2 What KACS does not do #

The elevation decision itself — whether this user should be allowed to use their elevated token right now — is entirely authd's. KACS does not gate elevation, verify credentials, or display prompts. It stores the pair and restricts unprivileged access to it.

Beyond enforcing the LogonSession, token-type and same-user invariants, KACS does not verify that the filtered token really is a FilterToken-derived reduction of the elevated one. That correspondence is authd's to get right.

3.2.6.3 Lifecycle #

Both tokens in a pair share a LogonSession, and that session is not destroyed while any token fd, credential, pair slot, or other reference keeps one of its token objects live. When the last external reference is released and only the linked-pair's own references remain, KACS destroys the LogonSession, removes the linkage, and drops the pair's references to both tokens. After that cleanup no token object from the session remains live purely because it was linked.

Stale-role tokens can exist before final destruction — for instance when KACS_IOC_LINK_TOKENS replaces a LogonSession's active pair while an old token object is still held by an fd or credential. Fork produces them too: the deep copy a child receives preserves the parent's elevation type, so a forked child can hold a Full or Limited token that was never linked to anything. Once a token is no longer the active member of the pair, querying its linked token returns an error, because the partner relationship no longer exists for it. Such survivors keep their sticky Full or Limited elevation type: they are stale-role tokens with no active partner, not Default tokens.

3.2.7 LogonSessions and Revocation

Peios / Advanced Peios / PKM / KACS / Tokens

3.2.7.1 LogonSessions #

A LogonSession is a lightweight kernel object identified by a LUID, carried on tokens as auth_id. Every token references one.

authd creates a LogonSession through a KACS syscall at authentication time, before creating the token. The object holds the LogonSession ID, the logon type (Interactive, Network, Service, and so on), the user SID, the authentication package name such as Kerberos or Negotiate, and a creation timestamp. The logon SID, S-1-5-5-X-Y, is derived from the LogonSession ID. Several tokens may share one session — linked pairs, and tokens derived by duplication.

When the last token referencing a session is freed, the kernel destroys the session object and emits a logon-session-destroyed event through KMES. authd subscribes to those events and uses them to clean up associated credentials such as cached Kerberos tickets.

There is one rollback path for the case where authd creates a session but no token ever becomes live for it: kacs_destroy_empty_logon_session, which requires SeTcbPrivilege and succeeds only when the session exists, has zero live tokens, has no linked-token state, and has no other in-flight kernel references. On success it destroys the object and emits the same logon-session-destroyed event as normal cleanup. A nonexistent session fails with -ENOENT; one with any live token, linked-token state, or in-flight reference fails with -EBUSY.

A second enumeration surface exists alongside /proc: /sys/kernel/security/kacs/sessions lists every live session, one line each, giving the session ID, user SID, logon type, authentication package, and creation time. Reading it is access-checked against a synthetic descriptor granting read to SYSTEM and the creator, and is PIP-checked.

AccessCheck never consults auth_id, and the logon SID influences a decision only because it is materialised as an ordinary group SID on the token. Two enforcement decisions elsewhere in the kernel do read session state, though: installing a primary token denies a non-TCB caller whose target token belongs to a different LogonSession, and the CAP_SYS_BOOT mapping selects between SeShutdownPrivilege and SeRemoteShutdownPrivilege by inspecting the session's logon type. interactivity_scope is metadata in the same way: the kernel stores it and returns it on query, and no kernel security mechanism evaluates it.

3.2.7.2 Expiration #

The expiration field carries a timestamp, and AccessCheck does not enforce it. It is informational.

Token lifetime is governed by reference counting instead: a token exists as long as at least one reference — a process credential or an open file descriptor — exists.

3.2.7.3 Revocation #

KACS has no token revocation primitive. There is no "invalidate token X" syscall, and no syscall destroys a LogonSession while tokens still reference it. kacs_destroy_empty_logon_session is only authd's rollback for a session that never acquired live tokens.

Terminating a LogonSession is therefore userspace coordination:

  1. authd decides a session has to end — an admin request, a security incident, an account deletion, or a user logging off.
  2. authd enumerates processes whose tokens carry the target auth_id or interactivity_scope by walking /proc/*/token, opening each node's query-only inspection handle, and reading TokenStatistics, which includes auth_id. No dedicated enumeration syscall exists or is needed.
  3. authd requests termination — through peinit for supervised services, through signals for user processes.
  4. The processes terminate, dropping their token references.
  5. The last reference drops and the session object is cleaned up.

Token file descriptors can be passed between processes over IPC, so a reference held by a process outside the target session survives that session's process termination. authd has to account for this when enumerating token holders — the walk finds processes running under the session, not every process holding one of its tokens.

Kernel-side invalidation — a dead flag on the LogonSession object checked during AccessCheck, so that access checks against its tokens fail immediately — is not implemented.

3.2.8 Token Access Rights

Peios / Advanced Peios / PKM / KACS / Tokens

Tokens are securable objects: each has its own security descriptor, and reaching a token means passing an AccessCheck against it.

3.2.8.1 Obtaining a token file descriptor #

Opening directly. A syscall takes a pidfd — not a raw PID — and a desired access mask. The kernel finds the target's primary token, evaluates the caller's token against that token's descriptor, and returns a token fd with the granted mask cached on it. A separate variant opens a thread's impersonation token. Opening another process's token additionally requires PROCESS_QUERY_INFORMATION on the target process's descriptor.

kacs_open_peer_token is the exception: it takes no desired-access mask, and the fd it returns always carries the fixed rights TOKEN_QUERY | TOKEN_IMPERSONATE.

Receiving over IPC. A token fd can be passed over a Unix socket with SCM_RIGHTS. What the recipient may do is bounded by the mask cached on the fd when it was originally opened, not by the recipient's own identity.

Implicit self-access. A thread has implicit access to its own effective token for query operations, but this is not a kernel bypass. It follows from the default token descriptor, which grants TOKEN_QUERY and the adjustment rights to the token's own user SID. The AccessCheck still runs; it simply succeeds while the descriptor continues to grant the right. Explicitly mutating a token's own descriptor later can revoke self-query by removing that grant.

3.2.8.2 Token-specific rights #

RightValueGrants
TOKEN_ASSIGN_PRIMARY0x0001Install as a process's primary token. Also requires SeAssignPrimaryTokenPrivilege on the caller's token.
TOKEN_DUPLICATE0x0002Duplicate the token, or create a restricted copy with FilterToken.
TOKEN_IMPERSONATE0x0004Install as a thread's impersonation token.
TOKEN_QUERY0x0008Read token information: SIDs, groups, privileges, integrity, claims, source, statistics, elevation type.
TOKEN_ADJUST_PRIVILEGES0x0020Enable, disable, or permanently remove privileges.
TOKEN_ADJUST_GROUPS0x0040Enable or disable groups.
TOKEN_ADJUST_DEFAULT0x0080Change the default DACL, owner SID, and primary group SID.
TOKEN_ADJUST_INTERACTIVITY_SCOPE0x0100Change the interactivity scope. Also requires SeTcbPrivilege.

Bit 0x0010, TOKEN_QUERY_SOURCE, is subsumed by TOKEN_QUERY: a holder of 0x0008 can query source information too. The bit is not reused for anything else, for format compatibility with MS-DTYP.

TOKEN_ALL_ACCESS is 0x000F01FF — the union of the token-specific rights with STANDARD_RIGHTS_REQUIRED (DELETE | READ_CONTROL | WRITE_DAC | WRITE_OWNER, 0x000F0000). The named rights alone OR to 0x01EF; the reserved TOKEN_QUERY_SOURCE bit adds 0x0010 to reach 0x01FF.

3.2.8.3 Generic mapping #

Generic rightMaps to
GENERIC_READTOKEN_QUERY | READ_CONTROL
GENERIC_WRITETOKEN_ADJUST_PRIVILEGES | TOKEN_ADJUST_GROUPS | TOKEN_ADJUST_DEFAULT | WRITE_DAC
GENERIC_EXECUTETOKEN_IMPERSONATE
GENERIC_ALLTOKEN_ALL_ACCESS (0x000F01FF)

3.2.8.4 Standard rights #

READ_CONTROL reads the token's own descriptor, WRITE_DAC modifies its DACL, and WRITE_OWNER changes its owner. DELETE has no practical effect on a token and is present only for uniformity across standard rights.

3.2.8.5 The default token descriptor #

A newly created token receives a descriptor owned by the creating process's user SID, with a DACL granting:

  • the token's own user SID TOKEN_QUERY | TOKEN_ADJUST_PRIVILEGES | TOKEN_ADJUST_GROUPS | TOKEN_ADJUST_DEFAULT;
  • the creator TOKEN_ALL_ACCESS;
  • SYSTEM (S-1-5-18) TOKEN_ALL_ACCESS.

Self-access is deliberately limited to the adjustment operations that cannot escalate. TOKEN_DUPLICATE, TOKEN_IMPERSONATE, and WRITE_DAC are not granted to the token's own subject.

That limit needs protecting in the case where the creator and the token's own user SID are the same, which would otherwise hand the subject TOKEN_ALL_ACCESS through the creator ACE. When the two SIDs are identical the creator ACE is omitted — and the descriptor also gains a non-inherit-only OWNER RIGHTS ACE suppressing the owner's implicit READ_CONTROL | WRITE_DAC grant while preserving READ_CONTROL. Without it, owner-implicit WRITE_DAC would let the subject rewrite its own DACL and reintroduce exactly the escalation that omitting the creator ACE was meant to close.

3.2.8.6 Check-at-open #

The check-at-open model applies to tokens exactly as it does to files, for the token-specific rights. AccessCheck runs once, when the token fd is obtained; the granted mask is cached on the fd; and each of the token ioctls verifies against that cached mask with no re-evaluation.

The standard rights are the exception. Reading and writing a token's own descriptor passes no cached mask at all and runs a live AccessCheck on every call, so READ_CONTROL, WRITE_DAC and WRITE_OWNER are re-evaluated per operation rather than snapshotted at open. A descriptor change therefore takes effect immediately for those three rights, while already-opened handles keep their cached token-specific rights.

3.3.1 The PSB Model

Peios / Advanced Peios / PKM / KACS / The Process Security Block

The Process Security Block is a per-process structure carrying properties that describe what the process is, independent of the principal running it.

Its PIP identity fields, pip_type and pip_trust, are determined by the binary loaded at exec (§3.6). Its other fields — process mitigations and process restrictions — are process policy, set by whoever launches the process. Neither category is token identity, and neither is determined by the principal.

Keeping the PSB separate from the token is the point. The token describes identity and travels with impersonation: when a thread impersonates a client, the effective token changes. The PSB describes the process, and impersonation never touches it.

The PSB is never affected by impersonation. Impersonation changes who the thread is acting as. It does not change what the process is.

The canonical PSB reference lives on the task's LSM security blob, task_struct->security, separate from the credential that holds the token pointer. A mirrored, non-authoritative reference is also kept in credential security blobs, for the benefit of Linux hooks that receive only a credential. For any task-attached credential that mirror refers to the same PSB as the task blob, and it never defines a different process identity.

Credentials are swapped during impersonation, and that swap may change the credential's token pointer — but the canonical PSB is untouched and any credential-level mirror continues to refer to it.

3.3.2 PSB Fields

Peios / Advanced Peios / PKM / KACS / The Process Security Block

3.3.2.1 Process identity (fixed at fork) #

FieldTypeDescription
process_guidUUID128-bit identifier for this process instance, generated by the kernel at fork and immutable for the process's lifetime. It is not copied from the parent — every process receives a new one. Used by KMES and kernel-internal consumers for identity stamping and event correlation.

The process GUID is distinct from the PID. PIDs are recycled; process GUIDs are unique within a boot, and globally unique in practice. It is the stable correlation key that lets KMES attribute events to a process across its whole lifetime.

3.3.2.2 Protection (set at exec, fixed) #

FieldTypeDescription
pip_typeu32Process Integrity Protection type. Determined by the binary's cryptographic signature at exec.
pip_trustuintTrust tier within a PIP type; higher values can reach lower ones. Determined by the signer's identity.

PIP fields are signing-based. At exec the kernel verifies the binary's signature and derives both fields from the signer, using the algorithm and key model of §3.6.

Both are plain unsigned integers rather than enumerations, and are compared numerically. Three type values are conventional — None (0), Protected (512) and Isolated (1024) — but only two are producible: the key table validator accepts a key only at exactly Protected with PeiosTcb trust (8192) and rejects the whole table otherwise, so Isolated is unreachable and a signed binary is always Protected/8192. Neither None nor Isolated is defined as a named constant in the public ABI at all. The parent process cannot influence the determination at all — even a compromised peinit running as SYSTEM cannot forge PIP protection for an unsigned binary. The public verification key is compiled into the kernel image, and the kernel only ever verifies; it never signs.

This is a deliberate departure from MS-DTYP, where the parent sets a protection level at process creation and the kernel validates the binary's signature against it. Peios removes the parent-controlled half entirely: one input, one answer.

3.3.2.3 Process mitigations (one-way) #

FieldDescription
lsvLibrary Signature Verification. Only signed shared libraries load. When the process has pip_type != None the library's trust level has to be at or above the process's PIP trust; when pip_type = None any valid signature suffices and trust is not compared.
wxpWrite-XOR-Execute Protection. No page is simultaneously writable and executable; W+X mappings and transitions between writable and executable are rejected.
tlpTrusted Library Paths. Shared libraries load only from approved directory prefixes. Weaker than LSV, since it trusts the path rather than the binary.
cfifForward-Edge Control Flow Integrity. Hardware indirect-branch tracking — Intel IBT, ARM BTI — is locked on and cannot be disabled by the process. Not settable: see below.
cfibBackward-Edge Control Flow Integrity. The hardware shadow stack, Intel CET, is locked on and cannot be disabled by the process.
piePosition-Independent Executable Requirement. Non-PIE binaries are rejected at exec, so that ASLR is actually effective.
smlSpeculation Mitigation Lock. Speculation mitigations are locked on and cannot be disabled by the process.

Mitigations are inherited from the parent at fork and can be set by syscall, typically by peinit between fork and exec. They are one-way: once set they are never cleared, and exec does not reset them — a mitigation set by the launcher persists regardless of which binary is loaded.

Setting a bit is activation-backed. Before a mitigation bit moves from clear to set, KACS either activates the underlying protection for the target process or verifies that the process already satisfies the invariant. If any requested mitigation cannot be activated or verified, the whole operation fails closed without mutating any bit from that request. Once committed, later operations that would disable the protection or make the process violate the invariant are rejected, and re-requesting an already-set mitigation never weakens what is already committed.

For the runtime memory mitigations, activation covers existing state as well as future transitions. Enabling wxp fails if the process already has a mapping that is simultaneously writable and executable, or otherwise already violates the invariant in a way KACS can observe. Enabling tlp fails if the process already has a file-backed executable mapping whose kernel-resolved path is missing, unresolvable, outside the approved prefix cache, or otherwise TLP-denied. Enabling lsv fails if the process already has a file-backed executable mapping whose signing material is missing, invalid, or below the required PIP trust. Anonymous executable mappings are governed by wxp alone; tlp and lsv apply only to file-backed ones.

For the architecture-backed mitigations, activation goes through the architecture's kernel interface to place the process in the protected state and prevent later process-controlled disablement. Enabling cfif, cfib, or sml fails closed when the platform cannot make the protection true for the target.

sml also accepts a second route: a platform that reports speculation as unconditionally not-affected satisfies activation by that fact alone, with nothing to enable.

cfif cannot currently be committed at all. Activation against a live task returns ENODEV unconditionally, because the kernel exposes no userspace control surface for IBT or BTI, so the bit fails closed on every request. cfib works, through shadow-stack enable-and-lock, with one restriction: enabling it on a task other than the caller fails.

Two mitigations are event-gated rather than retroactive: pie is enforced at subsequent exec and no_child_process at subsequent process creation. They still follow the one-way commit rule, and have to be set before the event they are meant to constrain.

All of this is distinct from PIP. Mitigations are policy set by the launcher; PIP is a property of the binary determined by the kernel.

The mitigations compose deliberately: LSV ensures only signed libraries load, WXP blocks code injection, CFIF blocks forward-edge code reuse through indirect calls and jumps, CFIB blocks return-oriented programming, and PIE makes ASLR effective. Together they make exploitation dramatically harder than any one of them alone.

3.3.2.4 UI access (one-way) #

FieldDescription
ui_accessPermits interaction with higher-integrity UI elements. Reserved for future desktop functionality. Set by syscall, typically by peinit between fork and exec, and fixed thereafter.

3.3.2.5 Process restrictions (one-way) #

FieldDescription
no_child_processOnce set, the process creates no child processes — fork, or clone without CLONE_THREAD. New threads are unaffected, and the flag is never cleared.

Unlike the exec-time fields, this one can be set at two points. The parent's code, running in the freshly forked child, can set it before exec, so the new binary loads with the restriction already in place. Or a process can restrict itself at any time during its life — after it has finished spawning its own workers, for instance.

3.3.2.6 The TLP cache #

The approved directory prefixes for TLP live in a global kernel cache rather than on individual PSBs: the tlp flag decides whether a process is subject to enforcement, while the paths themselves are machine-wide.

The cache is an array of absolute directory prefix byte strings evaluated against kernel-resolved Linux path bytes, holding at most 64 entries of at most 4096 bytes each. Every prefix begins with / and ends with / — so that /usr/lib/ does not match /usr/libevil — and contains no embedded NUL byte. An empty, relative, NUL-containing, or non-slash-terminated prefix is invalid and is rejected without mutating the existing cache, which is staged and swapped under a mutex so a rejected update cannot leave it partly written.

The cache has no production writer. The only code that populates it is a test helper compiled in solely under the KUnit configuration: there is no syscall, no securityfs node, and no registry path that fills it. In a shipping build the cache is therefore permanently empty — and since an empty cache matches no path, enabling tlp on a process denies every file-backed executable mapping it subsequently attempts.

At mmap(PROT_EXEC) time, a process with TLP enabled has the mapped file's current kernel-resolved backing path checked against every approved prefix. If the path cannot be resolved, if no prefix matches, or if the cache is empty, the mapping is rejected.

3.3.2.7 Identity virtualization (reserved) #

FieldDescription
virtualizationPer-process state for setuid compatibility redirection. Not active, and implementations may omit the field until it is.

3.3.3 Process Security Descriptors

Peios / Advanced Peios / PKM / KACS / The Process Security Block

Every process carries a security descriptor controlling who may operate on it, stored on the PSB alongside the PIP and mitigation fields. It replaces Linux's UID-based process access control — a patchwork of UID comparisons and capabilities — with a single descriptor evaluation.

3.3.3.1 Process access rights #

RightValueMeaning
PROCESS_TERMINATE0x0001Send signals whose default action is termination.
PROCESS_SIGNAL0x0002Send informational signals whose default action is to ignore: SIGCHLD, SIGURG, SIGWINCH.
PROCESS_VM_READ0x0010Read process memory — ptrace peek, /proc/<pid>/mem, process_vm_readv.
PROCESS_VM_WRITE0x0020Write process memory — ptrace poke, /proc/<pid>/mem, process_vm_writev. Includes debugger attach.
PROCESS_DUP_HANDLE0x0040Extract file descriptors from the process through pidfd_getfd.
PROCESS_SET_INFORMATION0x0200Change priority, CPU affinity, I/O priority, resource limits, process group membership where Linux permits it, timer slack, memory-placement policy or pages, and mutable /proc/<pid> task state — sched, autogroup, timens_offsets, timerslack_ns, coredump_filter, oom_adj, oom_score_adj, make-it-fail, fail-nth, latency and clear_refs, plus write intent on the coupled uid_map, gid_map, projid_map and setgroups seq files.
PROCESS_QUERY_INFORMATION0x0400Inspect the process's token; read the detailed /proc/<pid>/* files — cmdline, status, io, limits, sched, autogroup, timens_offsets, personality, syscall, latency, timers, timerslack_ns, mounts, mountinfo, mountstats, coredump_filter, oom_adj, oom_score_adj, loginuid, make-it-fail, fail-nth, seccomp_cache, ksm_merging_pages and ksm_stat — plus read intent on the coupled uid_map, gid_map, projid_map and setgroups seq files; query Linux compatibility capability state through capget(pid); and query detailed scheduler, CPU-affinity and I/O-priority state.
PROCESS_SUSPEND_RESUME0x0800Send signals whose default action is to stop or continue.
PROCESS_QUERY_LIMITED0x1000Read basic process information: PID, process group ID, session ID, image name, state, CPU and memory usage — stat, statm, comm, wchan, schedstat, cpuset, cgroup, cpu_resctrl_groups, oom_score, sessionid, patch_state, stack_depth and arch_status. This is what ps and top show, it covers /proc/<pid>/stat, and it is the right required for pidfd_open() and for kill(pid, 0) existence probes.
READ_CONTROL0x20000Read the process's own descriptor.
WRITE_DAC0x40000Modify the process's DACL.
WRITE_OWNER0x80000Change the descriptor's owner.

Three /proc entries are not where the right names suggest. maps, fd and environ are not gated by PROCESS_QUERY_INFORMATION: they keep their upstream PTRACE_MODE_READ_FSCREDS gating, which maps to PROCESS_VM_READ — reading a process's memory map is treated as reading its memory, which is defensible but is not what the right's name implies. And cgroup sits in the PROCESS_QUERY_LIMITED set rather than the detailed one.

3.3.3.2 Signal classification #

Each Linux signal maps to a process access right according to its default action.

Signal 0 is not delivered at all. A kill(), tkill(), or tgkill() call with signal 0 is an existence and permission probe, requiring PROCESS_QUERY_LIMITED on the target plus PIP dominance.

PROCESS_TERMINATE — default action terminate, or terminate with a core dump:

Signal#DefaultNotes
SIGHUP1TerminateSession hangup
SIGINT2TerminateCtrl-C
SIGQUIT3Terminate + coreQuit request
SIGILL4Terminate + coreIllegal instruction
SIGTRAP5Terminate + coreDebug trap
SIGABRT6Terminate + coreAbort
SIGBUS7Terminate + coreBus error
SIGFPE8Terminate + coreFloating point exception
SIGKILL9TerminateForced kill, cannot be caught
SIGUSR110TerminateUser-defined
SIGSEGV11Terminate + coreSegfault
SIGUSR212TerminateUser-defined
SIGPIPE13TerminateBroken pipe
SIGALRM14TerminateAlarm timer
SIGTERM15TerminateGraceful termination request
SIGSTKFLT16TerminateStack fault
SIGXCPU24Terminate + coreCPU time exceeded
SIGXFSZ25Terminate + coreFile size exceeded
SIGVTALRM26TerminateVirtual timer
SIGPROF27TerminateProfiling timer
SIGIO29TerminateI/O possible
SIGPWR30TerminatePower failure
SIGSYS31Terminate + coreBad syscall

PROCESS_SUSPEND_RESUME — default action stop or continue:

Signal#DefaultNotes
SIGSTOP19StopForced stop, cannot be caught
SIGTSTP20StopTerminal stop, Ctrl-Z
SIGTTIN21StopBackground read from terminal
SIGTTOU22StopBackground write to terminal
SIGCONT18ContinueResume a stopped process

PROCESS_SIGNAL — default action ignore:

Signal#DefaultNotes
SIGCHLD17IgnoreChild status change
SIGURG23IgnoreUrgent socket data
SIGWINCH28IgnoreWindow resize

The real-time signals, SIGRTMIN through SIGRTMAX (32–64), default to terminate and therefore require PROCESS_TERMINATE.

3.3.3.2.1 What bypasses the check #

This classification applies only to signals sent by userspace through kill(), tkill(), and tgkill(). Kernel-generated signals — hardware faults such as SIGSEGV, SIGBUS and SIGFPE, SIGCHLD from a child exiting, SIGPIPE from a broken pipe — are delivered by the kernel and bypass the process descriptor check entirely, because the task_kill LSM hook does not fire for kernel-originated delivery.

Terminal-generated job control signals are kernel-originated under that rule and bypass the check the same way: SIGINT, SIGQUIT and SIGTSTP from the tty driver's isig handling, and SIGHUP on hangup. This is intentional. Authorization for keyboard-driven signals is possession of the controlling terminal, which was gated by the terminal's file descriptor at open time — so Ctrl-C reaches the whole foreground process group even when a member of it is more privileged or more PIP-trusted than whoever holds the terminal. A process that cannot accept that exposure must not attach to an untrusted controlling terminal.

The si_uid in a delivered signal's siginfo_t is the sender's projected UID (§3.10), captured at send time. Like every projected credential surface it is informational only and is not an authorization input; si_pid carries the same caveat and is subject to PID reuse besides.

3.3.3.3 Generic mapping #

Generic rightMaps to
GENERIC_READPROCESS_QUERY_INFORMATION | PROCESS_VM_READ | READ_CONTROL
GENERIC_WRITEPROCESS_SET_INFORMATION | PROCESS_VM_WRITE | WRITE_DAC
GENERIC_EXECUTEPROCESS_TERMINATE | PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED
GENERIC_ALLevery process right above, together with READ_CONTROL, WRITE_DAC and WRITE_OWNER

3.3.3.4 The default process descriptor #

Every process receives a default descriptor at creation:

Owner: <creator's primary token user SID>
Group: <creator's primary token primary group SID>
DACL:
  ALLOW  <process's own user SID>   GENERIC_ALL
  ALLOW  BUILTIN\Administrators     GENERIC_ALL
  ALLOW  SYSTEM                     GENERIC_ALL
  ALLOW  Everyone                   PROCESS_QUERY_LIMITED

A process can therefore do anything to itself; Administrators and SYSTEM have full control over every process; everyone can see basic process information, which is what makes ps and top work for all users; and detailed inspection — token, memory, environment — is restricted to the process itself, administrators, and SYSTEM.

A service can modify its own descriptor at runtime with kacs_set_sd, which requires WRITE_DAC — granted to the process itself by the default DACL. Requesting a custom descriptor at launch through a service definition is not implemented: the only descriptor creation path always builds the default template, so every process starts from it and any deviation is a subsequent write.

3.3.3.5 How PIP relates to it #

PIP and the process descriptor are complementary, and both checks have to pass. The descriptor controls who may operate on the process; PIP controls what trust level is required for invasive access to a protected one. AccessCheck evaluates the caller's token against the target's descriptor for the requested right, and PIP evaluates the caller's trust against the target's pip_type and pip_trust for operations crossing the process boundary.

The two are genuinely independent. A process may have a permissive descriptor granting Administrators GENERIC_ALL and still be PIP-protected, so administrators pass the descriptor check and are stopped only by insufficient PIP trust.

The converse — a process with no PIP protection carrying a restrictive descriptor that denies even administrators — holds with one qualification. When a descriptor check denies access and PIP was not the deciding factor, an enabled SeDebugPrivilege on the caller grants the access anyway and is marked used. The privilege therefore rescues a descriptor denial while remaining unable to cross a PIP boundary, which is exactly the split §3.4.2 describes for it.

3.3.4 PSB Lifecycle

Peios / Advanced Peios / PKM / KACS / The Process Security Block

3.3.4.1 Fork #

The child receives a copy of the parent's PSB with a single exception: process_guid is not copied, and the child is given a new kernel-generated one. Everything else — the PIP fields, the mitigations, and any active restrictions — is inherited, so a Protected process's children start Protected and PIP propagates across fork.

The child also receives a new default process descriptor. Its owner is the forking thread's primary token's user SID, not the impersonation token's, even when the thread is impersonating at the time. The DACL follows the default template.

3.3.4.2 Exec #

The PIP fields are reset at exec from the new binary's cryptographic signature. A Protected parent that execs an unsigned binary loses PIP protection: protection follows the binary, not the lineage.

The mitigation flags — lsv, wxp, tlp, cfif, cfib, pie, sml, ui_access — are not reset. They persist across exec unchanged, so a mitigation set between fork and exec survives whatever binary is subsequently loaded. no_child_process persists in the same way: a process restricted from creating children stays restricted no matter what it execs.

process_guid is not reset either, because it identifies the process — the scheduling entity — rather than the binary.

The process descriptor is not reset. Exec preserves it unchanged. It was initialised at fork from the forking thread's primary token and reflects the process creation context, or a later explicit management context, rather than the binary being executed. Primary token installation and the other explicit process-descriptor mutation paths replace or modify it only under their own rules (§3.2.3).

3.3.4.3 Clone with CLONE_THREAD #

Threads share the process's PSB. Thread creation is unaffected by no_child_process, which blocks new processes only.

3.3.4.4 Relationship to AccessCheck #

The PSB is not an input to AccessCheck in the general case. AccessCheck takes a token and a descriptor and evaluates access; most PSB fields are invisible to it.

PIP is the exception. The pipeline includes a PIP enforcement step reading pip_type and pip_trust, which come from the PSB rather than from any token: the enforcement layer extracts them and passes them to AccessCheck as explicit parameters.

The asymmetry between MIC and PIP follows from that. MIC uses the effective token, so impersonation changes how it evaluates — which is safe because the integrity ceiling on impersonation (§3.5.2) prevents escalation. PIP uses the PSB, so impersonation cannot change how it evaluates — necessary because there is no impersonation gate constraining the PIP dimensions, making the PSB the only safe source.

The process mitigations and no_child_process do not interact with AccessCheck at all. Each is enforced at its own enforcement point, independently of the access control pipeline.

3.4.1 The Privilege Model

Peios / Advanced Peios / PKM / KACS / Privileges

Some operations do not fit the subject-object model at all. Rebooting the machine, loading a kernel module, changing the system clock, creating a token — these affect the system itself rather than a specific protected resource, so there is nothing to attach a security descriptor to. They still need authorization.

Privileges fill that gap. A privilege is the right to perform a particular system operation, carried on the token beside the principal's identity. Where a descriptor says "this principal may read this file", a privilege says "this principal may shut down the system". The descriptor lives on the object; the privilege lives on the subject.

3.4.1.1 Lifecycle #

A privilege is assigned by policy when authd creates the token, resolving the principal's assignments from security policy once, at creation. There are no runtime grants: a privilege absent at creation can never be added later.

The privilege then sits on the token in whatever enabled state it was created with. The kernel accepts any enabled set that is a subset of the present set, and takes the creation-time enabled set as the enabled-by-default set. authd issues every privilege it grants already enabled, on the reasoning that a privilege the holder had to enable before it worked would be a grant in name only — so the present-but-disabled resting state exists in the model and is reachable through AdjustPrivileges, but is not where privileges normally start.

A privilege that has been disabled is re-activated by explicitly enabling it through AdjustPrivileges.

When the privilege is exercised, the kernel checks that it is both present and enabled — a single mask test against both words — permits the operation, and records it as used.

For a standalone gate, "exercised" means the gate accepted that bit, and the used bit is meant to be recorded even when a later independent check — a process descriptor, PIP, or a malformed-input test — denies the operation afterwards. Where the shared privilege helper performs the check, it marks the bit immediately, and the impersonation gate does the same. Three gates mark later and therefore record nothing when a subsequent check fails: token creation marks after the token has been constructed and its descriptor allocated, so a malformed specification leaves the bit unset; primary token installation marks after the same-user and same-LogonSession gate; and the CAP_SYS_BOOT mapping marks after the remote-shutdown origin gate.

Used-state for the AccessCheck-influencing privileges follows the provenance rules of §3.8 instead.

Recording the used bit is not merely bookkeeping. Every gate treats a failure to record it as a failure of the operation itself and returns EPERM or EACCES.

Afterwards the privilege may be disabled, returning to rest, or removed permanently, which clears it from the present, enabled, and enabled-by-default states while preserving the used bit for audit.

3.4.1.2 Two enforcement categories #

Standalone operation gates are the majority. They authorize specific operations that AccessCheck does not mediate — rebooting, loading modules, debugging processes — and the kernel simply checks whether the calling thread's token holds the privilege present and enabled before allowing the operation.

AccessCheck-influencing privileges alter the outcome of AccessCheck itself, causing it to grant rights the object's DACL would not grant on its own. They are evaluated inside the pipeline alongside DACL rules, integrity policy, and confinement. There are five: SeSecurityPrivilege grants ACCESS_SYSTEM_SECURITY for SACL access (and doubles as a standalone gate for the audit-related Linux capabilities); SeTakeOwnershipPrivilege grants WRITE_OWNER as a post-DACL fallback; SeBackupPrivilege grants all read access; SeRestorePrivilege grants all write access plus WRITE_DAC, WRITE_OWNER, DELETE and ACCESS_SYSTEM_SECURITY; and SeRelabelPrivilege loosens MIC's constraint on WRITE_OWNER for non-dominant callers. §3.8 gives the exact mechanics.

3.4.1.3 Intent gating #

SeBackupPrivilege and SeRestorePrivilege are intent-gated. Other AccessCheck-influencing privileges are self-scoping — SeSecurityPrivilege only matters when ACCESS_SYSTEM_SECURITY is requested — but backup and restore grant such broad categories of access that evaluating them unconditionally would apply them to every AccessCheck on the system.

AccessCheck therefore takes a privilege_intent parameter. A caller passes BACKUP_INTENT for a backup-context operation and RESTORE_INTENT for a restore-context one, and the corresponding privilege is evaluated only when its flag is present. Without the flag, these privileges are invisible to the pipeline.

Intent gating also keeps backup and restore inside the pipeline rather than short-circuiting it, which matters because later stages — PIP in particular — have to be able to constrain privilege-granted access.

3.4.1.4 Assignment #

Privileges are assigned by security policy, not by identity. Membership of the Administrators group confers no privilege by itself. Groups and privileges are orthogonal: groups determine which objects you can reach through DACLs, privileges determine which system operations you can perform.

An administrator defines policy — that members of Backup Operators receive SeBackupPrivilege and SeRestorePrivilege, say. At authentication authd resolves the principal's group memberships, evaluates the policy against them, and creates the token carrying the result. The kernel neither verifies nor evaluates that policy; it trusts authd as a TCB component. The token then carries those privileges for its whole lifetime.

3.4.1.5 Auditing #

Every exercise sets the token's monotonic used state for that privilege, and every standalone gate emits an ftrace event.

KMES audit events are emitted only for the five AccessCheck-influencing privileges, and only when the token's audit_policy opts in through PRIVILEGE_USE_SUCCESS or PRIVILEGE_USE_FAILURE. The event fires when the privilege's provenance bits intersect both the mapped desired mask and the final granted mask. For SeSecurityPrivilege and SeTakeOwnershipPrivilege that intersection is genuinely counterfactual — ACCESS_SYSTEM_SECURITY is pre-decided by the privilege, and take-ownership contributes only when WRITE_OWNER was not already granted — so an event means the privilege was load-bearing. Backup and restore seed their bits unconditionally, without asking whether the DACL would have granted the same access, so their events also fire for accesses the DACL alone would have permitted.

A MAXIMUM_ALLOWED request short-circuits this accounting entirely, recording no used bits and emitting no privilege-use events for any privilege.

3.4.2 Privilege Catalogue

Peios / Advanced Peios / PKM / KACS / Privileges

The complete set of Peios privileges, with the bit each occupies in the token's four 64-bit privilege words. Format-compatible privileges sit at their standard Windows LUID positions in bits 2–35; custom Peios privileges are allocated downward from bit 63, so that a privilege defined by a future AD release cannot collide with one of ours.

Enforcement classes are: kernel standalone, enforced at a specific operation boundary independently of AccessCheck; AccessCheck, evaluated inside the pipeline; AccessCheck + standalone, both; application-level, checked by a userspace service rather than the kernel; and reserved, allocated for format compatibility with no enforcement point.

3.4.2.1 Identity and token management #

PrivilegeBitMaskEnforcement
SeCreateTokenPrivilege20x4Kernel standalone
SeAssignPrimaryTokenPrivilege30x8Kernel standalone
SeImpersonatePrivilege290x20000000Kernel standalone

SeCreateTokenPrivilege mints tokens from scratch, and only TCB components — authd and peinit — carry it. SeImpersonatePrivilege lets a service impersonate a principal other than itself, and every service that handles requests on behalf of users needs it; it is checked in exactly one place, the impersonation identity gate (§3.5.2).

SeAssignPrimaryTokenPrivilege gates installing a token as a process's primary identity. Installation is self-directed: KACS_IOC_INSTALL acts on the calling process and fans out to the sibling threads of its own thread group. There is no mechanism for installing a token on a different process (§3.2.3). The kernel also requires the new token to carry the same user SID and the same LogonSession as the outgoing one, unless the caller additionally holds SeTcbPrivilege. The privilege is also consulted as a deny gate on the exec and credential projection paths.

3.4.2.2 Access control #

PrivilegeBitMaskEnforcement
SeSecurityPrivilege80x100AccessCheck + standalone
SeTakeOwnershipPrivilege90x200AccessCheck
SeBackupPrivilege170x20000AccessCheck + standalone
SeRestorePrivilege180x40000AccessCheck + standalone
SeRelabelPrivilege320x1_0000_0000AccessCheck + standalone
SeChangeNotifyPrivilege230x800000Kernel standalone
SeCreateSymbolicLinkPrivilege350x8_0000_0000Kernel standalone

SeSecurityPrivilege reads and writes an object's SACL, and also gates CAP_AUDIT_CONTROL, CAP_MAC_ADMIN and CAP_AUDIT_READ through the capability mapping, the KMES ring buffer attach, and supplying a SACL at object creation.

SeTakeOwnershipPrivilege takes ownership of any object regardless of its permissions. It is the only privilege here with no standalone enforcement point at all — it exists purely inside AccessCheck.

SeBackupPrivilege and SeRestorePrivilege read and write any object regardless of the DACL. Inside AccessCheck they are intent-gated (§3.4.1), but both are also used as plain standalone gates outside it: restore on the descriptor replacement path when the cache is invalid and on owner assignment to a SID the subject does not hold, and both on the registry key backup and restore paths.

SeRelabelPrivilege changes an object's integrity label, punching WRITE_OWNER through MIC for non-dominant callers and removing the at-or-below-own-level restriction when a label is written.

SeChangeNotifyPrivilege bypasses traverse checking — without it, reaching a file requires FILE_TRAVERSE on every intermediate directory. The bypass has one exception the name does not suggest: it is suppressed when the access carries MAY_CHDIR, so an explicit chdir takes a full FILE_TRAVERSE check whether or not the caller holds the privilege. The privilege additionally gates open_by_handle_at, which has nothing to do with traversal. It is checked once per intermediate directory on every path resolution, and each check takes the token's mutation lock for a snapshot and then performs a used-bit update, so the cost is O(depth) locked operations per path walk.

SeCreateSymbolicLinkPrivilege creates symbolic links, and is required in addition to FILE_ADD_FILE on the parent directory.

3.4.2.3 System operations #

PrivilegeBitMaskEnforcement
SeTcbPrivilege70x80Kernel standalone
SeLockMemoryPrivilege40x10Kernel standalone
SeIncreaseQuotaPrivilege50x20Kernel standalone
SeLoadDriverPrivilege100x400Kernel standalone
SeSystemProfilePrivilege110x800Kernel standalone
SeSystemtimePrivilege120x1000Kernel standalone
SeProfileSingleProcessPrivilege130x2000Kernel standalone
SeIncreaseBasePriorityPrivilege140x4000Kernel standalone
SeManageVolumePrivilege280x10000000Kernel standalone
SeShutdownPrivilege190x80000Kernel standalone
SeDebugPrivilege200x100000Kernel standalone
SeAuditPrivilege210x200000Kernel standalone
SeRemoteShutdownPrivilege240x1000000Kernel standalone

SeTcbPrivilege is the catch-all for system operations with no more specific privilege, and only TCB services need it. It has by far the widest reach of any privilege: fourteen Linux capability mappings, several token operations, the mount policy paths, the central access policy cache, LogonSession creation and destruction, removal of mandatory resource attribute ACEs during descriptor merge, a KMES rate limit exemption, the LCS source authentication path, and the upgrade of a linked-token query from an Identification-level copy to the real token at full access.

SeShutdownPrivilege shuts down or reboots the machine, mapped through CAP_SYS_BOOT. SeRemoteShutdownPrivilege is required in addition when the request originates from a Network, NetworkCleartext, or NewCredentials logon.

SeLoadDriverPrivilege loads and unloads kernel modules through CAP_SYS_MODULE. SeDebugPrivilege attaches to and inspects any process regardless of its descriptor, and does not bypass PIP (§3.7). SeSystemtimePrivilege changes the clock, SeIncreaseBasePriorityPrivilege raises scheduling priority and sets CPU affinity for other processes, SeIncreaseQuotaPrivilege overrides resource limits, SeLockMemoryPrivilege locks pages in physical memory, and SeAuditPrivilege writes events to the audit log — it is what KMES requires for userspace event emission.

SeProfileSingleProcessPrivilege attaches perf_event_open() to a specific other process. It respects PIP dominance, and own-task profiling requires nothing. SeSystemProfilePrivilege covers system-wide profiling — per-CPU events, all-task sampling, kernel-mode events — and does not respect PIP at the per-sample level, since system-wide samples include PIP-protected tasks. It is an operator-class privilege.

Those two and SeLoadDriverPrivilege share one mapping: CAP_PERFMON is satisfied by any of the three, and every one the caller holds is marked used. The two profiling privileges are otherwise disjoint tiers.

3.4.2.4 Network #

PrivilegeBitMaskEnforcement
SeBindPrivilegedPortPrivilege630x8000_0000_0000_0000Kernel standalone

Binds TCP and UDP ports below 1024, mapped through CAP_NET_BIND_SERVICE. A custom Peios privilege, retaining the Linux convention as defence in depth.

3.4.2.5 Directory and domain operations #

PrivilegeBitEnforcement
SeSyncAgentPrivilege26Application-level
SeEnableDelegationPrivilege27Application-level
SeMachineAccountPrivilege6Application-level

SeSyncAgentPrivilege reads every object in the directory regardless of per-object permissions, for AD replication agents. SeEnableDelegationPrivilege marks a principal as trusted for delegation. SeMachineAccountPrivilege adds computer accounts to the domain. None has a kernel definition, which is consistent with their being application-level — but none has a userspace definition in the tree either, so at present nothing anywhere enforces them.

3.4.2.6 Reserved #

Allocated for format compatibility so that tokens from Active Directory environments carry them without information loss. None is defined in the kernel and none has an enforcement point.

PrivilegeBitReservation rationale
SeCreatePagefilePrivilege15Absorbed into SeTcbPrivilege.
SeCreatePermanentPrivilege16No Linux equivalent.
SeSystemEnvironmentPrivilege22Gated by descriptors on efivar files under FACS.
SeUndockPrivilege25Server operating system.
SeCreateGlobalPrivilege30Peios has no per-LogonSession object namespaces.
SeTrustedCredManAccessPrivilege31Reserved for future secrets infrastructure.
SeIncreaseWorkingSetPrivilege33Linux does not gate memory residency hints.
SeTimeZonePrivilege34Linux does not gate timezone changes.

3.4.2.7 Unallocated and unnamed bits #

SeCreateJobPrivilege is allocated bit 62 for submitting supervised jobs through JFS. No kernel definition exists for it. The bit is nevertheless included in the boot SYSTEM token's privilege set, which covers bits 2–35 together with 62 and 63, so the SYSTEM token holds bit 62 present and enabled with no name attached to it and no gate that consults it.

Nothing validates a privilege mask against the allocated set. Token creation checks only that the enabled set is a subset of the present set, and adjustment accepts any bit index from 0 to 63. Bits 0, 1, and 36–61 — positions the catalogue does not allocate at all — can therefore be set at creation and disabled or removed afterwards without error, and are simply inert.

3.4.2.8 Default grants #

SeChangeNotifyPrivilege is granted to every principal, as an authd policy decision rather than a kernel one: the issuer's floor grants it to Everyone. SeCreateSymbolicLinkPrivilege is not granted by default despite being the other traditional default-grant privilege — authd deliberately omits it from the floor, and no shipped seed grants it. Either can be removed from a specific token by FilterToken.

3.5.1 Impersonation Levels

Peios / Advanced Peios / PKM / KACS / Impersonation

Impersonation lets a server thread temporarily assume a client's identity, so that access control decisions on that thread evaluate the client's token instead of the server's.

The client controls how far its identity can travel by setting an impersonation level on the connection before it is established, and the server cannot escalate beyond the level the client chose. There is no API that bypasses that choice.

Anonymous. The server cannot identify the caller at all. The connection carries no identity information: both token inspection and impersonation yield a token whose user SID is Anonymous (S-1-5-7), which carries Everyone as an enabled group and does not carry Authenticated Users.

Identification. The server can identify the caller — read SIDs, query groups, inspect privileges — but cannot act as them. An Identification-level token is barred from AccessCheck against resources: a server thread impersonating one and attempting to open a file simply fails the check.

Impersonation. The server can act as the caller for all local operations, including ones that cross local IPC boundaries. If service A impersonates client B at this level and connects to local service C, C sees B's identity — identity cascades freely across local services. This is the default.

Delegation. Locally identical to Impersonation. The distinction activates at the network boundary, where a Delegation-level token carries authorization for the server to forward the client's identity to services on other machines through Kerberos. KACS enforces the level; authd is what acts on it.

The level is set by the client through a KACS syscall on the socket before connect(), and defaults to Impersonation.

3.5.2 Impersonation Gates

Peios / Advanced Peios / PKM / KACS / Impersonation

When a server thread attempts to impersonate a client's token, two independent checks decide whether it proceeds at the requested level. Both have to pass. If either fails the effective level is reduced to Identification — the movement is only ever downward.

Both gates are evaluated against the server's primary token (real_cred), never its effective token. A server already impersonating another client has its gates judged against its own service identity, so a previous impersonation cannot influence the next one.

3.5.2.1 The identity gate #

The identity gate asks whether this server may impersonate this particular user's identity. Impersonation at Impersonation or Delegation level is permitted if either of two conditions holds.

Same user, same restriction status — the server's primary token and the client's token carry the same user SID, and both are restricted or both unrestricted.

SeImpersonatePrivilege — the server's primary token holds it, enabled.

If neither holds, the level is silently capped to Identification. No error is returned: the call succeeds, and the resulting token is merely at Identification level.

There is one hard denial. A restricted server impersonating an unrestricted client of the same user is rejected outright with -EPERM rather than capped, because that is precisely how a sandboxed process would escape by impersonating its parent's unrestricted token. The reverse direction, unrestricted server to restricted client, is a harmless downgrade and takes the ordinary cap-to-Identification path.

MS-DTYP includes a third condition — an origin LogonSession check letting the session that created a token impersonate it without the privilege. KACS drops it. A service needing to impersonate a different user holds SeImpersonatePrivilege, and there are no hidden paths.

3.5.2.2 The integrity ceiling #

The integrity ceiling asks whether the client's token sits at an integrity level the server is allowed to assume. To act at Impersonation or Delegation level, the client token's integrity level has to be less than or equal to the server primary token's. A Medium-integrity server can impersonate Low or Medium clients; against a High-integrity client the level caps to Identification.

The installed token may keep the client's literal integrity label as identity metadata after the cap, but that preserved label authorizes nothing, because Identification-level tokens are barred from AccessCheck entirely.

The ceiling exists because MIC evaluates the effective token's integrity level for tokens that can act. Without it, a server could impersonate a higher-integrity token and gain write access to higher-integrity objects — integrity escalation through impersonation.

The ceiling is enforced unconditionally, regardless of privilege. SeImpersonatePrivilege bypasses the identity gate and never the ceiling. MS-DTYP allows the privilege to bypass every check including this one; KACS does not, because mandatory_policy is immutable here (§3.2.2) and MIC is consequently a real boundary. Letting a privilege punch through would give back exactly what that immutability buys.

3.5.2.3 Composition #

The two gates are independent, both are evaluated, and the effective level is the minimum any constraint permits: start from the level the client set on the socket, cap to Identification if the identity gate fails, cap to Identification if the integrity ceiling fails, and the result is the effective impersonation level.

3.5.3 Impersonation Lifecycle

Peios / Advanced Peios / PKM / KACS / Impersonation

3.5.3.1 The sequence #

The client connects. It optionally sets the maximum impersonation level through a syscall — the default is Impersonation — and calls connect(). The kernel's LSM hook fires on the Unix stream connection.

Identity is captured. The hook examines the client thread's effective credential together with the socket's maximum level. At Anonymous, a token whose user SID is S-1-5-7, whose enabled groups include Everyone, and which does not carry Authenticated Users is stored on the socket's LSM blob, and the client's real identity is never recorded. At Impersonation or Delegation, the thread's effective token is stored — and if the connecting thread is itself impersonating, the impersonated identity is what flows through, which is how identity cascades across local services. At Identification, the effective token is stored but tagged at that level.

The server impersonates by calling kacs_impersonate_peer with the connection fd. The kernel retrieves the stored token, evaluates both gates against the server thread's primary token, computes the effective level, and constructs a new credential carrying the impersonation token at that level.

Access control follows the impersonation token. Every subsequent AccessCheck on the thread evaluates it, and MIC uses its integrity level. PIP continues to read the PSB, unchanged.

The server reverts with kacs_revert(), restoring the thread's credential to real_cred and its service identity with it.

3.5.3.2 Anonymous #

Any thread may impersonate the Anonymous identity without passing either gate. Assuming Anonymous is always a downgrade — the token has no access beyond what is explicitly granted to S-1-5-7 or Everyone — so no privilege is needed, no identity gate runs, and no integrity ceiling applies.

Anonymous tokens carry the Anonymous SID as the user SID, no privileges, and Untrusted integrity. The socket path constructs that minimal shape rather than preserving any part of the caller's real identity.

3.5.3.3 Double impersonation #

A thread already impersonating that calls kacs_impersonate_peer again causes the kernel to revert internally and then re-impersonate. Because the gates are evaluated against the primary token, the previous impersonation has no bearing on the new one.

3.5.3.4 Interaction with MIC and PIP #

MIC reads the effective token for tokens permitted to act, which is safe precisely because the integrity ceiling makes acting impersonation safe. A thread cannot act at Impersonation or Delegation level with a client token whose integrity exceeds the server primary token's. When the ceiling caps the level to Identification, the installed token may still preserve the client's literal label as metadata, but it authorizes no resource access because Identification-level tokens are barred from AccessCheck. An acting impersonation token therefore preserves or lowers the server's integrity, and a higher literal label can only ever exist on a non-acting Identification-level token.

PIP reads the PSB, because there is no equivalent ceiling for it. PIP operates on pip_type and pip_trust, which are orthogonal to integrity level. Reading them from the effective token would let a process impersonate a token carrying higher PIP values and acquire protection it has not earned.

3.5.3.5 SeImpersonatePrivilege #

The privilege permits a service to impersonate arbitrary clients — those with different user SIDs. Without it a process can impersonate only tokens matching its own user SID and restriction status.

It is checked against the server's primary token, so a thread already impersonating one client is judged on its real service identity. It has to be enabled at the moment of the call. And it bypasses the identity gate only, never the integrity ceiling.

3.5.3.6 Delegation and the network boundary #

Locally, Impersonation and Delegation behave identically. The distinction activates at the network boundary, where a Delegation-level token carries authorization for Kerberos credential forwarding to services on other machines.

KACS tracks the level on the token and the socket, and authd checks it when it needs to perform Kerberos authentication for an impersonating thread. KACS itself has no Kerberos awareness and no network awareness: the level is a flag that authd interprets.

3.5.3.7 Supported transports #

Socket-based impersonation through kacs_impersonate_peer works on two socket types. SOCK_STREAM is the connection-oriented byte stream, and SOCK_SEQPACKET is connection-oriented with message boundaries and uses the same identity capture model. Both follow the same lifecycle: capture at connect(), impersonate, revert.

Three transports do not support it. SOCK_DGRAM is connectionless, so identity would arrive as per-message credentials — a different model that is not part of this syscall surface; datagram sockets create no KACS peer token. Sockets from socketpair() are pre-connected and unnamed, and while possession of the fd authorizes use of the channel, no peer-token snapshot is installed. Pipes and FIFOs have no peer credential mechanism at all.

For all of these, the universal fallback is explicit token fd impersonation through KACS_IOC_IMPERSONATE, which works regardless of how the token fd was obtained — socket-based capture, an SCM_RIGHTS transfer, kacs_open_peer_token, or any other path.

3.6 Binary Signature Verification

Peios / Advanced Peios / PKM / KACS

Binary signing is the foundation of PIP trust determination and of Library Signature Verification. The kernel verifies cryptographic signatures on executable files to establish their trust level, and signing is the only mechanism by which a binary acquires PIP protection — there is no runtime API that can confer it.

This section describes verification. The signature format itself, and what a signer has to produce, are specified in PSPK's Binary Signing and PIP chapter. The kernel only ever verifies; it holds no private key and contains no signing primitive.

3.6.1 The key table #

The kernel carries its verification keys in a dedicated data section of the kernel image, as an array of 1960-byte entries — a raw 1952-byte ML-DSA-65 public key, then a u32 little-endian PIP type, then a u32 little-endian PIP trust — terminated by an all-zero entry.

Before any cryptographic work, the whole table is walked and validated. A table with no all-zero terminator is rejected with EINVAL, and so is a table containing any entry whose tier is not exactly Protected (512) with PeiosTcb trust (8192). Both rejections fail every verification on the system, which is what makes the single-tier constraint absolute rather than conventional: a key at any other tier does not merely fail to be honoured, it disables signing entirely.

The consequence is that a verified binary is always Protected/8192. The Isolated type (1024) is reserved and unreachable, and the multi-key, multi-tier model the table layout anticipates would need a change to the validator, not merely an added key.

A build configured for KUnit compiles in a different, hard-coded key at the same tier, taken from the test vector header. Such a kernel trusts a publicly known key.

3.6.2 Finding a signature #

Verification begins by recording the file's current size. Everything that follows is bounded by that snapshot, and the size is re-read before the attempt returns — a file that changed size mid-verification invalidates the whole result.

The first four bytes decide the path. A file matching \x7fELF takes the ELF path; a file shorter than four bytes, or with different magic, goes straight to the xattr.

On the ELF path the kernel parses the header, locates the section header table and the section-name string table, and scans sections in index order for one named exactly .peios.sig. The match is over all eleven bytes including the terminating NUL, so a longer name with that prefix does not match.

Finding the section commits the ELF path. The moment a section header with that name is found, the xattr is no longer consulted — whatever happens next. A wrong section type, a size other than 3310, a range outside the file, an allocation failure, a short read, a bad version byte, or a hash failure all yield "unsigned" rather than falling back. This is deliberate: without it, an attacker could craft a malformed ELF section to force fallback to whichever path they could more easily control.

Several structural ELF failures commit the path too, before any .peios.sig section has been seen: a file shorter than an ELF header, a class other than ELFCLASS64, a byte order other than little-endian, an unexpected ELF version, a section header entry size other than 64, an absent or out-of-range section-name string table index, and a section header table or string table lying outside the recorded size. A 32-bit or big-endian ELF therefore cannot carry an xattr signature at all — it is committed to the ELF path and then fails on it. The one structural case that does not commit is e_shnum == 0, so an ELF with no section headers falls through to the xattr normally.

The xattr path reads security.peios.sig and requires exactly 3310 bytes; any other size is treated as unsigned. The read bypasses the LSM xattr hooks, so FACS does not mediate the verifier's own read of the signature.

3.6.3 Hashing and verification #

The message signed is a 32-byte SHA-256 content hash, computed differently depending on where the signature was found. For an ELF section source, the hash covers the file with the section's contents replaced by zeros — the Elf64_Shdr entry describing it is hashed verbatim, along with everything else. For an xattr source the hash covers the entire file with no exclusions, and that applies to ELF files reaching the xattr path as well as to non-ELF ones. Hashing proceeds in 4 KB chunks, with the zero run emitted in SHA256_BLOCK_SIZE pieces.

The kernel then verifies the 3309-byte signature against each key in the table, in order, returning on the first success. There is no key identifier in the blob, so key selection is exhaustive trial and the cost is one ML-DSA verification per key. The trust tier is a property of which key verified, never of anything the signer encoded.

Verification uses the kernel crypto signature API with the mldsa65 algorithm. That API exposes no context parameter, so the empty FIPS 204 context is structurally guaranteed rather than checked — a signature made under a non-empty context simply fails to verify.

3.6.3.1 When verification cannot be performed #

"Did not verify" and "could not be verified" are different answers and are kept apart. The per-key verifier is tri-state: verified, did not verify, or a negative errno meaning the check could not be made — an unavailable ML-DSA transform, or a key the transform will not accept.

A negative stops the search rather than trying the remaining keys: the failure is in the machinery, and every remaining key would meet the same one. It then propagates out of the exec path, which refuses the exec with EACCES.

That asymmetry with the ordinary unsigned path is the point. An unsigned binary runs with no integrity label, which is legitimate. Treating an unverifiable one the same way removes PIP from every process the system executes, and the result is indistinguishable from a correctly working system that has no signed binaries — so nothing surfaces it until signing is deployed, at which point it looks like the signing rollout broke something.

On the LSV path the same condition denies the mapping, which already fails closed.

3.6.3.2 The boot-time probe #

A late_initcall allocates the transform once and, if it cannot, emits pr_err and a KACS_SIGNING_CRYPTO_UNAVAILABLE KMES event carrying the errno. The condition is then visible at boot rather than inferred from every process running without an integrity label.

It cannot refuse to start, and two things rule that out rather than one:

  • At LSM init the algorithm is not yet registered, so crypto_alloc_sig returns ENOENT on every boot. A probe there would fire always.
  • A non-zero return from an LSM's init function is only WARN'd (security/lsm_init.c, lsm_init_single). The hooks are never added, so "refuse to initialise" means running with no KACS at all — worse than the failure it would be preventing.

Enforcement therefore lives at exec, where refusing one exec is recoverable in a way losing the integrity boundary is not.

The probe is not IS_ENABLED(CONFIG_CRYPTO_MLDSA). That option is an unconditional select under SECURITY_PKM, which is a bool, so a config test would always pass and catch nothing. Only calling the allocator sees the ordering failure.

3.6.4 PIP determination at exec #

At execve() the kernel looks up and verifies the signature as above. A verified binary takes pip_type and pip_trust from the matched key; no signature, an invalid or unstable one, a bad signature, or no matching key all yield None/0.

Exec proceeds in every case where the question could be answered. PIP is additive protection, not an execution gate: it determines trust level, not permission to run. The one exception is a signature that could not be verified at all, described above, which refuses the exec — because there the question was not answered, so there is no basis on which to assign a trust level. An attacker who replaces a signed binary's signature with garbage costs it PIP protection but can still execute it, subject to FACS. The asymmetry with LSV — which does block unsigned libraries — is intentional. Exec is permissive; mmap(PROT_EXEC) is restrictive.

Determination is transactional with exec success, in three phases. The pending value is cleared at the top of every bprm_creds_from_file invocation, staged into the task's security blob, and committed to the process state only from bprm_committed_creds. An exec that fails between staging and commit leaves the process state untouched, and the stale pending value is cleared by the next exec or at task teardown.

Because the hook fires once per binfmt iteration and each iteration re-stages, a #! script's PIP comes from the interpreter — the last file processed — not from the script. A TCB-signed interpreter runs at TCB level whatever script it executes. Symlinks need no special handling either: the LSM hooks receive the already-resolved target file, so a symlink inherits its target's level by construction.

Once committed, the values are fixed for the lifetime of the process image, inherited at fork, and re-derived at the child's exec.

3.6.5 Content pinning #

A binary that verifies to a nonzero tier has its backing inode pinned as KACS-verified executable content before the exec result is committed, and LSV pins on success too, before allowing the mapping. Pinning closes the gap between verifying one byte image and modifying the same inode afterwards.

If pinning fails at exec, the PIP result is downgraded to None/0 rather than the exec being failed or the tier being kept — the process runs unprotected. Under LSV a pin failure denies the mapping instead.

A pinned inode rejects in-place content mutation even when the size would be preserved: ordinary, positioned and append writes; ftruncate() and pathname truncate(); and every fallocate mode, including allocation-only ones, because the pin check runs before and independently of the mode-support test. File ioctls that mutate content, ranges, or allocation are rejected, and so are ioctls the kernel cannot classify — unknown ioctls fail closed on a pinned inode. Mutation or removal of the security.peios.sig xattr is rejected as well.

The pin is conservative and one-way. It is set once and cleared only when the inode is allocated or freed, never while the inode is live. Updating verified executable content therefore means replacing the inode — write a new file and rename() over it — rather than modifying it in place.

Unsigned, invalid, unstable, bad-signature and no-match attempts never pin.

3.6.6 Library Signature Verification #

With the lsv mitigation enabled, mmap() with PROT_EXEC on a file-backed mapping verifies the backing file. An unsigned file, an invalid or unstable one, a bad signature, or no matching key all deny the mapping with EACCES. On success the library's tier is compared against the loading process's: the image has to dominate the process, so a Protected/PeiosTcb process can load only PeiosTcb-or-above libraries. With one key in the table this reduces to "is it signed with the TCB key?"

The hash covers the entire file, not the mapped region — the whole file is read and hashed even when only part of it is being mapped, so the signature covers code in sections this particular mapping does not touch.

mprotect() adding PROT_EXEC to a mapping that was not already executable runs the same checks in a fixed order: WXP, then TLP, then LSV. Anonymous mappings have neither a path nor a signature, so TLP and LSV skip them and only WXP applies.

Enabling lsv on a running process also re-validates its existing executable mappings before the bit is committed (§3.3.2), so the mitigation cannot be turned on over already-mapped unsigned code.

3.6.7 Revocation #

There is none. A signed binary later found to be malicious cannot be invalidated short of removing it from the filesystem or replacing the kernel image with a different key. No hash blocklist, no per-key revocation, and no revocation state of any kind exists.

3.6.8 Interaction with other mechanisms #

WXP is orthogonal: it prevents pages being writable and executable at once, while LSV prevents unsigned executable pages. A process with both can execute only signed code in read-only pages.

FACS runs independently. A signed binary in a directory the caller cannot reach is still unreachable — the open is denied before signing is consulted. Signing determines the PIP level of a binary that is already being executed; it never grants access to one.

PIP object protection consumes the result: once a process carries a tier from its binary's signature, trust labels on objects are enforced through the AccessCheck pipeline against the pip_type and pip_trust held in the PSB (§3.7).

3.7 Process Integrity Protection

Peios / Advanced Peios / PKM / KACS

PIP protects objects from insufficiently trusted processes through trust label ACEs evaluated inside AccessCheck (§3.8.7). It also protects processes — their memory, their execution, their metadata — from other processes, which is what this section covers.

Every process-to-process operation passes two independent checks, and both have to succeed.

The process descriptor check is an ordinary AccessCheck of the caller's token against the target's process descriptor (§3.3.3). It answers who may operate on the process, and it is where per-operation granularity lives — different rights for signals, memory, and metadata.

The PIP dominance check is a direct comparison of the two processes' PSB fields. It answers what trust level is required. It does not use AccessCheck, does not read a descriptor, and does not involve the DACL pipeline at all: it is a standalone arithmetic test.

The two are complementary. Object PIP protection stops a non-dominant process opening authd's private key file; process PIP protection stops the same process reading the key straight out of authd's memory with ptrace, or killing authd with a signal.

3.7.1 The dominance test #

pip_dominates(caller_psb, target_psb) -> bool:
    if target_psb.pip_type == None:
        return true   // Unprotected target — any caller dominates.
    return caller_psb.pip_type  >= target_psb.pip_type
       AND caller_psb.pip_trust >= target_psb.pip_trust

Both axes are plain unsigned integers compared numerically, not closed enumerations — the dominance layer would happily order tiers the signing layer cannot currently produce (§3.3.2). The early return for an unprotected target is what keeps ordinary processes universally accessible whatever trust values a caller happens to carry.

Dominance is binary. A caller that does not dominate has no process access at all, whichever operation was attempted; the descriptor provides the granularity and PIP is the all-or-nothing gate above it.

That asymmetry with the object model is deliberate. Object access has natural categories — read, write, execute. Process access does not: a caller that can ptrace a process can read its memory, inject code, and effectively become it. Partial process access is not a meaningful boundary.

3.7.2 SeDebugPrivilege #

SeDebugPrivilege bypasses the descriptor check and never the dominance check. This holds at every enforcement point, and it is enforced structurally in two independent places: inside the descriptor evaluation a PIP-label denial short-circuits before the debug rescue is reached, and the standalone dominance test runs afterwards with no privilege escape of any kind.

3.7.3 Where dominance is enforced #

ptrace, in every mode. A single successful attach is equivalent to full compromise of the target — read and write memory and registers, single-step, inject signals, redirect execution — so a non-dominant caller is refused whatever the mode. The Linux __ptrace_may_access path is patched to return the LSM's answer directly, so native UID and capability rules no longer grant where KACS denies. Direct memory access through /proc/<pid>/mem, process_vm_readv and process_vm_writev routes through the same check, so one hook covers every memory-access vector. This is what makes in-memory secrets genuinely unreachable: a compromised administrator cannot read an HSM daemon's key material out of its address space.

PTRACE_TRACEME inverts the roles — the nominated tracer is the subject and the caller is the target — and requires PROCESS_VM_WRITE on the caller's own descriptor plus dominance by the nominated tracer.

Mode combinations are validated: the mutually exclusive PIDFD_OPEN, GETFD and PROC_QUERY flags cannot be combined, and a request that is neither a read nor an attach, or claims to be both, is rejected as malformed.

Signal delivery, uniformly regardless of signal type. Lifecycle management of PIP-protected processes therefore has to go through a process that dominates them — in practice peinit, which runs at the highest tier.

Signalling within the same process security state is not a boundary operation, and the exemption is structural: it is a pointer comparison of the two processes' security state, tested before any descriptor or dominance evaluation. It does not depend on the default descriptor's self ACE, which is what makes raise(), abort() and pthread_kill() work for restricted and confined tokens whose AccessCheck against their own descriptor would fail.

Multi-target sends are evaluated per target by Linux's own iteration, so the signal reaches the permitted subset and the call succeeds if at least one delivery happened. POSIX's same-session SIGCONT exception is deliberately absent — the patched check_kill_permission returns the KACS answer before reaching the switch that carried it — so SIGCONT needs PROCESS_SUSPEND_RESUME plus dominance like every other job-control signal, whatever the session.

Kernel-originated signals bypass the whole check, as described in §3.3.3.

pidfd_open(), a boundary information query rather than a memory or attach operation, needs PROCESS_QUERY_LIMITED plus dominance. pidfd_getfd() maps to PROCESS_DUP_HANDLE plus dominance — extracting a descriptor from another process is a boundary crossing in its own right.

/proc metadata. Entries that are already ptrace-gated or memory-open-gated are covered automatically by the ptrace hook. The rest would leak information about a protected process, so the non-ptrace-gated entries carry their own descriptor requirement plus dominance; §3.3.3 gives the mapping. Entries stricter than a metadata query, such as /proc/<pid>/stack, keep their native hardening and are not brought under the metadata rule.

Denying access prevents reading inside /proc/<pid>/ but does not hide the PID: the directory name is still visible through getdents. Visible-but-inaccessible is the accepted position.

/proc is not FACS-managed — it is a virtual filesystem with no backing store and no xattrs — so enforcement there happens through direct kernel checks rather than an object-backed FACS path.

Capability metadata. capget() on the current process, or on a thread sharing its security state, is not a boundary operation. Against another process it is a detailed information query needing PROCESS_QUERY_INFORMATION plus dominance.

Resource limits, scheduler and placement. Read-only prlimit needs PROCESS_QUERY_INFORMATION plus dominance; a limit change needs PROCESS_SET_INFORMATION plus dominance. setpgid() needs PROCESS_SET_INFORMATION; getpgid() and getsid() need PROCESS_QUERY_LIMITED; the scheduler, affinity and I/O priority queries need PROCESS_QUERY_INFORMATION; and the memory-placement mutations Linux routes through task_movememory need PROCESS_SET_INFORMATION. Setting nice, scheduler parameters and I/O priority all need PROCESS_SET_INFORMATION too. Self-directed versions of all of these are not boundary operations and skip both checks.

CPU affinity is per-thread, so changing the caller's own thread or a sibling in the same process is not a boundary operation. Changing a thread in a different process needs PROCESS_SET_INFORMATION plus dominance plus SeIncreaseBasePriorityPrivilege — and the privilege is checked and marked used before the descriptor and dominance call, so the SeDebugPrivilege rescue cannot substitute for it. KACS does not relax the kernel's native affinity validity rules: an invalid or disallowed mask still fails.

Token opens. kacs_open_process_token and kacs_open_thread_token need PROCESS_QUERY_INFORMATION plus dominance. Reading a process's security identity is as sensitive as reading its memory.

Performance monitoring. Target-specific perf_event_open() on another process can leak execution timing, branch prediction behaviour, cache access patterns and instruction traces — side channels that reveal cryptographic keys. It needs SeProfileSingleProcessPrivilege plus PROCESS_QUERY_INFORMATION plus dominance, with the privilege again checked first so the debug rescue cannot stand in for it. Own-task profiling is not a boundary operation and needs no privilege. System-wide profiling, pid == -1, samples every task on a CPU including protected ones, so it needs the operator-class SeSystemProfilePrivilege. Cgroup perf mode stays under Linux's native model. Because the target task is resolved after the stock security_perf_event_open hook fires, this rule is enforced through a target-resolved syscall patch rather than that hook — there is no security_perf_event_open registration at all.

3.7.4 The PeiosTcb floor on kernel-initiated execs #

One place PIP gates execution rather than merely labelling it. It is not a dominance test — there is no caller to compare against — but a threshold: a binary the kernel execs on its own behalf must carry at least PeiosTcb trust, or the exec fails with EACCES.

The case that motivates it is request_module(). When the kernel needs a module it does not have — get_fs_type() on a mount, socket() for an unknown protocol family, the crypto API resolving a name — it spawns CONFIG_MODPROBE_PATH as a usermode helper and runs it at the kernel's own authority. That path is a writable sysctl, which makes redirecting /proc/sys/kernel/modprobe a classic escalation: point it somewhere attacker-controlled and the next module request executes it with the kernel behind it. The floor makes the redirection worthless on its own, because the attacker would also have to produce a TCB-signed binary.

This is the only exec KACS refuses on integrity grounds. Everywhere else an unsigned binary runs and simply carries no tier, because a requesting process's own authority bounds what it can do. A kernel-initiated exec has no such process behind it, so there is no lesser authority to fall back to.

The floor also refuses when no tier could be derived at all, not only when one was derived and graded too low. Treating "could not establish trust" differently from "is not trusted" would leave the check bypassable by whatever prevented the derivation from running.

Nothing upstream identifies such an exec by the time the LSM sees it. The helper child is created by user_mode_thread(), so it never carries PF_KTHREAD — kernel_execve() rejects kernel threads outright — and security_kernel_module_request() fires in the requesting task, before the child exists. So kernel/umh.c is patched to mark the child after commit_creds() and before kernel_execve(), and the mark is read in the bprm_creds_from_file hook. It lives in the KACS task blob rather than costing a task_struct flag.

The mark is never cleared. A helper that re-execs — an interpreter for a #! helper — stays under the floor rather than escaping it on the second exec; the task exists only to be that helper.

A refusal emits kacs_exec with reason umh-not-tcb, so it is visible rather than presenting as an unexplained module-load failure.

3.7.5 Raw physical memory #

A process able to read /dev/mem could map any process's physical pages and bypass virtual memory protections entirely, PIP included.

The defence is CONFIG_STRICT_DEVMEM, which restricts /dev/mem to I/O regions and denies RAM access. It is not merely a recommended build option: the LSM refuses to initialise unless both CONFIG_STRICT_DEVMEM and CONFIG_MODULE_SIG_FORCE are enabled, so a kernel configured without them does not boot with KACS at all. The same initialisation gate refuses to coexist with SELinux, AppArmor, Smack, TOMOYO or the BPF LSM.

Placing a restrictive descriptor on /dev/mem and /dev/kmem as a secondary defence is not implemented; nothing in the kernel handles those paths specially.

3.7.6 Limits of the guarantee #

PIP operates inside the kernel's trust boundary, and three things sit outside it.

Kernel compromise. A loaded module runs with unrestricted access to all memory and kernel structures. PIP is enforced by the kernel, so a compromised kernel voids it, and SeLoadDriverPrivilege is the ceiling of every guarantee here. CONFIG_MODULE_SIG_FORCE is hard-required as noted above, and module signing is itself ML-DSA-65. Stripping SeLoadDriverPrivilege from every token but peinit's and the device manager's is the other half of that defence, and is policy rather than kernel behaviour — nothing in the kernel strips it. The device manager holds it because loading drivers for the hardware that appears is its job; module signature enforcement is what keeps the privilege from meaning more than "load a module Peios built".

Hardware access. DMA-capable devices read and write physical memory directly, bypassing the CPU's virtual memory system. An IOMMU mitigates this, and configuring one is a kernel responsibility outside KACS.

Hypervisor-level isolation. PIP does not offer guarantees equivalent to hypervisor-based memory isolation. The threat model ceiling is a non-compromised kernel.

3.7.7 Impersonation #

PIP reads the PSB, never the effective token. A Protected service impersonating a client still evaluates its own PSB for every process boundary check, so the client's identity has no bearing on it. In the other direction, an unprotected process impersonating a token created for a protected one gains nothing — its PSB is still None. Since nothing constrains the PIP dimensions the way the integrity ceiling constrains impersonation (§3.5.3), the PSB is the only safe source.

3.7.8 Coredumps #

A crashing PIP-protected process is a potential secret leak, so its dumps must not be readable by non-dominant processes.

The implemented strategy is to disable them: a process with a nonzero pip_type has its dumpable flag cleared at exec, and prctl(PR_SET_DUMPABLE, 1) is refused for as long as the process remains protected. Requests that keep or make it non-dumpable are allowed, and no alternative dumpable-setting path is left ungated. If a later exec assigns None/0, normal Linux exec-time dumpability rules apply to the new image.

The alternative — a signed, high-trust crash handler receiving dump data from the kernel and writing it under a restrictive descriptor, so that diagnostics survive without bypassing isolation — is not implemented. The two are not mutually exclusive; disabling dumps is the minimum viable position.

3.8.1 AccessCheck Overview

Peios / Advanced Peios / PKM / KACS / AccessCheck

AccessCheck is the function that connects tokens to security descriptors. Given a token — who is asking — a descriptor — what the rules are — and a desired access mask — what they want to do — it returns a verdict: which of the requested rights are granted, and whether the request as a whole succeeds.

It is a pipeline, evaluating several layers of policy in a fixed order. Each layer can grant or constrain access and the layers interact: integrity policy can block what the DACL would allow, privileges can override what the DACL denied, and confinement can revoke what privileges granted. The order is not incidental — it is the specification.

3.8.1.1 Two API variants #

AccessCheck is the common case. It returns the granted access mask, whether the request succeeded, a continuous audit mask derived from SACL alarm ACEs, and a CAAP staging mismatch flag. When an object type list is supplied, success requires every listed node to pass and the returned mask is the intersection across them. The staging mismatch flag is set when the staged scalar result differs from the effective scalar result, when any per-node staged grant differs from the effective per-node grant, or when staged auditing differs from effective auditing.

AccessCheckResultList is the per-property variant and requires an object type list. It returns a separate verdict for each node, so a denial on one property fails that property alone rather than the whole request. It returns the same continuous audit mask and staging mismatch flag, with the flag set when any node's staged granted mask differs from that node's effective granted mask, or when staged auditing differs from effective auditing. Directory services use it, because one operation there may touch several properties with independent access rules. Privilege-use auditing in this variant takes the same per-node view: a privilege counts as successfully used if its contributed bits survive on any node's final granted mask.

Both variants share one evaluation pipeline. Only the collection of results differs.

3.8.1.2 The three state values #

Every access check tracks three masks.

decided records which bits have been resolved. It enforces first-writer-wins within the DACL walk: once a bit is decided, no later ACE in the same walk changes its outcome. Pipeline layers that operate on top of the DACL result — restricted token intersection, confinement intersection, PIP revocation, CAAP intersection — may still revoke granted bits. Those layers narrow the result; they do not re-open decided bits for re-evaluation through the DACL.

granted records which bits resolved to yes. During the walk it is a subset of decided. Afterwards the later layers may remove bits from it, and the final value is what the caller receives.

privilege_granted records which bits in granted came from a privilege rather than from the DACL. It exists for two reasons: audit accuracy, so that privilege-granted access is distinguishable from DACL-granted access, and the restricted token merge, where privilege-granted bits are restored after the intersection so that privileges bypass the restricted pass. PIP may revoke privilege-granted bits.

With an object type list present, each node carries its own decided and granted pair.

3.8.2 The DACL Walk

Peios / Advanced Peios / PKM / KACS / AccessCheck

The DACL is an ordered list of ACEs, walked from first to last, comparing each ACE's SID against the calling token's identity. The governing principle is first-writer-wins: once a bit has been resolved, granted or denied, no later ACE changes the outcome for that bit.

An allow ACE whose SID matches the token grants the rights it carries that have not yet been decided, leaving already-decided bits untouched. A deny ACE whose SID matches denies its not-yet-decided rights — marking them decided but not granted — and likewise leaves decided bits alone.

3.8.2.1 SID matching #

An ACE's SID matches the token when it equals the user SID or a group SID on the token, subject to attribute filtering.

For allow ACEs, only groups that are enabled and not deny-only match. For deny ACEs, both enabled groups and deny-only groups match: a deny-only group always participates in deny matching whatever its enabled state. A group with neither SE_GROUP_ENABLED nor SE_GROUP_USE_FOR_DENY_ONLY participates in no matching at all.

The user SID follows the same rule — when user_deny_only is set on the token, it matches deny ACEs and not allow ACEs.

3.8.2.2 Skipping and mapping #

ACEs carrying INHERIT_ONLY exist purely to propagate to child objects and are skipped by the walk.

At the top of the walk each ACE's access mask is mapped through MapGenericBits using the same GenericMapping applied to the caller's request. The mapping works on a local copy — the ACE itself is never mutated. Mapping ACE masks at evaluation time is a deliberate departure from MS-DTYP, and it is what makes GENERIC_ALL work in central access policy recovery ACEs (§3.8.8).

3.8.2.3 Absent and empty DACLs #

If the descriptor has no DACL — SE_DACL_PRESENT unset — every valid right not already decided by an earlier pipeline stage is granted. The valid rights are bounded by MapGenericBits(GENERIC_ALL, mapping) rather than by a raw 0xFFFFFFFF.

If the DACL is present but holds zero ACEs, the walk grants nothing. The only access an owner gets in that case comes from the implicit rights mechanism below.

3.8.2.4 Owner implicit rights #

By default the owner of an object receives READ_CONTROL and WRITE_DAC whatever the DACL says. These are granted before the walk begins, as the first action inside EvaluateDACL, and because first-writer-wins governs the walk, no deny ACE encountered later can override them.

EvaluateDACL takes a skip_owner_implicit parameter. The confinement pass sets it, because confinement is an absolute intersection with no owner bypass.

The grant is suppressed entirely if any non-inherit-only access-control ACE in the DACL targets the OWNER RIGHTS SID (S-1-3-4). This is a pre-scan performed at the start of EvaluateDACL, before the main loop, and it checks only for the SID's presence — it does not evaluate any conditional expression on the ACE.

During the walk proper, S-1-3-4 is treated as an ordinary SID matching the owner, at both allow and deny polarity. It obeys the same rules as any other SID: an allow ACE matches only through an enabled, non-deny-only group, and not through the user SID of a user_deny_only token; a deny ACE matches through a group that is enabled or deny-only, and through the user SID unconditionally.

Note that the implicit grant above is a separate rule and remains presence-based. It is bounded by the pre-scan rather than by polarity.

The implicit grant is also bounded twice over: by the object type's valid rights, and by what has already been decided. A pre-decision from MIC, PIP or a privilege therefore suppresses it — a non-dominant owner does not receive WRITE_DAC through this route.

3.8.2.5 MAXIMUM_ALLOWED #

When the caller includes MAXIMUM_ALLOWED (bit 25), AccessCheck runs the full pipeline and returns the complete set of rights that would be granted. The bit is stripped from the desired mask before evaluation begins.

Two things change. The walk runs to completion with no short-circuit, and the returned mask is whatever the pipeline accumulated rather than being filtered to the requested bits.

MAXIMUM_ALLOWED can be combined with specific rights: MAXIMUM_ALLOWED | READ_CONTROL asks both "can I read the descriptor?" as a success or failure and "what else could I get?" as a mask. A pure MAXIMUM_ALLOWED request carrying no specific bits always succeeds.

Otherwise — when the desired mask is fully decided — the walk may stop early.

First-writer-wins applies to MAXIMUM_ALLOWED requests exactly as it does to targeted ones. MS-DTYP treats the two differently; KACS does not, which is what stops "what can I do?" and "can I do this?" disagreeing on a DACL that is not in canonical order.

3.8.3 Mandatory Integrity Control

Peios / Advanced Peios / PKM / KACS / AccessCheck

MIC is a mandatory constraint that restricts which rights the DACL is allowed to grant, along a vertical trust hierarchy. It is evaluated before the DACL walk, in the pre-SACL phase.

Every token carries an integrity level, and every object may carry a mandatory label — a SYSTEM_MANDATORY_LABEL_ACE in its SACL. MIC compares the two: a caller below the object's level is blocked from whole categories of access whatever the DACL says.

The default is no-write-up. A lower-integrity process can read and execute a higher-integrity object but cannot write to it, and the object's label may additionally block reads or execution for callers beneath it.

An object with no mandatory label ACE in its SACL — or no SACL at all — is treated as Medium integrity with no-write-up, so Low and Untrusted processes cannot write to unlabelled objects.

A caller whose level is greater than or equal to the object's label dominates it, and MIC pre-decides nothing: the DACL handles authorization normally.

3.8.3.1 What MIC does and does not touch #

MIC constrains what the DACL can grant. It does not revoke what privileges have already granted, because it mutates only decided and never touches granted or privilege_granted.

ACCESS_SYSTEM_SECURITY is outside its reach for a structural reason: the bits MIC can decide are bounded by MapGenericBits(GENERIC_ALL, mapping), which does not include it. The right is privilege-granted rather than DACL-granted, so MIC never blocks it. PIP is stricter and does revoke it for non-dominant callers — explicitly ORing it into the set of bits it can take away — which is the mechanism by which objects stay protected even from administrators.

SeRelabelPrivilege has one specific interaction: it lets the DACL grant WRITE_OWNER even when an integrity mismatch would otherwise block it, so a privileged administrator can take ownership of a higher-integrity object as the first step in modifying it. The bit granted this way is recorded under its own provenance and is deliberately not part of privilege_granted, so it is not restored after the restricted merge and is not preserved by the CAAP error escape hatch.

Enforcement is gated on the token's mandatory_policy: with NO_WRITE_UP set — the default — the rule applies, and with it clear MIC is effectively disabled for that token. The field is fixed at creation (§3.2.2), which is what makes MIC a boundary rather than a suggestion.

3.8.3.2 Labels #

An object's SACL may carry more than one mandatory label ACE. Only the first non-inherit-only one is used; inherit-only labels do not apply to the object carrying them.

The SID in a mandatory label ACE has the Mandatory Label authority (S-1-16) and exactly one sub-authority, and that sub-authority value is the integrity level, compared as an unsigned integer. Any S-1-16-X is therefore valid.

SIDLevelName
S-1-16-00Untrusted
S-1-16-40964096Low
S-1-16-81928192Medium
S-1-16-1228812288High
S-1-16-1638416384System

Peios tooling and authd use these five, but intermediate values such as S-1-16-2048 or S-1-16-8448 are valid and compared numerically, which is what allows Windows-originated descriptors carrying non-standard levels to be evaluated without translation.

A label ACE whose SID falls outside the S-1-16 authority — wrong identifier authority, or the wrong sub-authority count — is malformed, and so is one that is not a plain single-SID ACE. Either causes AccessCheck to reject the whole descriptor with an error rather than ignore the label.

3.8.3.3 Policy bits #

BitValueMeaning
SYSTEM_MANDATORY_LABEL_NO_READ_UP0x00000001Non-dominant callers receive no read-mapped rights from the DACL.
SYSTEM_MANDATORY_LABEL_NO_WRITE_UP0x00000002Non-dominant callers receive no write-mapped rights from the DACL.
SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP0x00000004Non-dominant callers receive no execute-mapped rights from the DACL.

Unknown bits in a label mask are ignored.

3.8.3.4 The algorithm #

EnforceMIC(ace, token, mapping, &decided):

    if not (token.mandatory_policy & NO_WRITE_UP):
        return

    token_dominates = (token.integrity_level >= ace.integrity_level)

    if token_dominates:
        return

    // Non-dominant: start with read + execute, strip per label policy.
    allowed = MapGenericBits(GENERIC_READ, mapping)
            | MapGenericBits(GENERIC_EXECUTE, mapping)

    if ace.mask & SYSTEM_MANDATORY_LABEL_NO_READ_UP:
        allowed &= ~MapGenericBits(GENERIC_READ, mapping)
    if ace.mask & SYSTEM_MANDATORY_LABEL_NO_WRITE_UP:
        allowed &= ~MapGenericBits(GENERIC_WRITE, mapping)
    if ace.mask & SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP:
        allowed &= ~MapGenericBits(GENERIC_EXECUTE, mapping)

    // READ_CONTROL and SYNCHRONIZE are always allowed regardless of the
    // object type's GenericMapping and the label's up-strip policy — a
    // non-dominant caller can always read the descriptor and synchronize
    // on the object. Applied after the strips because a file GENERIC_READ
    // mapping folds these bits in, so NO_READ_UP would otherwise take them.
    allowed |= READ_CONTROL | SYNCHRONIZE

    // SeRelabelPrivilege: let WRITE_OWNER through MIC.
    if token.privilege_enabled(SeRelabelPrivilege):
        allowed |= WRITE_OWNER

    all_bits = MapGenericBits(GENERIC_ALL, mapping)
    decided |= all_bits & ~allowed

3.8.4 Restricted Tokens

Peios / Advanced Peios / PKM / KACS / AccessCheck

A restricted token carries a secondary SID list, the restricting SIDs. The second pass runs whenever that list or the restricted device group list is non-empty, and AccessCheck evaluates the DACL twice.

The normal pass evaluates the DACL against the token's ordinary identity — user SID and group SIDs — exactly as usual. The restricted pass evaluates the same DACL again with SID matching drawn only from the restricting SID list; the token's normal groups are invisible to it. Conditional membership operators — Member_of, Member_of_Any, and their device variants — take the same restricted view, seeing only the restricting SIDs plus any virtual groups injected from that list.

The restricting SID list is presence-based. A SID participates whenever it appears in the list, and SE_GROUP_ENABLED and SE_GROUP_USE_FOR_DENY_ONLY on those entries are ignored both for restricted-pass SID matching and for restricted-pass non-device conditional membership. This matches Windows restricted-token behaviour, where restricting SIDs are always enabled for access checks.

Access is granted only for rights both passes agree on — the intersection. The restricting list acts as a ceiling: the principal can never receive more access than the restricting SIDs would independently justify.

3.8.4.1 Write-restricted tokens #

In the write-restricted variant the intersection applies only to write-category bits, and read and execute access comes from the normal pass alone. What counts as write is whatever the object type's GenericMapping maps GENERIC_WRITE onto.

3.8.4.2 Privileges bypass the restricted pass #

Rights granted by privileges are added back after the intersection. Privileges are system-level grants from security policy rather than from the object's DACL: token restriction reduces the identity-based access surface, and privilege-based grants are orthogonal to it. The bits restored are the post-PIP privilege-granted set together with the take-ownership grant.

3.8.4.3 Owner rights and virtual groups in the restricted pass #

The restricted pass evaluates owner implicit rights independently. If the object's owner SID appears in the restricting SID list, the pass grants READ_CONTROL and WRITE_DAC, subject to the same OWNER RIGHTS suppression pre-scan as the normal pass; if the owner SID is not a restricting SID, no implicit rights are granted.

Two virtual groups are injected on the same basis. S-1-3-4 (OWNER RIGHTS) is injected when the object's owner SID is among the restricting SIDs, and S-1-5-10 (PRINCIPAL_SELF) when self_sid is. This keeps the restricted pass consistent in its handling of these well-known SIDs rather than letting them leak the unrestricted identity.

Restricted device groups, when the token has them, are swapped in for the restricted pass so that Device_Member_of and its relatives evaluate against the restricted set rather than the unrestricted device groups.

3.8.5 Object ACEs and Property-Level Access

Peios / Advanced Peios / PKM / KACS / AccessCheck

An object ACE carries a GUID identifying the property or property set its rule applies to, which is what enables per-property access control on objects with internal structure — Active Directory objects, most obviously. An object ACE with no GUID, meaning ACE_OBJECT_TYPE_PRESENT is unset, behaves exactly like a basic ACE.

3.8.5.1 Object type lists #

To request property-level access the caller supplies an object type list: a tree of GUIDs representing the properties being asked for. Each node carries its own decided and granted pair and is resolved independently.

With no list supplied, object ACEs with GUIDs apply globally, as if they were basic ACEs. With a list supplied, every ACE that behaves like a basic ACE — ordinary basic ACEs, and object ACEs without an ObjectType GUID — applies to every node in the tree. An object ACE whose GUID does not appear anywhere in the supplied tree is silently skipped.

3.8.5.2 Propagation #

Decisions move through the tree in four ways.

Downward from grants. A grant on a property set flows to every attribute within it, each child node still applying first-writer-wins for itself.

Upward from grants. When every attribute within a property set has been granted the same right, that right propagates up to the set's node. The propagation is a per-bit intersection, so a right reaches the parent only if all siblings share it.

Upward from denials. A denial on an attribute propagates to every ancestor regardless of what its siblings hold, and siblings are themselves unaffected. Ancestors still apply first-writer-wins to the propagated bits, so a denial cannot overturn something an ancestor had already decided.

Downward from denials. A denial on a property set flows to every attribute within it, again subject to first-writer-wins.

3.8.5.3 PRINCIPAL_SELF #

PRINCIPAL_SELF (S-1-5-10) is a placeholder for the object's associated principal. An ACE targeting it matches the caller when the caller's token represents the same principal as the object, which the caller establishes by passing the object's principal SID as the self_sid parameter. With a null self_sid, PRINCIPAL_SELF ACEs match nothing.

It follows the ordinary deny-only rules: if the caller's matching SID is deny-only, PRINCIPAL_SELF matches deny ACEs but not allow ACEs.

3.8.5.4 Scalar and per-node results #

AccessCheck requires every node to pass, so a denial on any one property fails the whole request. AccessCheckResultList returns a separate verdict per node.

The scalar result AccessCheck returns is the root node's granted mask. That is equivalent to the intersection across all nodes, but by construction rather than by computation: upward denial propagation forces every descendant's denial into all of its ancestors' decided sets, which guarantees the root's granted mask is a subset of every node's.

3.8.5.5 Validation #

Object type lists are validated strictly, at parse time. A supplied list is non-empty; its first node is at level 0; there is exactly one level-0 node; there are no level gaps, meaning no node at level N+2 following one at level N; and no GUID appears twice.

These checks are stricter than MS-DTYP, which does not specify them. They exist because a duplicate GUID makes node lookup return the wrong node, and a level gap makes propagation undefined.

3.8.6 Application Confinement

Peios / Advanced Peios / PKM / KACS / AccessCheck

Confinement restricts a token's effective access to what is explicitly granted to its confinement identity. Even where the normal DACL walk grants access through the user SID or a group SID, the confinement pass intersects that with what the confinement SIDs would receive on their own, and revokes anything they cannot independently justify.

A confined token carries confinement_sid, its confinement identity; confinement_capabilities, the capability SIDs the application declares; and confinement_exempt, an escape hatch that skips confinement evaluation entirely.

The confinement SID set is confinement_sid together with every SID in confinement_capabilities. Capabilities are presence-based identities rather than ordinary ACE-matching groups: a capability SID participates whenever it is present, and disabling an entry or marking it deny-only does not remove it from the confinement identity.

The pass also injects two confinement-scoped virtual groups. S-1-5-10 (PRINCIPAL_SELF) is injected only when self_sid equals the confinement SID or one of the capability SIDs, and S-1-3-4 (OWNER RIGHTS) only when the object's owner SID does. Both apply to ACE SID matching during the confinement walk and to the conditional membership operators — Member_of, Member_of_Any, and their device and negated variants — evaluated during that pass.

3.8.6.1 An absolute boundary #

Confinement is not overridable. Privileges do not bypass it: the confinement merge takes no privilege-granted input at all, so backup, restore, SeTakeOwnershipPrivilege and SeSecurityPrivilege are alike unable to grant access the confinement check denies. Owner implicit rights are skipped entirely, because the pass runs with skip_owner_implicit set.

One thing does pass through: an object with a null DACL grants in the confinement pass exactly as it does in the normal pass. A null DACL means "no discretionary restrictions", and the confinement pass follows standard evaluation, granting all valid bits.

3.8.6.2 Strict confinement #

A normal confined token carries both ALL_APPLICATION_PACKAGES and ALL_RESTRICTED_APPLICATION_PACKAGES among its capabilities. Omitting ALL_APPLICATION_PACKAGES gives strict confinement: far fewer system objects grant to ALL_RESTRICTED_APPLICATION_PACKAGES, so the access surface is much narrower.

Strict confinement is not a separate kernel mode bit. It is derived purely from the SID set supplied at token creation — if ALL_APPLICATION_PACKAGES is absent, AccessCheck simply evaluates the remaining confinement SIDs. The kernel never synthesises it, and never rejects an otherwise valid confined token for carrying it. Deciding which capabilities a package token receives belongs to authd and policy tooling.

3.8.6.3 Consequences worth stating #

SACL access is unreachable. ACCESS_SYSTEM_SECURITY is only ever privilege-granted, and privileges do not bypass confinement, so a confined token cannot reach a SACL unless a confinement ACE grants the right outright.

OWNER RIGHTS is confinement-scoped. S-1-3-4 matches in the confinement pass only when the owner SID is part of the confinement SID set.

PRINCIPAL_SELF is isolated from user identity. S-1-5-10 is injected only when self_sid matches a confinement SID — the package SID or one of its capabilities — not when it matches the user.

Conditional expressions still see the full token. The confinement pass isolates ordinary SID matching to the confinement identity, but conditional expressions inside ACEs continue to evaluate against the user's real groups and claims. The two confinement-scoped virtual groups are the only exception, and they follow the confinement rules during conditional membership evaluation.

3.8.6.4 Ordering #

Confinement runs after the restricted token merge and after its privilege restoration. The order is load-bearing: privileges bypass the restricted pass but must not bypass confinement, and if confinement ran first the privilege restoration would resurrect bits that confinement had already blocked.

3.8.7 PIP in AccessCheck

Peios / Advanced Peios / PKM / KACS / AccessCheck

An object opts in to PIP protection by carrying a SYSTEM_PROCESS_TRUST_LABEL_ACE in its SACL. The ACE's SID encodes the required type and trust, and its access mask names exactly the rights a non-dominant caller may still have.

A caller that dominates — pip_type and pip_trust both greater than or equal to the ACE's — is unrestricted by PIP. A caller that does not dominate is limited to the ACE mask, and everything else is denied.

Unlike MIC, PIP has no default. An object with no trust label is unrestricted, reachable by any process whatever its PIP identity.

3.8.7.1 The label SID #

A trust label SID has the form S-1-19-{type}-{trust} — the Process Trust authority, exactly two sub-authorities. Both axes are compared numerically. The conventional type values are 0 (None), 512 (Protected) and 1024 (Isolated), but they are labels rather than a closed enum: any other numeric type is valid and compared by the same dominance rule.

A trust label SID of any other shape — wrong authority, wrong sub-authority count — makes the descriptor malformed, and AccessCheck rejects it outright rather than guessing.

Where a SACL carries more than one trust label ACE, only the first non-inherit-only one is used; inherit-only labels do not apply to the object carrying them.

3.8.7.2 Privilege revocation #

This is the critical difference from MIC. PIP does not merely constrain what the DACL may grant — it revokes rights privileges already granted. A non-dominant caller who used SeBackupPrivilege to obtain read has those bits stripped; SeTakeOwnershipPrivilege's WRITE_OWNER is stripped; SeSecurityPrivilege's ACCESS_SYSTEM_SECURITY is stripped.

The last of those is the point. Without privilege revocation, a non-dominant administrator holding SeSecurityPrivilege could read the SACL of a PIP-protected object — and remove the trust label from it. PIP would be self-defeating.

There is no escape hatch. PIP has no SeRelabelPrivilege equivalent, and no privilege compensates for insufficient trust. It is an absolute boundary, which is why the enforcement step explicitly ORs ACCESS_SYSTEM_SECURITY into the set of bits it can take away — that right is outside the generic mapping and would otherwise escape.

3.8.7.3 The algorithm #

EnforcePIP(ace, pip_type, pip_trust, mapping, &decided,
           &granted, &privilege_granted):

    // pip_type and pip_trust are the subject's process-trust context,
    // not a token field. See "Where the values come from" below.

    ace_type  = ace.sid.pip_type
    ace_trust = ace.sid.pip_trust

    caller_dominates = (pip_type  >= ace_type
                    and pip_trust >= ace_trust)

    if caller_dominates:
        return

    // Non-dominant: the ACE mask IS the allowed set.
    allowed = MapGenericBits(ace.mask, mapping)

    // Everything not explicitly allowed is denied, including
    // ACCESS_SYSTEM_SECURITY.
    all_bits = MapGenericBits(GENERIC_ALL, mapping)
             | ACCESS_SYSTEM_SECURITY
    pip_denied = all_bits & ~allowed

    decided |= pip_denied

    // Revoke privilege-granted rights.
    granted           &= ~pip_denied
    privilege_granted &= ~pip_denied

3.8.7.4 Where the values come from #

pip_type and pip_trust are the subject's process trust context and are never derived from a token field — the token structure has no PIP field at all. They are passed into AccessCheck as explicit parameters by whichever layer is enforcing.

During enforcement — FACS file access, process boundaries — the values are the subject process's PSB, set at exec from the binary's signature (§3.6, §3.7).

The kacs_access_check query may instead supply them through its arguments, per axis: zero means "use the calling process's PSB value" and a nonzero value evaluates against the supplied context. This lets a userspace broker evaluate access under a client's trust level, in the same way the query's token argument lets it evaluate a token other than its own. The query is advisory and gates nothing in the kernel; enforcement always uses the PSB. The same effective values are used for the verdict and for the event the check emits, so an audit record never disagrees with the decision it describes.

The enforcement step also computes a record of which bits PIP decided. Nothing currently consumes it — it is threaded through three structures and exported, and no caller reads it.

3.8.8 Central Access and Auditing Policy

Peios / Advanced Peios / PKM / KACS / AccessCheck

CAAP separates policy definition from the objects it governs. A policy is defined once, centrally; objects reference it by SID through a SYSTEM_SCOPED_POLICY_ID_ACE in their SACL. When the policy changes, every future AccessCheck against a referencing object uses the new rules — already-open handles are unaffected, because the model is check-at-open.

It extends the Windows Central Access Policy model by adding an audit component: each rule carries both an access restriction and an audit requirement.

3.8.8.1 Policy structure #

A policy is a named collection of rules identified by a policy SID. Each rule carries:

An optional applies-to condition, a conditional expression determining whether the rule governs this decision. It may reference the @Resource, @User, @Device and @Local claim namespaces, but it cannot inspect SID or device-group membership: Member_of, Member_of_Any, Device_Member_of, Device_Member_of_Any and their negated forms all evaluate to UNKNOWN inside applies_to. A rule with no condition applies to every object referencing the policy.

A mandatory effective DACL — a real DACL evaluated through the full pipeline. An optional effective SACL, whose audit ACEs merge with the object's own during the audit walk. And optional staged DACL and SACL, proposed replacements used for testing.

3.8.8.2 Access evaluation #

The DACL result is ANDed with the normal evaluation. CAAP can only restrict, never expand: if the object's DACL grants read and write but the applicable rule's effective DACL grants only read, the result is read.

A SACL may carry several scoped policy ACEs, which makes policies composable — and the AND semantics are what make composition safe, since each additional policy can only narrow. Inherit-only scoped policy ACEs do not apply to the object carrying them and are ignored during lookup. MS-DTYP allows one scoped policy ACE per SACL; KACS allows several.

For each scoped policy ACE, AccessCheck looks the policy up in the kernel cache, then for every rule whose applies_to is TRUE or absent evaluates the rule's effective DACL through the full pipeline — privilege grants, MIC, PIP, the DACL walk, restricted tokens, confinement — and intersects the result with the running total, which starts at the normal evaluation's granted mask.

A rule whose condition evaluates FALSE or UNKNOWN is skipped. That is deliberately the opposite of the deny-ACE UNKNOWN rule: skipping is the conservative choice here, because a rule's DACL can only narrow what the normal DACL already granted.

If no rules apply — every condition false or unknown, or the policy has no rules — CAAP has no effect and the normal result stands.

The rule's DACL is evaluated with backup and restore intent not passed: intent is a caller concern, not a policy concern. And CAAP never recurses. A rule's synthetic descriptor has scoped policy ACEs stripped before evaluation, so nested CAAP evaluation cannot occur even when the original SACL carried more of them. The synthetic descriptor keeps the original owner and group, and preserves the MIC and PIP labels, so a rule is evaluated against the same mandatory constraints as the object itself.

3.8.8.3 Audit evaluation #

For each applicable rule carrying an effective SACL, the rule's audit ACEs are evaluated alongside the object's own during the audit walk. They are treated identically — if either says to audit an operation, it is audited.

The SACL component is purely additive. A CAAP SACL can add audit coverage and can never suppress auditing the object's own SACL asks for.

3.8.8.4 The policy cache #

The kernel keeps a map from policy SID to policy object, empty at boot. Policies are pushed in through kacs_set_caap, which requires SeTcbPrivilege — and marks it used. A non-null spec for an existing SID replaces the policy; a null spec or zero length removes it. The policy SID's length is bounded to 8–68 bytes before parsing begins, and until the cache has been initialised both setting and evaluating fail with EACCES.

The wire format is:

[version:u8 = 0x01]
[rule_count:u32le]
per rule:
  [applies_to_len:u32le][applies_to_expr bytes]     (0 = no condition)
  [effective_dacl_len:u32le][effective_dacl bytes]  (MUST NOT be 0)
  [effective_sacl_len:u32le][effective_sacl bytes]  (0 = no audit rules)
  [staged_dacl_len:u32le][staged_dacl bytes]        (0 = no staged DACL)
  [staged_sacl_len:u32le][staged_sacl bytes]        (0 = no staged SACL)

All lengths are little-endian u32. ACLs use the standard binary format, and every ACE type valid in a DACL or SACL is permitted inside a policy ACL. The applies_to expression is conditional ACE bytecode, carrying the same artx prefix as callback ACE application data.

The limits are a spec of at most 256 KB, at most 256 rules, an applies_to of at most 64 KB, and an individual ACL of at most 64 KB. The version byte has to be 0x01. Trailing bytes after the declared rules are rejected.

Validation is strict and total. Malformed or truncated applies_to bytecode fails the whole call with EINVAL at ingestion rather than being admitted and later treated as a runtime UNKNOWN — the structural check happens once, at the boundary. A rule with a zero-length effective DACL fails the same way, as do truncated fields, lengths exceeding the buffer, and invalid ACL headers. SeTcbPrivilege is checked before any parsing begins.

authd populates the cache — from the registry on a standalone machine, from Active Directory on a domain-joined one. The kernel neither knows nor cares about the source; it is a passive cache. Policies pushed after services are running do not retroactively affect handles already opened.

3.8.8.5 The recovery policy #

When a scoped policy ACE names a SID that is not in the cache — authd failed to push it, the policy was deleted, the machine is disconnected — a hardcoded recovery policy is used instead: GENERIC_ALL to BUILTIN\Administrators, to SYSTEM, and to OWNER RIGHTS.

Those masks are stored as the literal GENERIC_ALL bit rather than pre-mapped object-specific bits, and are expanded through the caller's GenericMapping at evaluation time, so the recovery policy works correctly for every object type.

Because CAAP is an intersection, recovery does not widen access beyond the object's own DACL: it limits missing-policy access to callers who also satisfy the recovery DACL. This is a fail-closed recovery mode with administrator, SYSTEM and owner escape hatches — not a no-effect fallback.

3.8.8.6 Errors #

A rule whose DACL evaluation errors denies everything except rights granted by privileges. Preserving those is the escape hatch: an administrator with SeSecurityPrivilege keeps the ability to read and modify the SACL and remove the offending scoped policy ACE.

The escape hatch is conditional, though, and the ordering is why. PIP runs before CAAP and may already have stripped ACCESS_SYSTEM_SECURITY from the privilege-granted set for a non-dominant caller. The hatch therefore only works for callers who are PIP-dominant, or where the object carries no trust label.

Rule evaluation swallows every error kind, including allocation failure, so an out-of-memory condition inside a rule is reported as "this rule denied all except privileges" rather than as ENOMEM.

A rule whose SACL evaluation errors has its audit contribution skipped, and a diagnostic event is emitted.

3.8.8.7 Staging #

A rule may carry staged DACLs and SACLs alongside its effective ones, and AccessCheck evaluates both in parallel. The staged result affects neither access nor audit; where effective and staged differ, the difference is reported through a staging mismatch flag returned to the caller and a diagnostic event.

A rule with no staged DACL contributes its effective result to both running totals, and a rule with no staged SACL contributes its effective SACL to both.

3.8.9 Auditing in AccessCheck

Peios / Advanced Peios / PKM / KACS / AccessCheck

Auditing is purely observational. No audit rule affects the access decision, and audit ACEs are evaluated after the decision is final.

Three mechanisms operate inside the pipeline, emitting two families of KMES record: access-audit for object-access events from the SACL walk and from token audit-policy forcing, and privilege-use for privilege-use events. A third family, caap-policy-diagnostic, is emitted for the CAAP conditions of §3.8.8 — a SACL evaluation error, or a staged-versus-effective mismatch. The event type strings and payload schemas are in §3.C.

Event delivery happens before any result is written back to the caller, and a failure to deliver fails the call. An audit event cannot be suppressed by handing the syscall a bad output pointer.

3.8.9.1 Access auditing #

SYSTEM_AUDIT ACEs in the SACL define which attempts to log. Each carries a SID, an access mask, and success and failure flags — SUCCESSFUL_ACCESS_ACE_FLAG (0x40) and FAILED_ACCESS_ACE_FLAG (0x80).

An event is emitted when the ACE's SID matches the caller, its mask overlaps the requested access, and its flags match the outcome.

Two details matter. The SID is matched with deny polarity — the broadest identity view, in which deny-only groups are visible — because auditing should capture the widest possible picture rather than the narrowest. And the overlap is tested against the generic-mapped requested mask, not the final granted mask, so a failed request is still auditable for the rights it actually asked for.

Conditional audit ACEs gate the event on an expression, using the same deny-side membership polarity. An expression evaluating to UNKNOWN emits the event: when in doubt, audit.

3.8.9.2 Continuous auditing #

Access auditing fires once, where AccessCheck runs. Continuous auditing covers per-operation monitoring.

SYSTEM_ALARM ACEs configure it. When AccessCheck evaluates an alarm ACE whose SID matches, the ACE's mask is accumulated into a continuous audit mask returned to the caller, which stores it on the open handle and enforces it per operation. Conditional alarm ACEs use the same deny-side polarity as conditional audit ACEs. The alarm branch deliberately performs no overlap test against the requested mask — an alarm ACE contributes its mask on a SID match alone.

On each later operation the enforcement point emits a continuous-audit event when the operation's normalised required-access mask overlaps the stored mask. For FACS handles that is the same mask used by the use-time check (§3.9.4). Where an operation's authorization accepts any one of several rights — append or write data, say — the required mask holds the accepted set and the event records the subset that overlapped.

Events are emitted after the per-operation decision is known, for successful and denied attempts alike. The subject and process recorded are the operation-time effective token and current task, not necessarily the ones that opened the handle. That keeps attribution correct after a handle is passed between processes, while still using the opener-computed mask to decide whether the handle is audited at all.

An enforcement point that cannot construct a required continuous-audit event fails closed. Transport buffering and drop accounting remain KMES's concern (§2.7).

3.8.9.3 Privilege-use auditing #

When a privilege is exercised to grant access the DACL would not have granted independently, a privilege-use event may be emitted. This runs after the complete pipeline — after integrity policy, confinement and central access policy — so it reflects the final result rather than an intermediate one.

Successful privilege use means the privilege's contributed bits survive into the final granted result. The privilege is marked used, and an event is emitted when the token's audit_policy carries PRIVILEGE_USE_SUCCESS (0x04). Failed privilege use means the privilege contributed bits during evaluation that did not survive. The privilege is not marked used, and an event is emitted under PRIVILEGE_USE_FAILURE (0x08). A privilege that contributed nothing to the requested access produces no event either way.

With an object type list, the test is per-node: a privilege counts as successfully used if its bits survive on any node's final mask.

A MAXIMUM_ALLOWED request short-circuits this stage entirely, recording no used bits and emitting no privilege-use events at all.

How counterfactual the accounting really is varies by privilege, as §3.4.1 describes: SeSecurityPrivilege and SeTakeOwnershipPrivilege contribute only where the DACL had not already granted the right, while backup and restore seed their bits unconditionally and so also report use for accesses the DACL alone would have permitted.

3.8.9.4 Per-token audit policy #

A token's audit_policy can force events regardless of SACL content. This runs after the SACL walk and before result computation: if the access succeeded and the policy carries OBJECT_ACCESS_SUCCESS (0x01), a success event is emitted; if it failed and the policy carries OBJECT_ACCESS_FAILURE (0x02), a failure event is.

Success here means every requested bit was granted, or that nothing was requested at all.

These events are additive — they fire even when no SACL ACE matched — and they carry the object_audit_context the caller supplied. The policy is per-token, fixed at creation, and follows impersonation, since it is read from the effective token.

3.8.9.5 Event contents #

An event carries the subject, the calling token's identity — user SID, group SIDs, integrity level, PIP identity; the object, as the caller-provided context; the access, meaning what was requested, what was granted, and whether the request succeeded; the trigger, which audit ACE matched or which privilege was exercised; and the process, its PID, name and executable path.

The pipeline itself produces only the object-and-access half — the matched ACE bytes, the requested and granted masks, the outcome, whether the event was policy-forced, the privilege, and the audit context. The subject and process halves are attached at emission time from the resolved call context, which is also where the effective PIP values used for the verdict are reused for attribution.

3.8.10 The Algorithm

Peios / Advanced Peios / PKM / KACS / AccessCheck

The preceding sections describe what each layer of AccessCheck does. This one describes how they compose, and is the definitive statement of the evaluation order.

3.8.10.1 Pipeline overview #

Before the pipeline proper, two things are checked and can fail the call outright. The token's own invariants are validated — a write_restricted token without user_deny_only is rejected as invalid — and, in the orchestrator, a null descriptor is rejected and AccessCheckResultList is required to have been given an object type list. A null descriptor presented by an Identification-level token therefore fails as an invalid parameter rather than as access denied, because the null check runs first.

The pipeline then runs in this order:

  1. Impersonation level gate. An impersonation token at Identification level is denied immediately. Anonymous tokens proceed through the full pipeline.
  2. Input validation. Reject a descriptor with no owner. A null group SID is valid and has no direct effect on the decision.
  3. Generic mapping. Map generic bits in the desired mask to object-specific bits; strip MAXIMUM_ALLOWED.
  4. Effective privileges. Clear the backup and restore bits when the corresponding intent flag is absent.
  5. Privilege grants. Resolve ACCESS_SYSTEM_SECURITY, backup and restore. Seed decided, granted and privilege_granted.
  6. Pre-SACL walk. Extract the mandatory integrity label, the PIP trust label, resource attributes and scoped policy SIDs from the SACL, then enforce MIC and PIP.
  7. Virtual group resolution. S-1-3-4 and S-1-5-10 become matchable where the caller is the owner or the object's principal.
  8. Tree initialisation. Seed each node from the scalar state.
  9. Normal DACL evaluation. Owner implicit rights, then the walk.
  10. Post-DACL WRITE_OWNER override. SeTakeOwnershipPrivilege grants WRITE_OWNER if the DACL did not and no mandatory mechanism blocked it.
  11. Restricted token pass, with intersection and privilege restoration.
  12. Confinement pass, with absolute intersection.
  13. CAAP. Evaluate each applicable rule's DACL through the full per-descriptor pipeline and intersect; collect SACLs.
  14. Privilege-use auditing.
  15. Audit emission, over the object's SACL and any CAAP SACLs.
  16. Result computation.

Object type lists are validated at parse time rather than at step 1 — non-empty, one level-0 node first, no level gaps, no duplicate GUIDs — so a malformed list never reaches the pipeline. Step 1 re-checks only emptiness.

Reserved access-mask bits (0x0CE0_0000) are rejected wherever a mask is mapped. That applies to the caller's desired mask and to every ACE mask, so a single ACE carrying a reserved bit aborts the entire check rather than being skipped.

3.8.10.2 EvaluateSecurityDescriptor #

Steps 0–11, called once for the normal evaluation and once per CAAP rule with a synthetic descriptor.

EvaluateSecurityDescriptor(
    sd, token, pip_type, pip_trust, desired, mapping,
    object_tree, self_sid, local_claims, privilege_intent
) -> (decided, granted, privilege_granted,
      max_allowed_mode, mapped_desired, resource_attributes,
      policy_sids) | error

    // Step 0: Impersonation level gate.
    if token.token_type == Impersonation
       and token.impersonation_level == Identification:
        return ERROR_ACCESS_DENIED

    // Step 1: Input validation.
    if sd.owner is null:
        return ERROR_INVALID_SECURITY_DESCR

    // Step 2: Generic mapping.
    desired = MapGenericBits(desired, mapping)
    max_allowed_mode = (desired & MAXIMUM_ALLOWED) != 0
    desired = desired & ~MAXIMUM_ALLOWED

    // Step 3: Effective privileges.
    effective_privileges = token.privileges_enabled
    if not (privilege_intent & BACKUP_INTENT):
        effective_privileges &= ~SeBackupPrivilege
    if not (privilege_intent & RESTORE_INTENT):
        effective_privileges &= ~SeRestorePrivilege

    // Step 4: Privilege-based grants.
    decided = 0; granted = 0; privilege_granted = 0

    // ACCESS_SYSTEM_SECURITY is always decided by privilege.
    decided |= ACCESS_SYSTEM_SECURITY
    if (effective_privileges & SeSecurityPrivilege):
        granted           |= ACCESS_SYSTEM_SECURITY
        privilege_granted |= ACCESS_SYSTEM_SECURITY

    if (effective_privileges & SeBackupPrivilege):
        backup_bits = MapGenericBits(GENERIC_READ, mapping)
        decided |= backup_bits; granted |= backup_bits
        privilege_granted |= backup_bits

    if (effective_privileges & SeRestorePrivilege):
        restore_bits = MapGenericBits(GENERIC_WRITE, mapping)
                     | WRITE_DAC | WRITE_OWNER | DELETE
                     | ACCESS_SYSTEM_SECURITY
        decided |= restore_bits; granted |= restore_bits
        privilege_granted |= restore_bits

    // Restore already includes WRITE_OWNER, so when it is active
    // step 9 has nothing left to do. Step 9 is the fallback for
    // when restore is inactive and the DACL did not grant it.

    // Step 5: Pre-SACL walk. mandatory_decided records bits decided
    // by MIC and PIP, so step 9 cannot override them.
    resource_attributes = {}; policy_sids = []; mandatory_decided = 0
    PreSACLWalk(sd, token, pip_type, pip_trust, mapping,
                &decided, &granted, &privilege_granted,
                &mandatory_decided, &resource_attributes,
                &policy_sids)

    // Steps 6-8: owner implicit rights are granted first, inside
    // EvaluateDACL, and the tree is seeded from the already
    // augmented scalar state. Virtual groups are resolved per
    // lookup rather than by building an enriched token.
    EvaluateDACL(sd, token, mapping, object_tree,
                 SidMatchesToken, desired, max_allowed_mode,
                 resource_attributes, local_claims,
                 skip_owner_implicit=false,
                 &decided, &granted)

    // Step 9: Post-DACL WRITE_OWNER override.
    if (desired & WRITE_OWNER) != 0 or max_allowed_mode:
        if (effective_privileges & SeTakeOwnershipPrivilege):
            if not (mandatory_decided & WRITE_OWNER)
               and not (granted & WRITE_OWNER):
                decided |= WRITE_OWNER
                granted |= WRITE_OWNER
                privilege_granted |= WRITE_OWNER
                for each node with WRITE_OWNER ungranted:
                    node.decided |= WRITE_OWNER
                    node.granted |= WRITE_OWNER

    // Step 10: Restricted token pass.
    if token.restricted_sids or token.restricted_device_groups:
        // Restricted identity view: only restricting SIDs, plus
        // S-1-3-4 if the owner is among them and S-1-5-10 if
        // self_sid is. Restricted device groups swap in.
        // Fresh tree, zeroed state.
        EvaluateDACL(sd, restricted_view, mapping, r_tree,
                     SidInRestrictingSids, desired,
                     max_allowed_mode, resource_attributes,
                     local_claims, skip_owner_implicit=false,
                     &r_decided, &r_granted)
        if token.write_restricted:
            write_bits = MapGenericBits(GENERIC_WRITE, mapping)
            granted = (granted & ~write_bits)
                    | (granted & r_granted & write_bits)
        else:
            granted = granted & r_granted
        granted |= privilege_granted        // privileges bypass
        // Same intersection and restoration per node.

    // Step 11: Confinement pass.
    if token.confinement_sid and not token.confinement_exempt:
        // Confinement SID set: confinement_sid plus every
        // capability, presence-based. S-1-3-4 and S-1-5-10 are
        // injected only if the owner or self_sid is in that set.
        EvaluateDACL(sd, token, mapping, c_tree,
                     SidInConfinementSids, desired,
                     max_allowed_mode, resource_attributes,
                     local_claims, skip_owner_implicit=true,
                     &c_decided, &c_granted)
        granted = granted & c_granted       // no privilege bypass
        // Same absolute intersection per node.

    return (decided, granted & root-consistent state,
            privilege_granted narrowed to what survived,
            max_allowed_mode, desired, resource_attributes,
            policy_sids)

The returned privilege_granted is narrowed by what actually survived the pass — it is intersected with the root's granted mask on return, and the orchestrator narrows it again against the CAAP result. A privilege-granted bit that the write-restricted merge or the confinement intersection removed is therefore no longer part of it, which matters in two places: the CAAP error escape hatch (§3.8.8) has fewer bits to preserve, and the audit provenance masks reflect the narrowed set.

3.8.10.3 AccessCheckCore #

The orchestrator runs steps 12 through 15.

Step 12, CAAP. For each scoped policy SID, look the policy up — falling back to the recovery policy when it is absent — and for each rule whose applies_to is TRUE or absent, evaluate the rule's synthetic descriptor through EvaluateSecurityDescriptor and intersect. A rule that errors denies everything except the (already narrowed) privilege-granted bits. Staged DACLs are evaluated in parallel into a separate running total; a rule with no staged DACL contributes its effective result to both.

After all policies, the staged and effective totals are compared and any difference sets the staging mismatch flag. In result-list mode a per-node delta sets it too — and so does a scalar delta, since the comparison is not mode-branched.

Step 13, privilege-use auditing. For each of the five provenance masks — security, backup, restore, take-ownership and relabel:

success_bits = provenance & mapped_desired & granted
failure_bits = provenance & mapped_desired & ~granted

Nonzero success_bits means the privilege was load-bearing: mark it used on the token, and emit a success event under PRIVILEGE_USE_SUCCESS. Otherwise nonzero failure_bits means it was exercised but did not survive: do not mark it used, and emit a failure event under PRIVILEGE_USE_FAILURE. Both zero means no event. In result-list mode the comparison folds across nodes — success if the bits survive on any node, failure only if they survive on none.

The whole step is skipped in MAXIMUM_ALLOWED mode, so such a request marks nothing used and emits nothing.

Note that relabel_granted is tracked as provenance but is deliberately excluded from privilege_granted itself, so a relabel-loosened WRITE_OWNER is neither restored after the restricted merge nor preserved by the CAAP error hatch.

Step 14, audit emission. Walk the object's SACL, then each CAAP effective SACL, accumulating audit events and ORing alarm masks into the continuous audit mask. This is read-only with respect to granted.

The staged comparison then walks the object's SACL again followed by the staged SACLs. That second walk is driven by the staged granted total rather than the effective one, so the success and failure classification of staged audit events reflects the staged access result — which is what makes the flag sensitive to descriptors whose staged and effective grants differ.

Step 14b, forced auditing. With success = (granted & mapped_desired) == mapped_desired or mapped_desired == 0, the token's audit_policy forces a success or failure event additively, regardless of what the SACL matched.

Step 15 returns the accumulated state.

3.8.10.4 The wrappers #

AccessCheck takes the root node's granted mask when a tree is present, then computes allowed as mapped_desired == 0 or every requested bit granted. The root's mask equals the intersection across all nodes by construction rather than by computation: upward denial propagation (§3.8.5) forces every descendant's denial into all of its ancestors, so the root can never grant what a descendant denies.

AccessCheckResultList requires a tree and returns a per-node granted mask and status, each node judged against mapped_desired independently.

Neither wrapper filters the returned granted to the requested mask. Privilege seeding at step 4 ORs bits in regardless of what was asked for, so a caller that requested only READ_CONTROL while holding backup can see read bits it never requested. The file enforcement path does filter its result; the generic query path does not.

3.8.10.5 Helpers #

SidMatchesToken(sid, token, for_allow) -> bool
    if sid == token.user_sid:
        if for_allow and token.user_deny_only:
            return false
        return true
    for group in token.groups:
        if not group.enabled and not group.deny_only:
            continue
        if for_allow and group.deny_only:
            continue
        if sid == group.sid:
            return true
    return false
MapGenericBits(mask, mapping) -> ACCESS_MASK
    if mask & RESERVED_BITS: reject
    mapped = mask & ~(GENERIC_READ | GENERIC_WRITE
                    | GENERIC_EXECUTE | GENERIC_ALL)
    if mask & GENERIC_READ:    mapped |= mapping.read
    if mask & GENERIC_WRITE:   mapped |= mapping.write
    if mask & GENERIC_EXECUTE: mapped |= mapping.execute
    if mask & GENERIC_ALL:     mapped |= mapping.all
    return mapped

All four generic bits are cleared before any is expanded, so a mask naming several generics maps every one of them.

Virtual group resolution. There is no enrichment step producing a modified token. S-1-3-4 and S-1-5-10 are resolved at each lookup instead, in the DACL walk, the SACL walk and conditional membership alike. S-1-5-10 resolves through the ordinary polarity rules against self_sid, and S-1-3-4 through the ordinary polarity rules against the object's owner SID. Both are computed once per walk — the owner is fixed while the polarity is per ACE — so each carries its allow and deny answers together.

EvaluateSACL walks in a fixed order per ACE: SID match with deny polarity, then object-type scoping against the tree, then the condition, then the mask overlap against mapped_desired. Audit ACEs need all four; alarm ACEs deliberately skip the overlap test and contribute their mask on a SID match alone. Inherit-only ACEs are skipped throughout.

synthetic_sd builds a CAAP rule's descriptor from the original's owner and optional group with the rule's DACL substituted, and the SACL copied with every scoped policy ACE stripped — which is what prevents recursion. The MIC and PIP labels are preserved, so a rule is evaluated under the same mandatory constraints as the object. The control bits and the stripped SACL's revision are recomputed rather than copied.

3.8.10.6 Provenance masks #

VariableSet atMeaning
security_grantedStep 4SeSecurityPrivilege granted ACCESS_SYSTEM_SECURITY.
backup_grantedStep 4SeBackupPrivilege granted read bits.
restore_grantedStep 4SeRestorePrivilege granted write and metadata bits.
take_ownership_grantedStep 9SeTakeOwnershipPrivilege granted WRITE_OWNER.
relabel_grantedStep 5SeRelabelPrivilege added WRITE_OWNER to the MIC allowed set.

Each records which bits that privilege contributed, and step 13 compares each against the requested mask and the final result.

3.9.1 The Handle Model

Peios / Advanced Peios / PKM / KACS / FACS

FACS, the File Access Control Shim, is the file-specific enforcement surface of KACS. It replaces Linux DAC — UID, GID and mode-bit checks — with security-descriptor evaluation on files.

Enforcement follows the handle pattern. AccessCheck runs once at open time, the granted mask is cached on the file description, and later operations test the cached mask:

(fd.granted & required) == required ? allow : deny

The mask is set once and never modified, and it is immutable for the descriptor's whole lifetime regardless of any later descriptor change. A file's DACL can be rewritten while a process holds it open, and that process keeps the rights it was granted. A few operations use a live AccessCheck instead; §3.9.4 lists them.

A second immutable mask is stamped alongside it: the continuous audit mask (§3.8.9), which every use-time operation consults to decide whether to emit a per-operation audit event. It travels with the granted mask through every path described below.

3.9.1.1 Mount policy #

Every mounted filesystem exposes exactly one FACS mount-policy class, scoped to the kernel superblock object rather than to a pathname or a bind mount — several paths or bind mounts over one superblock all observe the same class.

unmanaged puts the mount outside the handle model entirely: no granted mask is stamped on its file descriptions. facs_deny_missing is FACS-managed and denies access where a descriptor is missing. facs_synthesize_ephemeral synthesises missing descriptors and caches them in memory only. facs_synthesize_persistent synthesises them and writes them back immediately. The three facs_* classes are the only managed ones.

The default classifier is conservative. Hardcoded pseudo-filesystems — /proc, /sys, nullfs — are unmanaged. Filesystems that cannot reliably store the canonical descriptor xattr are facs_synthesize_ephemeral: FAT, exFAT, NFS client mounts, ISO9660, and cgroup2. StrataFS is fixed at facs_deny_missing. Everything else — tmpfs, squashfs, ext4, btrfs — defaults to facs_deny_missing unless a trusted policy agent adopts it.

Userspace cannot set a superblock to unmanaged through the public ABI; the class is reserved for the kernel classifier and the hardcoded rules. Attempting to set a policy on a magic-derived unmanaged superblock fails with EOPNOTSUPP, and so does attempting to change StrataFS's.

3.9.1.2 Scope #

The sole-authority claim covers local FACS-managed filesystems. It does not cover O_PATH descriptors, which are not managed; NFS client mounts, which are ephemeral-synthesising and retain dual authority with the server; /proc, which is unmanaged and PIP-protected; or /sys, which is unmanaged and carries a hardcoded rule instead — writes there require Administrators or SYSTEM, enforced against a built-in descriptor. Descriptors for processes obtained through pidfd bypass FACS at open as well.

3.9.1.3 Handle acquisition #

A descriptor carries its granted mask through every acquisition path, and transfer is an intentional capability-delegation mechanism. dup/dup3 and fork produce the same open file description and therefore the same rights; descriptors without FD_CLOEXEC survive exec unchanged; SCM_RIGHTS transfers the descriptor as a capability token, with possession as authorization and no re-check of the receiver's identity; and pidfd_getfd() is gated by an AccessCheck for PROCESS_DUP_HANDLE against the target process, after which the caller receives the descriptor with its full mask.

The security boundary is at open time. Every subsequent transfer carries the mask unchanged.

This means mandatory subject policy — MIC and PIP — is evaluated once, at open, against the opener. A high-integrity process that opens a file and passes the descriptor to a low-integrity one has effectively delegated its access. That is the handle model: authority is on the handle, not on the holder.

3.9.1.4 Stacked backing files #

When a stacking filesystem creates a kernel-private backing file for a managed user-visible file, the outer file's granted and continuous audit masks are captured into the backing-file blob, and the backing file's ordinary blob receives that exact snapshot when the provider is opened. No new AccessCheck runs against the task executing the stacking filesystem.

The inheritance is deliberately narrow. It is admitted only through the kernel's typed backing-file allocation path, only from a managed non-O_PATH outer file, and it retains no reference to that outer file — only the values. It is not a general snapshot-cloning mechanism.

An active StrataFS copy-up context takes precedence: a backing open that does not match its exact object and phase does not fall back to inherited authority. When an exactly-bound copy-up backing file is adopted by the outer descriptor, the outer snapshot is installed into both blobs as a single transition, after which the backing file behaves like any other stacked open.

The user-visible operation is checked and continuously audited once, against the outer handle. Immediate provider re-entry through security_file_permission or security_mmap_file neither repeats the caller authorization nor emits a second caller audit event. The backing file keeps the snapshot for later descriptor-local enforcement, including mprotect() after a stacked mmap, and the mmap_backing_file handoff verifies that the outer, backing and captured snapshots still agree before the provider mapping is installed.

This suppresses duplicate KACS authorization only. It bypasses nothing else — not another LSM, not filesystem errors, not read-only mounts, immutable state, quota, space, I/O, or format validation.

3.9.2 KACS-Native Open

Peios / Advanced Peios / PKM / KACS / FACS

kacs_open takes an explicit desired access mask. The caller names every right it will need — FILE_READ_DATA, FILE_WRITE_DATA, WRITE_DAC, READ_CONTROL, any combination — and AccessCheck evaluates the whole requested mask at open. If every requested right is granted the descriptor's mask is set to the requested mask; if any is denied, the open fails. This is the strict mode, in contrast to the legacy path's subset behaviour (§3.9.3).

MAXIMUM_ALLOWED may be combined with at least one concrete data right or FILE_EXECUTE. The concrete bits define the Linux f_mode of the returned descriptor and have to be granted; MAXIMUM_ALLOWED then makes the cached mask the full computed maximum rather than the requested set. Alone it is invalid, because it defines no file mode, and fails with EINVAL.

3.9.2.1 The required data right #

Every native open names at least one data right — FILE_READ_DATA, FILE_WRITE_DATA, FILE_APPEND_DATA — or FILE_EXECUTE, so that every descriptor has a valid mode. FILE_READ_DATA maps to FMODE_READ; either write right maps to FMODE_WRITE; and FILE_EXECUTE alone maps to FMODE_EXEC, which enables execveat(fd, "", ..., AT_EMPTY_PATH) — an execute-only handle that can neither read nor write the file's contents.

3.9.2.2 Directories #

Directory rights share bit positions with file data rights: FILE_LIST_DIRECTORY is FILE_READ_DATA (0x0001), FILE_ADD_FILE is FILE_WRITE_DATA (0x0002), and FILE_ADD_SUBDIRECTORY is FILE_APPEND_DATA (0x0004). A native directory open with FILE_LIST_DIRECTORY satisfies the data-right requirement and maps to FMODE_READ.

The two write aliases are not cacheable directory handle rights, though. kacs_open rejects a desired mask naming FILE_ADD_FILE, FILE_ADD_SUBDIRECTORY or FILE_DELETE_CHILD on a directory with EOPNOTSUPP. Those are parent-directory authorization rights for namespace operations, evaluated live at the operation, not rights a handle carries.

3.9.2.3 Special nodes and symlinks #

Existing FIFOs, pathname socket nodes, and character and block device nodes on managed mounts are ordinary filesystem file objects for authorization: the file object type, the file right mapping, and the file GenericMapping for metadata, standard and generic rights.

FILE_EXECUTE is not a valid data substitute for them — a kacs_open naming it on such a node fails closed. The Linux object implementation may still impose its own device- or filesystem-specific denial after KACS has authorized the file object.

Symlink objects are file objects for kacs_get_sd, kacs_set_sd and readlink, but they are not opened as terminal objects by kacs_open. Following is the default resolution behaviour, and AT_SYMLINK_NOFOLLOW fails with ELOOP. This differs deliberately from kacs_get_sd and kacs_set_sd, where the same flag resolves the symlink object itself.

Operations needing neither data nor execute access — changing a DACL without reading contents — use path-based interfaces or O_PATH descriptors as object anchors, which the get and set security calls accept through AT_EMPTY_PATH.

3.9.2.4 Create dispositions #

ValueNameIf it existsIf it does not
0FILE_SUPERSEDEDelete and recreateCreate
1FILE_OPENOpenFail
2FILE_CREATEFailCreate
3FILE_OPEN_IFOpenCreate
4FILE_OVERWRITETruncate to zeroFail
5FILE_OVERWRITE_IFTruncate to zeroCreate

FILE_SUPERSEDE deletes the existing name and creates a new file under it, requiring DELETE on the existing file — or FILE_DELETE_CHILD on the parent — together with FILE_ADD_FILE on the parent. The new file gets a new inode and a new descriptor, inherited or caller-supplied. The superseded pathname is broken away from the old hardlink set and names the new inode; other pre-existing hardlinks continue to name the old inode, and already-open descriptors still reference it.

FILE_OVERWRITE truncates in place — same inode, same descriptor, hardlinks preserved — and requires FILE_WRITE_DATA.

On an unmanaged mount, FILE_OPEN and FILE_OPEN_IF succeed and return an unmanaged descriptor with no stamped mask rather than failing; the creating dispositions fail with EOPNOTSUPP.

3.9.2.4.1 The DELETE fallback #

Both FILE_SUPERSEDE and delete-on-close reference "DELETE on the file, or FILE_DELETE_CHILD on the parent". That is a two-descriptor check inside the open path: AccessCheck runs first against the target file for DELETE, and if that is not granted it runs again against the parent directory for FILE_DELETE_CHILD. If neither grants, the open fails. The same duality governs link operations.

3.9.2.5 Create options #

ValueNameDescription
0x0001KACS_CREATE_OPT_DIRECTORYThe target has to be a directory. When creating, create a directory rather than a regular file; when opening an existing non-directory, fail with ENOTDIR.
0x0002KACS_CREATE_OPT_DELETE_ON_CLOSEDelete the file when the last handle in its lineage closes. Requires DELETE on the file or FILE_DELETE_CHILD on the parent.

All other bits are reserved and have to be zero; a nonzero reserved bit fails with EINVAL.

Delete-on-close is deliberately no-share. kacs_open_how carries no share-mode field, so the Windows FILE_SHARE_DELETE compatibility matrix is not representable in the frozen ABI, and the kernel contract is bounded instead. The obligation attaches to one ordinary file description lineage rather than to Linux inode last-reference semantics, so dup(), fork() and SCM_RIGHTS all preserve it because they preserve the description. Once a lineage exists for a file object, later opens of that object fail closed rather than emulating share-mode compatibility. The unlink happens at final close of that lineage — not at open, and not at generic inode last-reference drop — and if the pathname is already gone by then the close path treats it as a no-op rather than a new error. Regular files only: directory delete-on-close fails closed.

3.9.2.6 Caller-supplied descriptors #

When a disposition results in a new file the caller may supply a descriptor. A null pointer with zero length means the new file's descriptor is inherited from the parent directory through the inheritance algorithm.

Supplying one on a branch that opens an existing object is invalid input rather than something silently ignored. FILE_OPEN_IF resolving to an existing object fails with EINVAL, and so do FILE_OVERWRITE and the existing-object branch of FILE_OVERWRITE_IF, since those retain the existing inode and its descriptor. FILE_OPEN with a descriptor supplied fails with EOPNOTSUPP.

For a genuine creation the kernel computes the new object's descriptor first, then runs the strict native-open AccessCheck against that descriptor for the requested access. Parent create rights authorize namespace creation; they do not by themselves authorize the returned handle. A failed strict check rolls the creation back — the newly created file or directory is removed — and the syscall fails.

Creator-supplied descriptors are validated at create time. An owner SID has to be the caller's own or a token group marked SE_GROUP_OWNER, unless SeRestorePrivilege is enabled, in which case any owner is allowed. A supplied SACL is treated as a full SACL input rather than a label-only fragment, and therefore requires SeSecurityPrivilege. If that SACL carries an explicit mandatory label ACE, the label also has to satisfy the ordinary label-write constraint: at or below the caller's integrity level, unless SeRelabelPrivilege is held.

3.9.2.7 Modes and status #

kacs_open_how carries no POSIX mode field, so the raw Linux inode mode for native creation is fixed: 0600 for regular files, 0700 for directories. These are compatibility metadata only — and if Linux DAC ever denies an operation KACS would have authorized, the operation fails closed.

Native creation does not create FIFOs, socket nodes, device nodes or symlinks. Those stay on the Linux namespace APIs — mknod(), mkfifo(), Unix bind() to a path — governed by the namespace hooks. NTFS is excluded from native creation entirely.

The syscall reports what happened: created, opened, overwritten, or superseded. KACS_STATUS_SUPERSEDED is used only when an existing object was actually replaced — a FILE_SUPERSEDE that finds no target and simply creates one reports KACS_STATUS_CREATED.

3.9.3 Legacy Open Compatibility

Peios / Advanced Peios / PKM / KACS / FACS

Linux's open() and openat() cannot express rights like WRITE_DAC or READ_CONTROL. FACS maps the open flags to a core set of required rights plus a compat set of POSIX-expected ones, and evaluates both in a single AccessCheck.

3.9.3.1 Core rights #

The core set is the minimum for a usable descriptor, and the open fails if any of it is denied.

For regular files, device nodes, FIFOs and pathname sockets:

FlagsCore rights
O_RDONLYFILE_READ_DATA | FILE_READ_ATTRIBUTES
O_WRONLYFILE_WRITE_DATA | FILE_READ_ATTRIBUTES
O_RDWRFILE_READ_DATA | FILE_WRITE_DATA | FILE_READ_ATTRIBUTES

For directories, O_RDONLY gives FILE_READ_ATTRIBUTES | FILE_TRAVERSE. Directory core deliberately excludes FILE_LIST_DIRECTORY, so a directory opened O_RDONLY can be used for fchdir() and fstat() without listing permission; listing is a compat right.

Two modifiers apply in order. O_APPEND replaces FILE_WRITE_DATA with FILE_APPEND_DATA, and O_TRUNC adds FILE_WRITE_DATA. Given both, the replacement happens first and the re-addition second, so core ends up with FILE_APPEND_DATA and FILE_WRITE_DATA together.

FILE_READ_ATTRIBUTES is always core: a descriptor granting data access but denying attribute reads is not openable through the legacy APIs at all.

3.9.3.2 Compat rights #

Requested alongside core and silently omitted where denied: FILE_READ_EA for fgetxattr(); READ_CONTROL for reading the descriptor; FILE_WRITE_ATTRIBUTES for futimens(); FILE_WRITE_EA for fsetxattr(); FILE_WRITE_DATA on O_APPEND opens so ftruncate() works where the descriptor allows it; WRITE_DAC, because POSIX permits fchmod() on any descriptor; WRITE_OWNER, for fchown() on the same grounds; SYNCHRONIZE; FILE_LIST_DIRECTORY, enabling readdir() on directory descriptors; and FILE_EXECUTE, enabling fexecve() on regular files.

3.9.3.3 The flow #

The full requested mask is core plus compat. AccessCheck returns the subset the descriptor allows. If every core right is present the actual granted mask — which may include all, some or none of compat — is stamped on the descriptor; otherwise the open fails with EACCES.

Both open paths run the same pipeline with different success criteria. Native open is strict: everything requested has to be granted. Legacy open is subset: only core has to be fully present.

3.9.3.4 O_PATH #

O_PATH descriptors are not FACS-managed. The open hook does fire for them, but returns immediately without evaluating anything, so they carry no granted mask and are left unmanaged. They serve as namespace anchors for the *at() syscalls.

fstat() and fstatfs() on them are allowed unconditionally. fchdir() runs a live FILE_TRAVERSE check at use time. fchmod(), fchown(), fgetxattr(), fsetxattr(), ioctl() and mmap() are denied with EBADF — though futimens() currently has no such guard. execveat(fd, "", ..., AT_EMPTY_PATH) has exec permission enforced by a live AccessCheck in the bprm hook, and kacs_get_sd and kacs_set_sd with AT_EMPTY_PATH likewise run live, which gives race-free object identity without snapshot authorization.

That fstat() is unconditional means FILE_READ_ATTRIBUTES is not authoritative for attribute confidentiality. In practice size, timestamps and inode number are rarely confidential. The descriptor itself is protected: kacs_get_sd on an O_PATH handle performs a live check.

3.9.4 Use-Time Semantics

Peios / Advanced Peios / PKM / KACS / FACS

Every operation on an open descriptor is a mask check against the granted mask, with one exception: execveat(AT_EMPTY_PATH) uses a live AccessCheck.

3.9.4.1 Data operations #

OperationRequired right
ReadFILE_READ_DATA
Sequential write, no append intentFILE_WRITE_DATA
Append-intent write (O_APPEND descriptor or RWF_APPEND)FILE_APPEND_DATA or FILE_WRITE_DATA
Positioned write, or a no-append overrideFILE_WRITE_DATA — denied on append-only descriptors
Directory listingFILE_LIST_DIRECTORY
ftruncateFILE_WRITE_DATA
fallocate allocation (ALLOCATE_RANGE, with or without KEEP_SIZE)FILE_APPEND_DATA or FILE_WRITE_DATA
fallocate mutation (PUNCH_HOLE, ZERO_RANGE, COLLAPSE_RANGE, INSERT_RANGE, UNSHARE_RANGE, WRITE_ZEROES)FILE_WRITE_DATA
mmap PROT_READFILE_READ_DATA
mmap PROT_WRITE | MAP_SHAREDFILE_WRITE_DATA — FILE_APPEND_DATA alone is insufficient
mmap PROT_WRITE | MAP_PRIVATEFILE_READ_DATA — copy-on-write, no write to the file
mmap PROT_EXECFILE_EXECUTE
mprotectAs mmap, for the new protection flags
flock LOCK_SH / F_RDLCKFILE_READ_DATA
flock LOCK_EX / F_WRLCKFILE_WRITE_DATA or FILE_APPEND_DATA
fsync / fdatasyncSYNCHRONIZE

A fallocate mode outside the supported set fails closed, and PUNCH_HOLE additionally requires KEEP_SIZE.

3.9.4.2 Metadata operations #

OperationRequired right
stat / lstat / path statxFILE_READ_ATTRIBUTES
fstat / descriptor statxFILE_READ_ATTRIBUTES
fstatfsFILE_READ_ATTRIBUTES
path and descriptor file_getattrFILE_READ_ATTRIBUTES
path and descriptor file_setattrFILE_WRITE_ATTRIBUTES
truncate by pathnameFILE_WRITE_DATA
chmod / fchmodat / fchmodWRITE_DAC
chown / lchown / fchownat / fchownWRITE_OWNER
utimensat / utimes / futimensFILE_WRITE_ATTRIBUTES
getxattr / lgetxattr / fgetxattrFILE_READ_EA
setxattr / lsetxattr / removexattr / fsetxattr / fremovexattrFILE_WRITE_EA
listxattr / llistxattr / flistxattrnone
access / faccessat F_OKFILE_READ_ATTRIBUTES
access / faccessat R_OKFILE_READ_DATA
access / faccessat W_OKFILE_WRITE_DATA
access / faccessat X_OKFILE_EXECUTE

Reads and writes of the canonical descriptor xattr are denied unconditionally through the xattr hooks — security.peios.sd, or system.ntfs_security on NTFS. All descriptor access goes through kacs_get_sd and kacs_set_sd. POSIX ACL xattr writes are denied unconditionally too, with EOPNOTSUPP rather than EACCES so that probe-then-tolerate callers behave sensibly.

3.9.4.3 Directory traversal #

Path resolution checks FILE_TRAVERSE on managed directory components. A token holding SeChangeNotifyPrivilege bypasses the intermediate checks, including on directories whose descriptor is missing.

Explicit changes of the current or root directory are not intermediate resolution: chdir() and chroot() take a live FILE_TRAVERSE check on the final directory, and the privilege bypass does not apply (§3.4.2). An ordinary fchdir() checks the descriptor's cached mask; an O_PATH fchdir() runs live.

3.9.4.4 Append-only enforcement #

A handle carrying FILE_APPEND_DATA but not FILE_WRITE_DATA allows only true append-intent writes. Append intent means the effective write position is forced to end-of-file by O_APPEND or per-I/O RWF_APPEND, and is not negated by RWF_NOAPPEND on the same operation. Requesting both RWF_APPEND and RWF_NOAPPEND together fails with EACCES.

Denied on such a handle: positioned writes without effective append intent — pwrite64, pwritev, pwritev2 with an explicit offset, io_uring writes with an explicit offset, and AIO writes with an offset; any write using RWF_NOAPPEND, since it can negate append semantics inherited from O_APPEND; shared writable mmap and mprotect upgrades to PROT_WRITE; and the fallocate mutation modes.

3.9.4.5 fcntl #

For F_SETFL, KACS evaluates the mutable status flags Linux accepts — O_APPEND, O_NONBLOCK/O_NDELAY, O_DIRECT, O_NOATIME. Clearing O_APPEND is denied on a handle with FILE_APPEND_DATA but not FILE_WRITE_DATA; setting it is always allowed, being a privilege reduction. Adding O_NOATIME requires FILE_WRITE_ATTRIBUTES and clearing it is always allowed. Changing only O_NONBLOCK, O_NDELAY or O_DIRECT needs no KACS right, though ordinary Linux validation still applies.

These commands are descriptor-local and require no KACS right, and none of them widens the cached mask: F_CREATED_QUERY; F_DUPFD, F_DUPFD_CLOEXEC and F_DUPFD_QUERY, which preserve the same file description and mask; F_GETFD and F_SETFD; F_GETFL; and the async-notification set F_GETOWN, F_GETOWN_EX, F_GETOWNER_UIDS, F_GETSIG, F_SETOWN, F_SETOWN_EX and F_SETSIG, where Linux pid and signal validation still applies.

These are object-state queries or mutations, checked against the cached mask before Linux-specific validation:

CommandRequired right
F_GETLK / F_GETLK64 / F_OFD_GETLKAny data right
F_GETLEASE / F_GETDELEGFILE_READ_ATTRIBUTES
F_GETPIPE_SZFILE_READ_ATTRIBUTES
F_SETPIPE_SZFILE_WRITE_ATTRIBUTES
F_GET_SEALSFILE_READ_ATTRIBUTES
F_ADD_SEALSFILE_WRITE_ATTRIBUTES
F_GET_RW_HINT / F_GET_FILE_RW_HINTFILE_READ_ATTRIBUTES
F_SET_RW_HINT / F_SET_FILE_RW_HINTFILE_WRITE_ATTRIBUTES

Lock, lease and delegation commands — F_SETLK, F_SETLKW, F_SETLK64, F_SETLKW64, F_OFD_SETLK, F_OFD_SETLKW, F_SETLEASE, F_SETDELEG — pass through the fcntl hook so that the later file-lock hook can enforce them against normalised F_RDLCK, F_WRLCK or F_UNLCK values. An unknown lock type fails closed there.

For F_NOTIFY, removing a watch — a zero event mask, ignoring DN_MULTISHOT — requires nothing. Installing one with any known DN_* event requires FILE_LIST_DIRECTORY. Unknown DN_* bits on a managed descriptor fail closed.

Unmanaged descriptors sit outside the handle check entirely, and an unknown fcntl command on a managed one fails closed.

3.9.4.6 ioctl #

Known ioctls are classified by required right; an unclassified one is allowed if the descriptor carries at least one data right. The 32-bit compat aliases take the same right as their native command, including FS_IOC32_GETFLAGS, FS_IOC32_SETFLAGS, FS_IOC32_GETVERSION, FS_IOC32_SETVERSION and the compat preallocation commands.

Descriptor-local, requiring nothing: FIOCLEX and FIONCLEX, which change close-on-exec state; FIONBIO, which changes nonblocking state; and FIOASYNC, which changes async notification state with Linux and fops validation still applying.

Common VFS:

ioctlRequired right
FIBMAPFILE_READ_DATA
FIGETBSZFILE_READ_ATTRIBUTES
FIFREEZE / FITHAWFILE_WRITE_ATTRIBUTES
FITRIMFILE_WRITE_ATTRIBUTES
FS_IOC_GETFSUUIDFILE_READ_ATTRIBUTES
FS_IOC_GETFSSYSFSPATHFILE_READ_ATTRIBUTES
FS_IOC_GETLBMD_CAPFILE_READ_ATTRIBUTES

FIFREEZE, FITHAW and FITRIM mutate filesystem operational state, and Linux's own CAP_SYS_ADMIN checks still apply on top.

File and object:

ioctlRequired right
FS_IOC_FIEMAPFILE_READ_DATA
FIONREADFILE_READ_DATA on a regular file; any data right otherwise
FS_IOC_GETFLAGSFILE_READ_ATTRIBUTES
FS_IOC_SETFLAGSFILE_WRITE_ATTRIBUTES
FS_IOC_GETVERSIONFILE_READ_ATTRIBUTES
FS_IOC_SETVERSIONFILE_WRITE_ATTRIBUTES
FS_IOC_RESVSP / FS_IOC_RESVSP64FILE_APPEND_DATA or FILE_WRITE_DATA
FS_IOC_UNRESVSP / FS_IOC_UNRESVSP64FILE_WRITE_DATA
FS_IOC_ZERO_RANGEFILE_WRITE_DATA
FICLONE / FICLONERANGEFILE_WRITE_DATA
FIDEDUPERANGEFILE_WRITE_DATA
FIOQSIZEFILE_READ_ATTRIBUTES
FS_IOC_FSGETXATTRFILE_READ_ATTRIBUTES
FS_IOC_FSSETXATTRFILE_WRITE_ATTRIBUTES
FS_IOC_GETFSLABELFILE_READ_ATTRIBUTES
FS_IOC_SETFSLABELFILE_WRITE_ATTRIBUTES
FS_IOC_GET_ENCRYPTION_PWSALTFILE_READ_ATTRIBUTES
FS_IOC_GET_ENCRYPTION_POLICYFILE_READ_ATTRIBUTES
FS_IOC_GET_ENCRYPTION_POLICY_EXFILE_READ_ATTRIBUTES
FS_IOC_SET_ENCRYPTION_POLICYFILE_WRITE_ATTRIBUTES
FS_IOC_ADD_ENCRYPTION_KEYFILE_WRITE_ATTRIBUTES
FS_IOC_REMOVE_ENCRYPTION_KEYFILE_WRITE_ATTRIBUTES
FS_IOC_REMOVE_ENCRYPTION_KEY_ALL_USERSFILE_WRITE_ATTRIBUTES
FS_IOC_GET_ENCRYPTION_KEY_STATUSFILE_READ_ATTRIBUTES
BLKGETSIZE64FILE_READ_ATTRIBUTES
BLKFLSBUFFILE_WRITE_DATA

On directories, FS_IOC_GETFLAGS and FS_IOC_SETFLAGS take the same rights as on files.

Anything unclassified is allowed on any data right. For device nodes, pipes and sockets, device-specific ioctl semantics are outside FACS scope: the node's descriptor is the authorization boundary, and Linux's device-specific validation may still deny.

A pinned inode (§3.6) narrows all of this. Every content-, range- or allocation-mutating ioctl is rejected on one, and so is every ioctl the classifier does not recognise — unknown ioctls fail closed there rather than falling back to the data-right rule.

3.9.4.7 Execution #

Execution is nominally a two-layer check. The mode execute bit is the prerequisite meaning "this file is a program", set by package managers and chmod +x, applying to execve and execveat but not to mmap(PROT_EXEC). The descriptor's FILE_EXECUTE is the access control, gating both.

KACS enforces only the second. Nothing in the kernel module tests the execute mode bit; the prerequisite survives because Linux's own generic_permission() refuses MAY_EXEC on a file with no execute bit set, by way of capable_wrt_inode_uidgid(CAP_DAC_OVERRIDE). The +x requirement is therefore a Linux DAC property that KACS inherits rather than a FACS rule, and it is one of the few decisions where mode bits still matter (§3.10.2).

For descriptor-based exec — execveat with AT_EMPTY_PATH, including on O_PATH handles — a live AccessCheck for FILE_EXECUTE runs against the re-opened file rather than the cached mask being consulted.

3.9.5 File Descriptor Storage

Peios / Advanced Peios / PKM / KACS / FACS

3.9.5.1 Xattr protection #

FACS intercepts every raw xattr operation on the canonical descriptor xattr — security.peios.sd, or system.ntfs_security on NTFS — and denies all three directions.

Writes are denied: all modification goes through the set-security interface. Removal is denied: a descriptor is never detached from a file. And reads are denied, which is the least obvious of the three and the most important. The raw xattr holds the entire descriptor including the SACL, so allowing a read under READ_CONTROL alone would leak SACL content that properly requires ACCESS_SYSTEM_SECURITY. All reads go through kacs_get_sd, which distinguishes the two.

3.9.5.2 Caching #

A validated, parsed descriptor object is cached in the inode's LSM blob, holding immutable self-relative bytes together with a prevalidated component layout — enough for AccessCheck readers never to reparse untrusted storage bytes.

Readers on the AccessCheck path use the RCU-published pointer. Once a current entry exists, a reader does not take the inode mutex merely to run AccessCheck. It either completes the evaluation inside an RCU read-side critical section, or pins the object with a refcount while still under RCU and drops the RCU lock before doing anything that can allocate, sleep or emit an audit event. A pin is acquired with a non-zero refcount check and dropped afterwards.

Writers allocate a new object, swap the pointer atomically, and free the old one after a grace period and after reader pins have drained. No partial read is possible.

Population is lazy, on first access. The xattr is read through an internal kernel path that bypasses the read-denial hook, and the parsed result is installed by compare-and-swap; a thread that loses the race frees its own copy.

Eviction frees the cached descriptor when the inode is evicted, through an RCU-safe callback with the same pin draining, so in-flight permission checks complete before the object goes away.

Invalidation happens on write, but not atomically with it. The set-security path deliberately releases the inode security lock across the xattr write and re-acquires it afterwards to publish the new parsed object, because holding it across the write would invert the i_rwsem ordering the access path requires. Two concurrent set-security calls on one inode are therefore last-writer-wins rather than serialised end to end, and there is a window in which the xattr and the cache disagree. Readers are never blocked, and no reader sees a partially written object — the exposure is which of two racing writes lands, not a torn state.

3.9.5.3 Mount policy classes #

The superblock policy object carries the class (§3.9.1) and, for synthesise-class mounts, an optional mount-level default template.

The default classifier maps from the superblock's filesystem magic. PROC_SUPER_MAGIC and SYSFS_MAGIC are unmanaged: these expose kernel state through inode-shaped handles with no on-disk identity and no descriptor to consult. NULL_FS_MAGIC is unmanaged too — nullfs is the immutable, permanently empty filesystem the kernel mounts as the mount-namespace root, with the mutable rootfs mounted on top of it. It declares no xattr support at all, so it can never carry a descriptor, and its single root inode is immutable and childless: nothing to stamp and nothing to protect.

STRATAFS_SUPER_MAGIC is fixed at facs_deny_missing for the superblock's lifetime, because StrataFS delegates every check to current provider objects and must never synthesise a descriptor for its merged namespace. An attempt to change it fails with EOPNOTSUPP.

RAMFS_MAGIC, NFS_SUPER_MAGIC, MSDOS_SUPER_MAGIC, EXFAT_SUPER_MAGIC, ISOFS_SUPER_MAGIC and CGROUP2_SUPER_MAGIC are facs_synthesize_ephemeral — either no persistent backing at all, or storage with no native descriptor slot.

Everything else, including TMPFS_MAGIC, SQUASHFS_MAGIC, EXT4_SUPER_MAGIC and BTRFS_SUPER_MAGIC, defaults to facs_deny_missing. These can all carry the descriptor xattr natively and are expected to on every inode that participates in access checks.

TMPFS_MAGIC covers both userspace tmpfs mounts and the kernel-mounted instances established before any userspace runs. The latter are not exempt from the default; they are handled by seeding.

3.9.5.4 Kernel-internal mounts #

Two filesystems are mounted by the kernel before any userspace process exists and before anything can call kacs_set_mount_policy or kacs_set_sd: the mutable root filesystem mounted by init_mount_tree, a tmpfs mounted on top of the immutable nullfs namespace root and made / by set_fs_root; and the devtmpfs instance mounted by devtmpfs_init and populated by the kdevtmpfs thread.

Both are TMPFS_MAGIC and therefore facs_deny_missing, and their root inodes are kernel-created, never passing through a userspace-supplied artifact, so they carry no descriptor at the moment they become reachable. To make the class viable the kernel seeds one.

The rootfs root is seeded inside init_mount_tree, immediately after vfs_kern_mount returns and before the mount is published into init_mnt_ns, with the inode's i_rwsem held. The devtmpfs root is seeded inside devtmpfs_init, after vfs_kern_mount and before kdevtmpfs starts, likewise under i_rwsem. The nullfs root is not seeded — it is unmanaged, empty, and incapable of xattr storage.

The seeded descriptor is byte-for-byte identical in both places: owner and group SYSTEM (S-1-5-18), a DACL of one ACCESS_ALLOWED ACE granting GENERIC_ALL to SYSTEM flagged OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE so that inheritance derives a child descriptor for every inode created on the mount afterwards, and no SACL.

The writes go through the kernel-internal xattr path, bypassing both the FACS denial hooks and the LSM setxattr permission hook. They consult no token — at the point either runs there may be no meaningful subject — and the seeded descriptor is the sole authority for the mount until trusted userspace replaces it. They depend on nothing beyond the LSM scaffold that allocates inode and superblock blobs.

These are not exempt from later management: trusted userspace can overwrite the per-inode descriptor or change the superblock's class once it holds the privileges.

3.9.5.5 Boot artifacts #

A filesystem shipped as a boot artifact — a squashfs concatenated into the initrd, a vendor squashfs delivered as a package, a flashed partition image — defaults to facs_deny_missing and the kernel does not seed it. These are already-populated trees the kernel cannot extend at mount time, and in the read-only case cannot extend at all.

The obligation falls on the build pipeline, which emits security.peios.sd on every inode ordinary access checks will reach. mksquashfs, the ext utilities and the standard userland xattr surfaces all preserve security.* natively.

An artifact without descriptors is a packaging defect. FACS treats every missing descriptor on a facs_deny_missing mount as a corruption indicator and denies. The operator path is to rebuild the artifact, or to adopt the superblock under a synthesise class.

3.9.5.6 Administration #

Trusted userspace adopts a mounted filesystem by calling kacs_set_mount_policy on a descriptor naming any object on the target superblock; O_PATH descriptors are valid targets. The change applies to the superblock, not the pathname used to reach it.

The call requires enabled SeTcbPrivilege and marks it used. The public ABI accepts only the three managed classes; unmanaged, unknown values, nonzero reserved flags and malformed arguments all fail closed.

The optional template is accepted only with a synthesise class. It is a complete self-relative descriptor rather than a subset, passes structural validation, and is at most 65535 bytes. A null pointer with zero length clears it. Setting facs_deny_missing clears it and rejects non-empty template input. Pointer and length mismatches and invalid bytes fail before any state changes.

Policy changes are lazy. They do not walk the filesystem and do not stamp anything. The superblock carries a monotonic generation counter, incremented on every successful policy or template replacement. Missing-descriptor, ephemeral-synthetic and not-yet-written-back persistent-synthetic cache entries record the generation they came from and are discarded and repopulated when it changes. Xattr-backed and corrupt-descriptor caches are not made valid by a policy change, and open file descriptions keep their immutable masks.

3.9.5.7 Missing descriptors #

Under facs_deny_missing, no descriptor means deny. Two exceptions keep the repair path open. SeChangeNotifyPrivilege bypasses intermediate traverse checks including on directories with no descriptor, though not explicit chdir(), chroot() or fchdir() use-time checks. And O_PATH opens bypass the open hook entirely, so a file with a missing descriptor can still be acquired as an O_PATH reference — which is exactly the repair route: open(path, O_PATH) then kacs_set_sd with AT_EMPTY_PATH under SeRestorePrivilege.

Under the synthesise classes, a missing descriptor is generated from two sources in order. First, inheritance from the parent: if the parent has one, the inheritance algorithm runs as though a new file were being created. Otherwise the mount-level template, applied where there is no parent descriptor — typically only at the mount root. With no template configured, the fallback grants GENERIC_ALL to SYSTEM and BUILTIN\Administrators and GENERIC_READ | GENERIC_EXECUTE to Everyone, owned by SYSTEM with SYSTEM as group.

Because these files already exist, the accessor is not their creator. Where inheritance needs creator inputs — owner, primary group, default DACL — a synthetic system-policy creator supplies them: the template's owner, group and DACL if one exists, the fallback's otherwise. The accessor's token never affects the synthesised descriptor, and the synthesis path takes no subject token at all.

Inheritance is recursive — a parent whose own descriptor is missing is synthesised first, walking toward the mount root where the template terminates it. The walk is bounded at 32 ancestor levels; a target nested deeper than that below the nearest resolvable ancestor fails closed with EACCES rather than synthesising.

An ephemeral synthesis is cached in the inode blob only and never written back, leaving the original filesystem unmodified. A persistent one is additionally written to the xattr so the medium acquires durable descriptors — but never inline.

3.9.5.8 Deferred write-back #

Synthesis runs holding the FACS inode lock, and writing the xattr takes the inode's i_rwsem. Doing that inline would acquire i_rwsem under the FACS lock, inverting the order the access path requires, and would self-deadlock when synthesis is reached from a metadata operation whose VFS caller already holds i_rwsem. Write-back therefore runs with no FACS or VFS lock held.

Synthesis caches the descriptor immediately and marks the entry pending. The access decision is correct from that cached value the instant synthesis completes — correctness never depends on the xattr reaching disk. The write-back runs later from a task-work callback firing as the triggering syscall returns to userspace, so a persistent descriptor is normally on disk by the time the operation that first observed it missing returns.

A pending entry is generation-tagged exactly like an ephemeral one, so a policy or template change before the write-back discards it and re-synthesises; a stale pre-change descriptor is never pinned to disk.

Write-back is best-effort, and can be because the synthesised descriptor is a deterministic function of the parent or the template — the on-disk xattr is a cache of a recomputable value, not unique state. If it does not happen, because the entry was evicted or the task exited first, the identical descriptor is re-synthesised on next access and retried. A failed or skipped write-back never fails the operation that triggered synthesis. Kernel threads, and a failure to queue the callback, fall back to re-synthesis the same way.

Once written, the next cache miss reads it back as an ordinary xattr-backed descriptor: durable, no longer generation-tagged, and never synthesised again.

An ancestor synthesised only to supply inheritance inputs for a descendant is itself pending, and persists under the same rules when it is next accessed in its own right.

3.9.5.9 Corrupt descriptors #

A descriptor xattr that exists but fails structural validation is corrupt, and the policy is fail-closed: deny all access, do not call AccessCheck, and never treat a truncated DACL as an empty one.

Every encounter emits an audit event, fired exactly once per inode per cache population rather than per access, so a hot corrupt inode does not flood the log.

Recovery is a process holding SeRestorePrivilege calling set-security to overwrite it. Offline repair tools can also rewrite xattrs directly on an unmounted filesystem.

3.9.5.10 NFS client mounts #

NFS is the one managed class where the sole-authority guarantee does not hold. The server enforces its own access control independently: FACS evaluates locally against a synthesised descriptor, and the server may deny I/O that FACS allowed. A locally authorized open() can therefore produce a descriptor whose read() calls fail. This is inherent to network filesystems with server-side enforcement, and nothing suppresses the server's denial.

3.9.6 The Set-Security Interface

Peios / Advanced Peios / PKM / KACS / FACS

All descriptor modification flows through one syscall. The caller provides a file descriptor or path, a bitmask naming which components to modify, and a self-relative descriptor blob carrying the new values.

FlagComponentRequired right
OWNER_SECURITY_INFORMATIONOwner SIDWRITE_OWNER
GROUP_SECURITY_INFORMATIONGroup SIDWRITE_OWNER
DACL_SECURITY_INFORMATIONDiscretionary ACLWRITE_DAC
SACL_SECURITY_INFORMATIONSystem ACLACCESS_SYSTEM_SECURITY
LABEL_SECURITY_INFORMATIONMandatory integrity labelWRITE_OWNER, plus the integrity constraints below

The blob is validated structurally — parseable, well-formed ACEs, valid SIDs, at most 65535 bytes — and then only the indicated components are merged into the existing descriptor. Unindicated components are preserved unchanged.

The input is always one self-relative descriptor subset, never a raw SID or ACL fragment. SACL_SECURITY_INFORMATION and LABEL_SECURITY_INFORMATION cannot be combined in one call, because both target the SACL field with incompatible meanings; the pair fails with EINVAL.

A SACL_SECURITY_INFORMATION write replaces the object's entire SACL. A LABEL_SECURITY_INFORMATION write interprets the input SACL as the label subset only: no SACL component removes the explicit mandatory label and returns the object to the default unlabelled state; a present SACL contains exactly one non-inherit-only SYSTEM_MANDATORY_LABEL_ACE and nothing else; and the object's non-label SACL ACEs are preserved.

After merging, the result still has a non-null owner — the group SID may be null — and a merge that would leave no owner fails.

MIC and PIP apply to these checks. A low-integrity caller cannot modify a high-integrity file's descriptor even where the DACL grants WRITE_OWNER.

3.9.6.1 Ownership #

A new owner may be set only to the caller's own SID, or to a group SID on the token carrying SE_GROUP_OWNER. SeTakeOwnershipPrivilege allows setting ownership to the caller's own SID regardless of what the current descriptor says, and SeRestorePrivilege allows any arbitrary SID.

3.9.6.2 Integrity labels #

Without SeRelabelPrivilege a caller may set a label only at or below its own integrity level; with it, any level.

The constraint applies through both paths — the dedicated label subset, and a label ACE embedded in a full SACL write. A SACL write whose ACL contains a mandatory label ACE raising integrity above the caller's level requires SeRelabelPrivilege exactly as the label path does, even though the SACL component itself is gated only by ACCESS_SYSTEM_SECURITY.

3.9.6.3 The SeRestorePrivilege bypass #

SeRestorePrivilege fires inside the AccessCheck pipeline, so it bypasses the check only where kacs_set_sd runs a live one: an O_PATH descriptor with AT_EMPTY_PATH, a pidfd, a token descriptor with AT_EMPTY_PATH, or a path. On those paths it grants every requested right, WRITE_OWNER, WRITE_DAC and ACCESS_SYSTEM_SECURITY included.

Called on an ordinary file descriptor the required rights are checked against the cached mask instead, no AccessCheck runs, and the privilege has no effect at all. A caller needing the bypass has to use the O_PATH route — which is the mechanism behind backup restoration, administrative repair, and the missing-descriptor repair path (§3.9.5).

3.9.6.4 Mandatory resource attributes #

When a caller modifies the SACL, the existing and new SACLs are compared for changes to SYSTEM_RESOURCE_ATTRIBUTE_ACE entries. An existing attribute carrying CLAIM_SECURITY_ATTRIBUTE_MANDATORY (0x0020) cannot be removed, nor its values modified, without SeTcbPrivilege — and an attempt without it fails the entire call rather than silently dropping the change.

3.9.6.5 Write mechanics #

The updated descriptor is serialised to self-relative binary form and written to the xattr through an internal kernel path bypassing the denial hook, and the in-memory cache is updated. An audit event is emitted if the file's SACL carries a matching audit ACE.

The cache update is not atomic with the xattr write; §3.9.5 describes the lock ordering that forces this and the last-writer-wins window it produces.

3.9.7 The StrataFS Copy-Up Context

Peios / Advanced Peios / PKM / KACS / FACS

StrataFS copy-up is the internal realisation of an operation already authorized against a StrataFS handle — not a second caller-requested operation. KACS therefore provides a kernel-internal context so that the mechanics of copy-up introduce no new rights checks against the task that happens to execute them.

The context exempts KACS caller authorization only. It does not replace credentials, borrow an identity, grant a privilege, bypass another LSM, neutralise an underlying filesystem check, make a read-only mount writable, or suppress immutable, append-only, quota, space, I/O or format errors. Every exemption is a return before the authorize call, and every mutation still goes through the ordinary vfs_* path under mnt_want_write().

3.9.7.1 Admission and lifetime #

Only the in-kernel StrataFS implementation can create or enter a context. There is no userspace surface of any kind — no ABI, file descriptor, token, ioctl, syscall or securityfs control — and the copy-up API is declared in a kernel-private header with no exported symbols.

A context is created only after the StrataFS operation requiring copy-up has passed its complete outer authorization. KACS does not verify that: the context creation call performs no check, and the ordering is satisfied by StrataFS calling in the right order. The context is not itself an alternative authorization path.

The exemption covers namespace creation the outer handle authorises without an add-entry right on the create stratum, so the context is reachable only from a create-enabled mount established by a caller holding CAP_SYS_ADMIN in the initial user namespace. That closure rests on an explicit test of the mounter's user namespace at stack establishment — the capability half contributes nothing to it, because the KACS switchboard discards the target namespace and answers from SeTcbPrivilege alone (§3.10.2). The check is made when the immutable stack is established rather than when a copy-up runs, so descriptor delegation cannot reintroduce acting-task authorization. Reconfiguration cannot re-supply the strata list.

A context attaches to at most one task, and a task carries at most one. It is not inherited by fork(), clone() or execve() — exec explicitly clears it. A refcounted context can be transferred to a kernel worker, but the originating task leaves it first. Leaving, task exit, every error path, and completion all remove the attachment and clear any armed phase.

The interface fails closed on nesting, concurrent attachment, a stale phase, or an object mismatch. A mismatched operation is evaluated normally and neither consumes nor broadens the armed exemption.

3.9.7.2 Object and phase binding #

Creation pins the exact provider path, its current inode, a complete validated copy of its effective descriptor, and the provider-visible security.capability value or its absence. Every later positive path is paired with a pinned inode as well — retaining a dentry alone is insufficient, because an unlink followed by recreation can reinstantiate it over a different inode.

One phase is armed at a time:

PhaseObjects admitted
Source readThe pinned provider only.
CreateOne pinned provider, one destination parent, and either one named negative dentry or one anonymous creation in that parent. Used for both parent materialisation and the staged object.
PopulateThe pinned provider and the one staged object bound to the context.
Publish by linkThe bound staged object, one destination parent, one absent destination dentry.
Publish by renameThe bound staged object and its parent, one destination parent, one absent destination dentry.
CleanupOne bound staging or materialisation dentry and its exact parent.
Orphan cleanupOne positive provider dentry carrying an authenticated stale staging marker, and its exact parent.
Orphan-marker cleanupThe exact positive provider dentry whose authenticated stale marker is being removed after publication.

Path, dentry, inode, parent, object type and operation kind are all compared wherever the hook supplies them — and path comparison includes the mount, so the same dentry reached through a different mount of the same superblock does not match. An inode-only hook can match the pinned inode, but that does not authorize a pathname operation on another dentry, and a phase never acts as a wildcard for other objects of the same filesystem or directory.

The staged binding stays valid only while its dentry names the pinned inode under the pinned staging parent, and that parent still names its own pinned inode. A rename or parent substitution invalidates the populate, protected-metadata and publish exemptions even when the staged dentry and inode are themselves unchanged.

Named creation is bound to the exact final component. Anonymous creation is bound to its parent, expected object type, the attached task, and the single armed create phase — and once created, the anonymous object has to be bound as the staged object before populate, publish or cleanup can use it.

Where the destination is a stacking filesystem creating a real inode below the armed destination dentry, the transition is authenticated at the exact outer dentry, and only the one subsequent real-inode security initialisation of the expected type receives the pinned provider descriptor. The real inode is not the staged binding: the outer inode is separately anchored, through the matching post-create event for an anonymous object or an explicit confirmation of the exact positive outer dentry for a named one. A missing, repeated, mismatched or out-of-order transition fails closed, and an inner post-create event from the real filesystem is rejected as the outer anchor by superblock comparison.

KACS retains the exact path, inode, parent path and parent inode of every named object whose creation it admits. Cleanup can be armed only for one of those or for the exact bound staging object; an unrelated positive dentry fails with ESTALE. The cleanup setup call is not itself authority to choose a deletion victim.

After an atomic rename or link publishes the staged inode, the staging identity can be rebound to the published path, but only while mount, inode, parent dentry and pinned parent inode all still match. The rebind API exists and is not called by StrataFS: the link case is rebound internally by the publish path, and the rename case relies on publish preserving the dentry. That holds because publication always renames within a single parent. A cross-directory publish rename would silently invalidate the staging binding, since the rename-publish entry point does not require the destination parent to equal the staging parent — currently unreachable, but the guard is the caller's discipline rather than the interface's.

Named staging entries carry security.peios.stratafs_staging. Caller-originated writes and removals of it are denied. A write or removal is admitted only on the exact bound staging inode during populate, or on the exact orphan-marker object during authenticated recovery — where the predicate admits a write as well as a removal, being shared between the setxattr and removexattr hooks. Probing the marker for recovery is a kernel-only raw read conveying no caller authority, and orphan deletion is bound to the exact dentry, inode, parent dentry and parent inode supplied when the phase was armed, so an arbitrary name sharing the staging prefix never matches. Recovery processes at most 128 entries per batch.

3.9.7.3 Exempt operations #

For a matching object in a matching phase, the ordinary AccessCheck, cached-grant check, privilege check and caller-access audit decision are omitted at these points:

Copy-up actionEnforcement points
Open and read provider data or a symlink targetsecurity_inode_permission, security_file_open, security_file_permission, security_inode_readlink
Read provider attributes and extended attributesthe inode and file getattr, setattr-preflight, getxattr and listxattr paths as applicable
Create a staged file, directory or symlink, and materialise parentssecurity_inode_permission, security_inode_create, security_inode_mkdir, security_inode_symlink, security_inode_init_security; for a stacking destination also security_dentry_create_files_as and, for an anonymous object, security_inode_post_create_tmpfile
Populate staged data, attributes and eligible non-descriptor xattrssecurity_inode_permission, security_file_open, security_file_permission, the inode and file setattr paths, setxattr and removexattr. security.capability uses only the dedicated clone call below.
Publishsecurity_inode_permission on the exact staged object or destination parent, then security_inode_link or security_inode_rename
Remove staging or roll back materialised entriessecurity_inode_permission on the exact parent, then security_inode_unlink or security_inode_rmdir

A security_inode_permission or security_file_permission match also matches the requested mask. Provider access is read-only — write, append and non-directory execute requests never match it. Staging access is limited to the read, write and append masks population needs, plus execute and chdir for directories, and namespace parents to the write and traverse masks the one armed action needs, plus open and chdir. Unknown mask bits fail closed.

The list is exhaustive. The context does not exempt execution, memory mapping, ioctl, locking, arbitrary fcntl, device access, process access, socket access, mount operations, or operations on a descriptor installed into a userspace file table — each verified by the absence of any copy-up branch on those paths. Internal copy-up files are additionally denied statfs, truncate, fsync and fallocate.

A file opened internally under the exemption is marked copy-up-internal in its blob and carries a granted mask of zero. It is usable without a cached caller grant only while the same context is attached and its phase admits that exact file. Use after phase completion, from another task, or after transfer through SCM_RIGHTS fails closed. Each armed phase has a distinct monotonically increasing generation — overflowing it fails closed — and an internal file is sealed to the generation it was opened in, so a later phase of the same kind after a worker transfer cannot reactivate an older file. Final release drops the context reference.

The one exception is the read-only provider-directory cursor used by bounded staging recovery. StrataFS may retain that file while it leaves the context to clean one captured batch, then resume it after re-entering the same context and arming a new source-read phase. Only a directory file already marked internal for that exact context, still naming the pinned provider path and inode, opened for read without write, execute or path-only mode, and inside that attached source-read phase, resumes; it is then resealed to the new generation. Between phases it is unusable and conveys no deletion authority.

3.9.7.4 Backing-file adoption #

Once a regular-file copy is published, one exact copy-up-internal backing file can become the backing file for the outer StrataFS open description. The staging binding is verified, and the backing file's recorded user path is confirmed to be that same outer description; the outer description's immutable granted and continuous-audit snapshot is then copied across and the internal marker removed. This is a transfer of authority already attached to the descriptor, not a new AccessCheck against the task that caused the copy-up, which is what preserves descriptor delegation when that task is not the opener. It is one-shot, and requires the backing file mode.

The ordering is worth noting: StrataFS performs the adoption before publishing the anonymous staged object rather than after, so what is verified is the still-bound staging binding rather than a published one.

3.9.7.5 Deferred deletion #

Delete-on-close is authorized when armed and recorded on the open file description (§3.9.2). At final close there is no re-authorization against the closing task. Instead a synchronous, non-nesting internal deletion scope is armed for that exact outer file, and StrataFS binds it once to the exact provider parent, dentry and inode selected from the descriptor's settled provider. Only the corresponding outer and lower unlink calls match, and the scope is cleared on every return.

The mechanism never turns an ordinary close into deletion authority, never admits a different provider entry, and never permits unlinking an entry that no longer names the descriptor's inode. If the original entry has disappeared or changed identity, the deletion is already complete and no exemption is used.

3.9.7.6 Exact protected-metadata cloning #

KACS owns descriptor cloning rather than StrataFS raw-xattr code. Before a create phase is armed, the provider's complete effective descriptor is resolved and pinned using the ordinary mount-policy and corrupt-descriptor rules (§3.9.5). A missing, corrupt, unresolvable, oversized or unsupported descriptor fails the phase before the destination is created.

For a matching inode security initialisation, an exact byte-for-byte copy of the pinned descriptor is installed instead of inheritance running. The canonical xattr is installed as part of inode creation and the inode's validated parsed cache is seeded from identical bytes before the inode becomes usable. Failure to allocate, validate or install either representation fails creation — there is no window in which a named staging inode carries an inherited or otherwise weaker descriptor.

On a stacking destination the same applies to the real inode created below the outer dentry, with the authenticated outer transition as the only authority to redirect installation there. The pinned bytes are installed and cached during the real inode's creation, before the outer object is confirmed or bound; inheriting the parent's descriptor and repairing it afterwards would not be conforming.

The canonical descriptor is not copied by ordinary xattr enumeration — it is reported as cancelled so a stacking filesystem discards it — and raw canonical getxattr and setxattr stay denied even inside the context, with the hook-side denial evaluated before any phase match. The context does not override the unconditional denial of POSIX ACL mutation either.

Raw setxattr, including through an internal copy-up file, remains unable to install security.capability. Where the provider has one, StrataFS calls the dedicated clone entry point during populate, after any operation that might clear file capabilities. That call accepts only a kernel buffer exactly matching the pinned value, under the same user namespace it was pinned in, for the still-bound staged inode. It re-reads the provider immediately before installing and fails with ESTALE if the value or its presence changed.

The call copies the caller's buffer before comparing, and installs with XATTR_CREATE through the ordinary VFS path, so mount-idmap conversion, xattr validation, filesystem permission and format checks and other LSM checks all still apply. The otherwise-dead CAP_SETFCAP gate is satisfied only synchronously inside that validated call, and the corresponding setxattr re-entry is admitted only after the first hook matches the exact staged inode. The capability answer targets only the pinned caller namespace or the staged inode's filesystem namespace, and the condition is cleared on every return. Nothing is installed when the provider had no attribute, a different one, or an unreadable or invalid one. The exception does not revive exec-time file-capability grants.

There is one asymmetry here: the removal path does not reject security.capability the way the set path does, so an internal copy-up descriptor can remove it from the staged object during populate.

Other eligible provider xattrs follow StrataFS's replication rules while KACS's caller checks on their source and staging objects are exempted as above. An ineligible xattr that cannot be replicated triggers StrataFS's ordinary copy-up failure rule rather than being silently omitted.

3.9.7.7 Auditing #

The outer authorized handle operation remains subject to ordinary audit. No second caller AccessCheck or privilege-use audit is emitted for an exempt internal sub-operation, since that would attribute StrataFS mechanics to a caller decision that never happened — and internal files carry a continuous-audit mask of zero, so they generate no per-operation events either. The CAP_SETFCAP satisfaction path returns before the capability check that would record privilege use.

StrataFS decides when its own copy-up lifecycle and failure events occur; KACS supplies the kernel-only emitter, so KMES stamps each event with the effective token of the task whose operation caused it (§2.2). Two of those emissions are best-effort: an allocation failure, or an operation string that is empty or over 64 bytes, drops the event silently.

Denied mismatches, and operations performed with no active matching context, follow the ordinary authorization and audit paths.

3.10.1 Credential Projection

Peios / Advanced Peios / PKM / KACS / The Linux Credential Model

KACS tokens are the sole identity-based authorization mechanism, and Linux applications do not know tokens exist. They call getuid(), getgid() and getgroups(), read /proc/self/status, and assume those numbers determine their access. KACS projects token identity onto standard Linux credentials so unmodified applications work.

When a token is installed on a process, the process's Linux credentials are set to match. The numbers themselves are already on the token: the user SID's projected uid, the primary group SID's projected gid, and a projected supplementary gid per group SID are all computed by authd when the token is minted, and KACS copies them.

KACS never resolves a SID to a number itself. It holds no directory handle and consults nothing at install time — which is what makes projection cheap enough to do on every credential change, and what keeps a name-service outage from being able to change what a running process may do.

How a SID becomes a number is therefore not KACS's rule to state, and this chapter deliberately does not restate it. The authority is the principal source interface's numeric scope (PSPU §2): identifiers are computed — an authority grants a source a band (base, count) and derives base + r from a relative identifier — rather than looked up per principal, and a source's assertion outside its band is refused rather than clamped.

65534 does appear in KACS, but not as a "no attribute was set" fallback: it is ANONYMOUS_PROJECTED_ID, what the projected-id accessors return for the anonymous identity and for an invalid token pointer. It is a sentinel for no identity, not a default for an identity whose number could not be found.

The consequences are mostly convenient ones. No process runs as UID 0 unless it holds the SYSTEM token — enforced, not merely expected: a token creation naming a projected UID of 0 with any user SID other than S-1-5-18 is rejected, and the projection path refuses it again at install time. Home directories work naturally, because getpwuid(getuid()) returns the right answer when the UID is real and consistent with NSS. And different services get different UIDs, which is incidental defence in depth alongside KACS's own enforcement.

3.10.1.1 Projection is one-way #

Token state flows into credential fields and never the reverse. The projected credentials are observational compatibility data; the token is the authority. The setuid family restores the old credential rather than deriving a token from it (§3.10.3).

Projection reflects all groups regardless of enabled state, so adjusting groups never triggers recalculation.

Projected credentials reflect the effective token — the impersonated one during impersonation, the primary one otherwise. When a service thread impersonates a client and creates a file, the file is owned by the client's projected UID, quota is charged to the client, and audit attributes to the client.

The two accessors deliberately disagree during impersonation. current_fsuid() reads the projected UID from the effective credential, so it yields the client's UID; getuid() reads the primary credential's UID, so it yields the service's. During impersonation getuid() returns the service and current_fsuid() returns the client, and that is the intended behaviour rather than an inconsistency.

One caveat applies to a credential carrying no token at all — a blank credential, or one created before KACS initialised. current_fsuid() falls back to cred->fsuid in that case, and the capability switchboard denies before consulting the ALLOW list (§3.10.2), so Linux DAC becomes authoritative for such a task.

3.10.1.2 Precomputed values #

Every token carries precomputed projected UID and GID values, calculated by authd at creation and stored on the token. KACS never resolves a SID-to-UID mapping at runtime; the accessors are pure field reads.

Because SIDs are one namespace while Linux UIDs and GIDs are two, authd allocates from a single unified counter across all principal types rather than a separate one per namespace, so every SID projects to a unique number whichever Linux namespace it lands in. A user and a group can never collide on a number, which is what lets one SID answer both getuid() and getgid() questions without ambiguity.

3.10.2 DAC Neutralisation

Peios / Advanced Peios / PKM / KACS / The Linux Credential Model

Linux evaluates DAC — UID, GID and mode-bit checks — before consulting LSM hooks. If DAC denies an operation the hook never fires, and KACS cannot override a DAC denial: LSM hooks are restrictive, able to further deny what DAC would allow but never to grant what DAC has refused.

KACS therefore neutralises DAC so the hooks always fire. Every process receives a set of Linux capabilities that bypass the DAC gates, and those capabilities are mandatory substrate rather than grants.

3.10.2.1 The capability switchboard #

Linux's capabilities fall into three categories, and security_capable() is authoritative for all of them — the raw capability sets on struct cred are compatibility-visible state that answers nothing.

ALLOW capabilities exist to override UID-based permission checks on operations KACS enforces through its own hooks, so DAC never blocks something KACS will evaluate independently:

CAPNameRationale
0CAP_CHOWNKACS file hooks enforce.
1CAP_DAC_OVERRIDEKACS file hooks enforce.
2CAP_DAC_READ_SEARCHKACS file hooks enforce.
3CAP_FOWNERKACS file hooks enforce.
4CAP_FSETIDKACS file hooks enforce.
5CAP_KILLThe task_kill hook enforces.
6CAP_SETGIDCosmetic under KACS.
7CAP_SETUIDCosmetic under KACS.
11CAP_NET_BROADCASTUnused in modern kernels.
15CAP_IPC_OWNERKACS IPC hooks enforce.
28CAP_LEASEKACS file hooks enforce.

PRIVILEGE capabilities gate operations no other KACS hook covers, and map to a KACS privilege:

CAPNamePrivilege
9CAP_LINUX_IMMUTABLESeTcbPrivilege
10CAP_NET_BIND_SERVICESeBindPrivilegedPortPrivilege
12CAP_NET_ADMINSeTcbPrivilege
13CAP_NET_RAWSeTcbPrivilege
14CAP_IPC_LOCKSeLockMemoryPrivilege
16CAP_SYS_MODULESeLoadDriverPrivilege
17CAP_SYS_RAWIOSeTcbPrivilege
18CAP_SYS_CHROOTSeTcbPrivilege
19CAP_SYS_PTRACESeDebugPrivilege
20CAP_SYS_PACCTSeTcbPrivilege
21CAP_SYS_ADMINSeTcbPrivilege (but see the mount note below)
22CAP_SYS_BOOTSeShutdownPrivilege
23CAP_SYS_NICESeIncreaseBasePriorityPrivilege
24CAP_SYS_RESOURCESeIncreaseQuotaPrivilege
25CAP_SYS_TIMESeSystemtimePrivilege
26CAP_SYS_TTY_CONFIGSeTcbPrivilege
27CAP_MKNODSeTcbPrivilege
29CAP_AUDIT_WRITESeAuditPrivilege
30CAP_AUDIT_CONTROLSeSecurityPrivilege
33CAP_MAC_ADMINSeSecurityPrivilege
34CAP_SYSLOGSeTcbPrivilege
35CAP_WAKE_ALARMSeTcbPrivilege
36CAP_BLOCK_SUSPENDSeTcbPrivilege
37CAP_AUDIT_READSeSecurityPrivilege
38CAP_PERFMONSeSystemProfilePrivilege or SeProfileSingleProcessPrivilege or SeLoadDriverPrivilege
39CAP_BPFSeTcbPrivilege
40CAP_CHECKPOINT_RESTORESeTcbPrivilege

CAP_PERFMON is the one OR-mapped entry: the Linux capability genuinely spans several Peios privilege tiers, and no single privilege covers everything it gates. The check succeeds if the caller holds any of the three, and every one it holds is marked used. Per-operation enforcement then happens at the relevant syscall hook, which checks the specific privilege the specific operation needs (§3.7). OR-mapping stops the capability ceiling manufacturing false denials; it grants nothing the holder does not already have.

CAP_SYS_ADMIN maps to SeTcbPrivilege and is not OR-mapped, which would be the obvious way to let administrators mount and is the wrong one: CAP_SYS_ADMIN gates dozens of unrelated operations, so widening it would hand out far more than mounting.

Mounting is handled outside the capability table instead. may_mount() (fs/namespace.c, patched) calls pkm_kacs_may_manage_volumes(), which accepts SeManageVolumePrivilege or SeTcbPrivilege, before falling back to the ordinary CAP_SYS_ADMIN check. Every other CAP_SYS_ADMIN caller still needs the TCB.

It has to be asked there rather than through the sb_mount LSM hook: may_mount()'s capability check runs before security_sb_mount(), so an LSM is never consulted about a mount the capability check already refused. A hook can narrow that decision; it cannot widen it.

CAP_SYS_BOOT carries an extra condition the table cannot express: a token whose logon session is of a remote origin — Network, NetworkCleartext or NewCredentials — additionally requires SeRemoteShutdownPrivilege.

DENY capabilities are refused unconditionally, whatever privilege the caller holds: CAP_SETPCAP (8) and CAP_SETFCAP (31), because capabilities are dead under KACS, and CAP_MAC_OVERRIDE (32), because KACS is the active LSM and must not be bypassable.

An unmapped or unknown capability is denied by default. The switchboard fails closed.

3.10.2.2 Compatibility state #

Programs may inspect the credential capability sets with capget() or /proc/<pid>/status, and may attempt to mutate the non-ALLOW subset with capset() or prctl(). None of that is authoritative.

capget() and /proc/<pid>/status report the ALLOW substrate as present in the effective, permitted and inheritable sets — reported as CapEff, CapPrm and CapInh, and additionally CapBnd, by the proc interface. Non-ALLOW bits present in the credential state may also be reported, but grant no authority. CapAmb reports raw Linux ambient state; the ALLOW substrate does not depend on ambient capabilities.

The strict invariant is that the ALLOW set is mandatory substrate and has to survive wherever Linux capability mechanics would otherwise drop it out from under KACS. capset() rejects any request clearing an ALLOW capability from the effective, permitted or inheritable sets, and bounding-set drops and ambient manipulation reject anything that would clear or exclude one. The implementation is slightly stricter than that: an ambient raise of an ALLOW capability is refused too, not only a clear.

After that validation, capset() follows ordinary Linux ambient behaviour — ambient bits no longer present in both the requested permitted and inheritable sets may be cleared. Because requests clearing ALLOW bits from permitted or inheritable are denied outright, that intersection can never indirectly clear an existing ALLOW ambient bit.

Direct mutation of non-ALLOW state is therefore compatibility-only. It may change what capget() reports and cannot change the authority answer for any capability-gated operation.

3.10.2.3 Neutralised native paths #

Native commoncap helpers that make raw capability-subset decisions before a KACS hook are neutralised where KACS has an authoritative hook of its own. That covers the subset gates in ptrace access, PTRACE_TRACEME, and task_setnice, task_setscheduler and task_setioprio — each replaced by an unconditional allow, leaving the KACS process-descriptor and PIP hooks to decide. Capability checks that reach security_capable() directly stay under the switchboard.

For raw xattr operations the FACS metadata hooks are authoritative, so the native security-xattr capability prechecks that would run first are skipped. This does not revive Linux file capabilities: installing or replacing non-empty security.capability data stays denied by the dead CAP_SETFCAP policy, with the single exception of the KACS-owned StrataFS clone (§3.9.7), and exec-time file-capability grants remain suppressed. Removing stale metadata goes through the ordinary FILE_WRITE_EA path.

One structural wrinkle: security_capable() reaches the KACS switchboard twice — once through the patched commoncap entry point and once through the KACS capable hook — so a privilege consulted this way is marked used twice. The authorization answer is unaffected; the privilege-use accounting double-counts.

3.10.2.4 The LSM stack #

MAC LSMs — SELinux, AppArmor, SMACK, TOMOYO — and the BPF LSM have to be disabled. They would independently deny operations from their own label and policy systems, undermining KACS's claim to be the sole identity-based authorization mechanism and FACS's to be the sole file access authority. Non-MAC LSMs are permitted: landlock, lockdown, yama and integrity make no identity-based access decisions and stack safely.

The check is made at initialisation and KACS refuses to activate if it fails — but it is a build-configuration test rather than an inspection of the live LSM stack. It tests whether each conflicting LSM is enabled in the kernel config, and never parses CONFIG_LSM or enumerates what is actually registered.

3.10.3 setuid Behaviour

Peios / Advanced Peios / PKM / KACS / The Linux Credential Model

Under KACS a UID has no security properties whatever. It is not consulted in any access decision, appears in no descriptor, and is referenced by no ACE. It is a compatibility value stored in struct cred for the sole purpose of answering getuid().

3.10.3.1 The setuid syscalls #

The setuid family — setuid, setgid, setresuid, setresgid, setgroups — changes Linux credentials.

Without SeAssignPrimaryTokenPrivilege, which is the common case, the call is a silent no-op. It returns success, and every credential field is restored from the old credential: UIDs, GIDs, supplementary groups, capabilities. Neither the credential nor the token changes, and the process's authority before and after is identical.

The silent success preserves consistency between the visible UID and the KACS identity. Changing the UID without changing the token would produce subtle failures — the wrong home directory from getpwuid(), for instance — for no security benefit.

With SeAssignPrimaryTokenPrivilege the design calls for the credential change to trigger a full identity swap, redirected to authd to obtain a token for the target UID's principal, so that both token and credential change together.

That is not what happens. A caller holding the privilege receives EOPNOTSUPP and the call fails. There is no authd redirect anywhere in the LSM. The practical effect is that the privileged path is unavailable rather than dangerous: a TCB component cannot change identity this way, and has to install a token directly instead (§3.2.3).

3.10.3.2 The setuid bit on exec #

The filesystem setuid bit tells the kernel to change the effective UID to the file owner's on exec.

Without the privilege, the Linux-visible UID and GID slots change to the file owner's identity while the token is untouched — a cosmetic escalation in which the process sees geteuid() == 0 while KACS continues to enforce the original token. Concretely uid and suid are set from euid, the GID counterparts mirror that, fsuid and fsgid are carried over unchanged from the old credential, and the token is cloned as-is.

With the privilege, the design calls for the slots and the token to change — genuine escalation. As with the syscall, this is not implemented: an exec that would change UID or GID under a token holding SeAssignPrimaryTokenPrivilege returns EOPNOTSUPP and the exec fails.

The asymmetry between the two mechanisms is intentional in the design. setuid() is de-escalation, so leaving everything unchanged is the safe failure mode. The setuid bit is escalation, and the target binary expects the euid and will check it — sudo verifies geteuid() == 0 — so the euid has to change for the binary to function at all.

MechanismWith privilegeWithout privilege
setuid() syscallDesigned: all UIDs and token change. Actual: fails with EOPNOTSUPP.Silent no-op
Setuid bit on execDesigned: euid/suid and token change. Actual: exec fails with EOPNOTSUPP.euid/suid change, token unchanged

3.10.3.3 The current_fsuid patch #

The kernel calls current_fsuid() whenever it needs a UID for a filesystem operation — file creation ownership, quota tracking, keyring lookup, NFS credentials. KACS redefines it, along with current_fsgid() and current_fsuid_fsgid(), to return the projected value from the effective token rather than cred->fsuid.

So files are created owned by the projected UID, quotas track against it, per-user keyrings are keyed by it, and an NFS server sees the real identity rather than UID 0.

3.10.3.4 Compatibility gaps #

The privilege-drop pattern. A daemon that calls setuid(target) and then checks getuid() != target to confirm the drop sees success returned with the UID unchanged. This is intentional — UIDs carry no security meaning here — and software ported to Peios should use KACS-native token operations for privilege management instead.

Direct capability manipulation. Software that manipulates capabilities with capset() and capget(), writes seccomp filters, or inspects its own capability set may behave unexpectedly (§3.10.2).

setfsuid() is a no-op for filesystem purposes, since current_fsuid() ignores cred->fsuid; and cosmetic setuid-bit exec transitions do not flow into the projected values either.

access() and faccessat() use the effective token rather than the real credential — the entire native credential-override machinery those calls normally use is compiled out. The concept of a "real identity" separate from the acting one does not exist in KACS.

SO_PEERCRED returns projected UIDs rather than token information, and because the switchboard allows CAP_SETUID, cosmetic UID forgery in SCM_CREDENTIALS is possible. Both are Linux compatibility metadata, not peer-token authorities: a service needing authoritative identity uses stream or seqpacket peer-token capture, or explicit token descriptor passing (§3.5.3).

Legacy auditd records projected UIDs, so KACS audit through KMES replaces Linux audit for security-relevant logging.

A uid0 utility — running a program with cred->uid forced to 0 for legacy programs that refuse to start otherwise — is described in the design and does not exist in the tree. The kernel-side guarantee it would rely on does hold: current_fsuid() ignores cred->uid entirely, so even with such a utility active, filesystem operations would use the projected UID and files would be owned by the real user.

Appendix 3.A KACS ABI Reference

Peios / Advanced Peios / PKM / KACS

Every name, value, offset and size in this appendix is generated from pkm/uapi/pkm/ by pkm/tools/gen-kacs-abi.py, with struct layouts measured by compiling a probe against the real headers. Regenerate it whenever the ABI changes; do not edit it by hand.

The names here are the ones a program actually compiles against. Everything about the ABI a compiler cannot measure -- token query payload shapes, the specification spellings that differ from these names, what is documented elsewhere, and the kernel configuration -- is in the notes appendix, §3.D, which this generator does not touch.

3.A.1 Syscall numbers #

Signatures are read from the SYSCALL_DEFINE sites in pkm/kacs/.

NumberConstantSignature
1000SYS_KACS_OPEN_SELF_TOKENkacs_open_self_token(unsigned int flags, u32 access_mask)
1001SYS_KACS_OPEN_PROCESS_TOKENkacs_open_process_token(int pidfd, u32 access_mask)
1002SYS_KACS_OPEN_THREAD_TOKENkacs_open_thread_token(int pidfd, int tid, u32 access_mask)
1003SYS_KACS_CREATE_TOKENkacs_create_token(const void __user *spec, size_t spec_len)
1004SYS_KACS_CREATE_LOGON_SESSIONkacs_create_logon_session(const void __user *spec, size_t spec_len)
1005SYS_KACS_SET_PSBkacs_set_psb(int pidfd, u32 mitigations)
1006SYS_KACS_DESTROY_EMPTY_LOGON_SESSIONkacs_destroy_empty_logon_session(u64 auth_id)
1010SYS_KACS_OPEN_PEER_TOKENkacs_open_peer_token(int sock_fd)
1011SYS_KACS_IMPERSONATE_PEERkacs_impersonate_peer(int sock_fd)
1012SYS_KACS_REVERTkacs_revert(void)
1013SYS_KACS_SET_IMPERSONATION_LEVELkacs_set_impersonation_level(int sock_fd, u32 level)
1020SYS_KACS_OPENkacs_open(int dirfd, const char __user *path, struct kacs_open_how __user *uhow, size_t howsize, u32 __user *status_out)
1021SYS_KACS_GET_SDkacs_get_sd(int dirfd, const char __user *path, u32 security_info, void __user *buf, u32 buf_len, u32 flags)
1022SYS_KACS_SET_SDkacs_set_sd(int dirfd, const char __user *path, u32 security_info, const void __user *sd_buf, u32 sd_len, u32 flags)
1023SYS_KACS_ACCESS_CHECKkacs_access_check(const void __user *uargs)
1024SYS_KACS_ACCESS_CHECK_LISTkacs_access_check_list(const void __user *uargs, struct kacs_node_result __user *results, u32 results_count)
1025SYS_KACS_SET_CAAPkacs_set_caap(const void __user *policy_sid, u32 policy_sid_len, const void __user *spec, u32 spec_len)
1026SYS_KACS_GET_MOUNT_POLICYkacs_get_mount_policy(int fd, struct kacs_mount_policy_args __user *uargs, size_t argsize)
1027SYS_KACS_SET_MOUNT_POLICYkacs_set_mount_policy(int fd, struct kacs_mount_policy_args __user *uargs, size_t argsize)

uapi/pkm/syscall.h also registers the KMES and LCS numbers, 1090–1102, documented in their own chapters.

3.A.2 Structure layouts #

3.A.2.1 struct kacs_query_args #

Total size 16 bytes.

OffsetSizeTypeField
04__u32token_class
44__u32buf_len
88__u64buf_ptr

3.A.2.2 struct kacs_adjust_privs_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32count
44__u32_pad
88__u64data_ptr
168__u64previous_enabled

3.A.2.3 struct kacs_priv_entry #

Total size 8 bytes.

OffsetSizeTypeField
04__u32luid
44__u32attributes

3.A.2.4 struct kacs_adjust_groups_args #

Total size 144 bytes.

OffsetSizeTypeField
04__u32count
44__u32_pad
88__u64data_ptr
16128__u64``[16]previous_state

3.A.2.5 struct kacs_duplicate_args #

Total size 16 bytes.

OffsetSizeTypeField
04__u32access_mask
44__u32token_type
84__u32impersonation_level
124__s32result_fd

3.A.2.6 struct kacs_group_entry #

Total size 8 bytes.

OffsetSizeTypeField
04__u32index
44__u32enable

3.A.2.7 struct kacs_adjust_default_args #

Total size 16 bytes.

OffsetSizeTypeField
08__u64dacl_ptr
84__u32dacl_len
122__u16owner_index
142__u16group_index

3.A.2.8 struct kacs_restrict_args #

Total size 40 bytes.

OffsetSizeTypeField
08__u64privs_to_delete
84__u32num_deny_indices
124__u32num_restrict_sids
164__u32data_len
204__u32flags
248__u64data_ptr
324__s32result_fd
364__u32_pad

3.A.2.9 struct kacs_link_tokens_args #

Total size 16 bytes.

OffsetSizeTypeField
04__s32elevated_fd
44__s32filtered_fd
88__u64logon_session_id

3.A.2.10 struct kacs_get_linked_token_args #

Total size 4 bytes.

OffsetSizeTypeField
04__s32result_fd

3.A.2.11 struct kacs_access_check_args #

Total size 136 bytes.

OffsetSizeTypeField
04__u32caller_size
44__s32token_fd
88__u64sd_ptr
164__u32sd_len
204__u32desired_access
244__u32mapping_read
284__u32mapping_write
324__u32mapping_execute
364__u32mapping_all
408__u64self_sid_ptr
484__u32self_sid_len
524__u32privilege_intent
568__u64object_tree_ptr
644__u32object_tree_count
684__u32_pad0
728__u64local_claims_ptr
804__u32local_claims_len
844__u32_pad1
888__u64granted_out_ptr
964__u32pip_type
1004__u32pip_trust
1048__u64audit_context_ptr
1124__u32audit_context_len
1164__u32_pad2
1208__u64continuous_audit_out_ptr
1288__u64staging_mismatch_out_ptr

3.A.2.12 struct kacs_object_type_entry #

Total size 20 bytes.

OffsetSizeTypeField
02__u16level
22__u16_reserved
416__u8``[16]guid

3.A.2.13 struct kacs_node_result #

Total size 8 bytes.

OffsetSizeTypeField
04__u32granted
44__s32status

3.A.2.14 struct kacs_open_how #

Total size 32 bytes.

OffsetSizeTypeField
04__u32desired_access
44__u32create_disposition
84__u32create_options
124__u32flags
168__u64sd_ptr
244__u32sd_len
284__u32__pad

3.A.2.15 struct kacs_mount_policy_args #

Total size 32 bytes.

OffsetSizeTypeField
04__u32policy
44__u32flags
84__u32generation
124__u32__pad0
168__u64template_sd_ptr
244__u32template_sd_len
284__u32__pad1

3.A.2.16 struct kacs_generic_mapping #

Total size 16 bytes.

OffsetSizeTypeField
04__u32read
44__u32write
84__u32execute
124__u32all

3.A.3 Token constants #

From uapi/pkm/token.h.

kacs_open_self_token (SYS_KACS_OPEN_SELF_TOKEN) flags.

ConstantValue
KACS_TOKEN_OPEN_REAL0x01 (1)

Per-handle token rights (the low 16 bits of a token access mask).

ConstantValue
KACS_TOKEN_ASSIGN_PRIMARY0x0001 (1)
KACS_TOKEN_DUPLICATE0x0002 (2)
KACS_TOKEN_IMPERSONATE0x0004 (4)
KACS_TOKEN_QUERY0x0008 (8)
KACS_TOKEN_QUERY_SOURCE0x0010 (16)
KACS_TOKEN_ADJUST_PRIVS0x0020 (32)
KACS_TOKEN_ADJUST_GROUPS0x0040 (64)
KACS_TOKEN_ADJUST_DEFAULT0x0080 (128)
KACS_TOKEN_ADJUST_INTERACTIVITY_SCOPE0x0100 (256)
KACS_TOKEN_ALL_ACCESS0x000F01FF

Token ioctl interface identifier.

ConstantValue
KACS_IOC_MAGIC0x4B (75)

kacs_priv_entry.attributes bits.

ConstantValue
KACS_PRIVILEGE_ATTR_ENABLED0x00000002 (2)
KACS_PRIVILEGE_ATTR_REMOVED0x00000004 (4)

kacs_adjust_privs bulk-reset flag (not a per-entry attribute).

ConstantValue
KACS_PRIVILEGE_RESET_ALL_DEFAULTS0x80000000

kacs_restrict_args.flags bits.

ConstantValue
KACS_TOKEN_RESTRICT_WRITE_RESTRICTED0x00000001 (1)

Token type (KACS_TOKEN_CLASS_TYPE).

ConstantValue
KACS_TOKEN_TYPE_PRIMARY0x01 (1)
KACS_TOKEN_TYPE_IMPERSONATION0x02 (2)

Impersonation level (KACS_TOKEN_CLASS_IMPERSONATION_LEVEL).

ConstantValue
KACS_IMLEVEL_ANONYMOUS0x00 (0)
KACS_IMLEVEL_IDENTIFICATION0x01 (1)
KACS_IMLEVEL_IMPERSONATION0x02 (2)
KACS_IMLEVEL_DELEGATION0x03 (3)

Elevation type (KACS_TOKEN_CLASS_ELEVATION_TYPE).

ConstantValue
KACS_ELEVATION_DEFAULT0x01 (1)
KACS_ELEVATION_FULL0x02 (2)
KACS_ELEVATION_LIMITED0x03 (3)

Mandatory-policy bits (KACS_TOKEN_CLASS_MANDATORY_POLICY).

ConstantValue
KACS_TOKEN_MANDATORY_POLICY_NO_WRITE_UP0x00000001 (1)
KACS_TOKEN_MANDATORY_POLICY_NEW_PROCESS_MIN0x00000002 (2)

Per-token audit-policy bits — the create-token spec audit_policy field (KACS_TOKEN_SPEC_OFF_AUDIT_POLICY). They select which access-check outcomes the token's object accesses generate audit events for.

ConstantValue
KACS_AUDIT_POLICY_OBJECT_ACCESS_SUCCESS0x00000001 (1)
KACS_AUDIT_POLICY_OBJECT_ACCESS_FAILURE0x00000002 (2)
KACS_AUDIT_POLICY_PRIVILEGE_USE_SUCCESS0x00000004 (4)
KACS_AUDIT_POLICY_PRIVILEGE_USE_FAILURE0x00000008 (8)

Logon type (KACS_TOKEN_CLASS_LOGON_TYPE).

ConstantValue
KACS_LOGON_TYPE_INTERACTIVE2
KACS_LOGON_TYPE_NETWORK3
KACS_LOGON_TYPE_BATCH4
KACS_LOGON_TYPE_SERVICE5
KACS_LOGON_TYPE_NETWORK_CLEARTEXT8
KACS_LOGON_TYPE_NEW_CREDENTIALS9

Maximum number of groups a token may carry.

ConstantValue
KACS_TOKEN_MAX_GROUPS1024

Number of 64-bit words in a group enabled-state bitmask (KACS_TOKEN_MAX_GROUPS / 64).

ConstantValue
KACS_TOKEN_GROUP_MASK_WORDS16

kacs_create_token (SYS_KACS_CREATE_TOKEN) spec wire format.

The (spec, len) buffer the syscall consumes is a fixed KACS_TOKEN_SPEC_HEADER_BYTES-byte header followed by variable-length sections at header-specified byte offsets. An offset/length (or offset/count) pair that is both zero means the section is absent. Sections may appear in any order; every offset+length is validated to fall within the buffer. The header is not a C struct (it carries packed mixed-width fields and crosses the syscall boundary as raw bytes); read each field at its KACS_TOKEN_SPEC_OFF_* offset. Its fields, in order: __u32 version must be KACS_TOKEN_SPEC_VERSION _u8 token_type KACS_TOKEN_TYPE _u8 impersonation_level KACS_IMLEVEL __u8 _reserved0[2] must be 0 __u32 integrity_rid integrity-level RID _u32 mandatory_policy KACS_TOKEN_MANDATORY_POLICY* bits _u64 privs_present privilege bitmask (KACS_SE*_PRIVILEGE) __u64 privs_enabled initially enabled privileges (subset) __u32 _reserved1 must be 0 (elevation set only by LINK_TOKENS) __u32 projected_uid Linux UID for credential projection __u32 projected_gid Linux GID for credential projection __u32 audit_policy per-token audit flags __u64 expiration expiry timestamp (0 = none) __u64 logon_session_id logon session ID (auth_id) __u32 owner_sid_index 0 = user SID, 1..N = caller group __u32 primary_group_index 0 = user SID, 1..N = caller group __u8 source_name[8] token source name __u64 source_id token source LUID __u32 user_sid_offset byte offset to the user SID __u32 groups_offset byte offset to the groups array __u32 groups_count number of group entries __u32 default_dacl_offset byte offset to the default DACL (0 = none) __u32 default_dacl_len default DACL byte length (0 = none) __u32 user_claims_offset byte offset to user claims (0 = none) __u32 user_claims_len user claims byte length (0 = none) __u32 device_claims_offset byte offset to device claims (0 = none) __u32 device_claims_len device claims byte length (0 = none) __u32 device_groups_offset byte offset to device groups (0 = none) __u32 device_groups_count number of device-group entries (0 = none) __u32 restricted_sids_offset byte offset to restricted SIDs (0 = none) __u32 restricted_sids_count number of restricted-SID entries (0 = none) __u32 confinement_sid_offset byte offset to confinement SID (0 = none) __u32 confinement_sid_len confinement SID byte length (0 = none) __u32 confinement_caps_offset byte offset to confinement caps (0 = none) __u32 confinement_caps_count number of confinement-cap entries (0 = none) __u8 confinement_exempt 1 = exempt from confinement __u8 write_restricted 1 = write-restricted mode __u8 user_deny_only 1 = user SID matches deny ACEs only __u8 isolation_boundary 1 = enable namespace filtering __u32 supp_gids_offset byte offset to supplementary GIDs (0 = none) __u32 supp_gids_count number of supplementary-GID entries (0 = none) __u32 restricted_device_groups_offset byte offset (0 = none) __u32 restricted_device_groups_count entry count (0 = none) __u64 origin originating logon-session LUID (0 = none) __u32 interactivity_scope interactive session number __u32 lcs_credentials_offset byte offset to the LCS extension (0 = none) A group/device-group/restricted- SID/confinement-cap/restricted-device-group entry is [__u32 sid_len][__u8 sid[sid_len]][__u32 attributes]. A supplementary-GIDs section is supp_gids_count little-endian __u32 GIDs. All multi-byte header and section scalars are little-endian.

ConstantValue
KACS_TOKEN_SPEC_VERSION2
KACS_TOKEN_SPEC_HEADER_BYTES192
KACS_TOKEN_SPEC_MIN_BYTES192
KACS_TOKEN_SPEC_MAX_BYTES65536

Byte offsets of the fixed token-spec header fields.

ConstantValue
KACS_TOKEN_SPEC_OFF_VERSION0
KACS_TOKEN_SPEC_OFF_TOKEN_TYPE4
KACS_TOKEN_SPEC_OFF_IMPERSONATION_LEVEL5
KACS_TOKEN_SPEC_OFF_RESERVED06
KACS_TOKEN_SPEC_OFF_INTEGRITY_RID8
KACS_TOKEN_SPEC_OFF_MANDATORY_POLICY12
KACS_TOKEN_SPEC_OFF_PRIVS_PRESENT16
KACS_TOKEN_SPEC_OFF_PRIVS_ENABLED24
KACS_TOKEN_SPEC_OFF_RESERVED132
KACS_TOKEN_SPEC_OFF_PROJECTED_UID36
KACS_TOKEN_SPEC_OFF_PROJECTED_GID40
KACS_TOKEN_SPEC_OFF_AUDIT_POLICY44
KACS_TOKEN_SPEC_OFF_EXPIRATION48
KACS_TOKEN_SPEC_OFF_LOGON_SESSION_ID56
KACS_TOKEN_SPEC_OFF_OWNER_SID_INDEX64
KACS_TOKEN_SPEC_OFF_PRIMARY_GROUP_INDEX68
KACS_TOKEN_SPEC_OFF_SOURCE_NAME72
KACS_TOKEN_SPEC_OFF_SOURCE_ID80
KACS_TOKEN_SPEC_OFF_USER_SID_OFFSET88
KACS_TOKEN_SPEC_OFF_GROUPS_OFFSET92
KACS_TOKEN_SPEC_OFF_GROUPS_COUNT96
KACS_TOKEN_SPEC_OFF_DEFAULT_DACL_OFFSET100
KACS_TOKEN_SPEC_OFF_DEFAULT_DACL_LEN104
KACS_TOKEN_SPEC_OFF_USER_CLAIMS_OFFSET108
KACS_TOKEN_SPEC_OFF_USER_CLAIMS_LEN112
KACS_TOKEN_SPEC_OFF_DEVICE_CLAIMS_OFFSET116
KACS_TOKEN_SPEC_OFF_DEVICE_CLAIMS_LEN120
KACS_TOKEN_SPEC_OFF_DEVICE_GROUPS_OFFSET124
KACS_TOKEN_SPEC_OFF_DEVICE_GROUPS_COUNT128
KACS_TOKEN_SPEC_OFF_RESTRICTED_SIDS_OFFSET132
KACS_TOKEN_SPEC_OFF_RESTRICTED_SIDS_COUNT136
KACS_TOKEN_SPEC_OFF_CONFINEMENT_SID_OFFSET140
KACS_TOKEN_SPEC_OFF_CONFINEMENT_SID_LEN144
KACS_TOKEN_SPEC_OFF_CONFINEMENT_CAPS_OFFSET148
KACS_TOKEN_SPEC_OFF_CONFINEMENT_CAPS_COUNT152
KACS_TOKEN_SPEC_OFF_CONFINEMENT_EXEMPT156
KACS_TOKEN_SPEC_OFF_WRITE_RESTRICTED157
KACS_TOKEN_SPEC_OFF_USER_DENY_ONLY158
KACS_TOKEN_SPEC_OFF_ISOLATION_BOUNDARY159
KACS_TOKEN_SPEC_OFF_SUPP_GIDS_OFFSET160
KACS_TOKEN_SPEC_OFF_SUPP_GIDS_COUNT164
KACS_TOKEN_SPEC_OFF_RESTRICTED_DEVICE_GROUPS_OFFSET168
KACS_TOKEN_SPEC_OFF_RESTRICTED_DEVICE_GROUPS_COUNT172
KACS_TOKEN_SPEC_OFF_ORIGIN176
KACS_TOKEN_SPEC_OFF_INTERACTIVITY_SCOPE184
KACS_TOKEN_SPEC_OFF_LCS_CREDENTIALS_OFFSET188

Byte length of the fixed token-source-name field.

ConstantValue
KACS_TOKEN_SPEC_SOURCE_NAME_BYTES8

Optional LCS registry-credentials extension, located at the token-spec header's lcs_credentials_offset. The section is a fixed KACS_TOKEN_LCS_EXT_HEADER_BYTES-byte header bounded by the next active variable-section offset or the end of the spec; it is consumed exactly (trailing bytes are malformed). Header fields, in order: __u32 version must be KACS_TOKEN_LCS_EXT_VERSION __u32 _reserved must be 0 __u32 scope_count private hive scope GUIDs (<= max) __u32 private_layer_count private layer names (<= max) Payload: scope_count raw 16-byte GUIDs, then private_layer_count little-endian __u32 name byte lengths, then the concatenated UTF-8 layer names. Scope GUIDs must be non-nil and unique; layer names must be 1.. KACS_TOKEN_LCS_MAX_LAYER_NAME_BYTES bytes, must not contain '\', '/', or NUL, and must be unique under case-insensitive matching.

ConstantValue
KACS_TOKEN_LCS_EXT_VERSION1
KACS_TOKEN_LCS_EXT_HEADER_BYTES16
KACS_TOKEN_LCS_SCOPE_GUID_BYTES16
KACS_TOKEN_LCS_MAX_SCOPE_GUIDS256
KACS_TOKEN_LCS_MAX_PRIVATE_LAYERS256
KACS_TOKEN_LCS_MAX_LAYER_NAME_BYTES255

Byte offsets of the fixed LCS-extension header fields.

ConstantValue
KACS_TOKEN_LCS_EXT_OFF_VERSION0
KACS_TOKEN_LCS_EXT_OFF_RESERVED4
KACS_TOKEN_LCS_EXT_OFF_SCOPE_COUNT8
KACS_TOKEN_LCS_EXT_OFF_PRIVATE_LAYER_COUNT12

kacs_create_logon_session (SYS_KACS_CREATE_LOGON_SESSION) spec wire format.

The (spec, len) buffer the syscall consumes is, in order: _u8 logon_type one of KACS_LOGON_TYPE* above __le16 auth_pkg_len byte length of the auth-package name __u8 auth_pkg[auth_pkg_len] auth-package name (valid UTF-8) __le32 user_sid_len byte length of the user SID __u8 user_sid[user_sid_len] binary SID of the authenticated user The buffer is consumed exactly: 7 + auth_pkg_len + user_sid_len must equal len. The kernel assigns the session ID and derives the logon SID from it.

ConstantValue
KACS_LOGON_SESSION_SPEC_MIN_BYTES15
KACS_LOGON_SESSION_SPEC_MAX_BYTES4096

Byte offsets of the fixed-position session-spec fields.

ConstantValue
KACS_LOGON_SESSION_SPEC_OFF_LOGON_TYPE0
KACS_LOGON_SESSION_SPEC_OFF_AUTH_PKG_LEN1
KACS_LOGON_SESSION_SPEC_OFF_AUTH_PKG3

Token-handle ioctls.

ConstantValue
KACS_IOC_QUERY0xC0104B00
KACS_IOC_ADJUST_PRIVS0x40184B01
KACS_IOC_DUPLICATE0xC0104B02
KACS_IOC_INSTALL0x00004B03
KACS_IOC_RESTRICT0xC0284B04
KACS_IOC_LINK_TOKENS0x40104B05
KACS_IOC_GET_LINKED_TOKEN0xC0044B06
KACS_IOC_ADJUST_GROUPS0x40904B07
KACS_IOC_IMPERSONATE0x00004B08
KACS_IOC_ADJUST_DEFAULT0x40104B09
KACS_IOC_ADJUST_INTERACTIVITY_SCOPE0x40044B0A

Token information classes (kacs_query_args.token_class).

ConstantValue
KACS_TOKEN_CLASS_USER0x01 (1)
KACS_TOKEN_CLASS_GROUPS0x02 (2)
KACS_TOKEN_CLASS_PRIVILEGES0x03 (3)
KACS_TOKEN_CLASS_TYPE0x04 (4)
KACS_TOKEN_CLASS_INTEGRITY_LEVEL0x05 (5)
KACS_TOKEN_CLASS_OWNER0x06 (6)
KACS_TOKEN_CLASS_PRIMARY_GROUP0x07 (7)
KACS_TOKEN_CLASS_INTERACTIVITY_SCOPE0x08 (8)
KACS_TOKEN_CLASS_RESTRICTED_SIDS0x09 (9)
KACS_TOKEN_CLASS_SOURCE0x0A (10)
KACS_TOKEN_CLASS_STATISTICS0x0B (11)
KACS_TOKEN_CLASS_ORIGIN0x0C (12)
KACS_TOKEN_CLASS_ELEVATION_TYPE0x0D (13)
KACS_TOKEN_CLASS_DEVICE_GROUPS0x0E (14)
KACS_TOKEN_CLASS_APPCONTAINER_SID0x0F (15)
KACS_TOKEN_CLASS_CAPABILITIES0x10 (16)
KACS_TOKEN_CLASS_MANDATORY_POLICY0x11 (17)
KACS_TOKEN_CLASS_LOGON_TYPE0x12 (18)
KACS_TOKEN_CLASS_LOGON_SID0x13 (19)
KACS_TOKEN_CLASS_DEFAULT_DACL0x14 (20)
KACS_TOKEN_CLASS_IMPERSONATION_LEVEL0x15 (21)
KACS_TOKEN_CLASS_USER_CLAIMS0x16 (22)
KACS_TOKEN_CLASS_DEVICE_CLAIMS0x17 (23)
KACS_TOKEN_CLASS_PROJECTED_SUPPLEMENTARY_GIDS0x18 (24)

Privileges, as single-bit masks within a token's 64-bit privilege word (present / enabled / enabled-by-default / used are each one such word). Named for the Windows privilege identifiers (SeTcbPrivilege, …).

ConstantValue
KACS_SE_CREATE_TOKEN_PRIVILEGE4
KACS_SE_ASSIGN_PRIMARY_TOKEN_PRIVILEGE8
KACS_SE_LOCK_MEMORY_PRIVILEGE16
KACS_SE_INCREASE_QUOTA_PRIVILEGE32
KACS_SE_TCB_PRIVILEGE128
KACS_SE_SECURITY_PRIVILEGE256
KACS_SE_TAKE_OWNERSHIP_PRIVILEGE512
KACS_SE_LOAD_DRIVER_PRIVILEGE1024
KACS_SE_SYSTEM_PROFILE_PRIVILEGE2048
KACS_SE_SYSTEMTIME_PRIVILEGE4096
KACS_SE_PROFILE_SINGLE_PROCESS_PRIVILEGE8192
KACS_SE_INCREASE_BASE_PRIORITY_PRIVILEGE16384
KACS_SE_BACKUP_PRIVILEGE131072
KACS_SE_RESTORE_PRIVILEGE262144
KACS_SE_SHUTDOWN_PRIVILEGE524288
KACS_SE_DEBUG_PRIVILEGE0x100000 (1048576)
KACS_SE_AUDIT_PRIVILEGE0x200000 (2097152)
KACS_SE_CHANGE_NOTIFY_PRIVILEGE0x800000 (8388608)
KACS_SE_REMOTE_SHUTDOWN_PRIVILEGE0x1000000 (16777216)
KACS_SE_MANAGE_VOLUME_PRIVILEGE0x10000000 (268435456)
KACS_SE_IMPERSONATE_PRIVILEGE0x20000000 (536870912)
KACS_SE_RELABEL_PRIVILEGE0x100000000 (4294967296)
KACS_SE_CREATE_SYMBOLIC_LINK_PRIVILEGE0x800000000 (34359738368)
KACS_SE_BIND_PRIVILEGED_PORT_PRIVILEGE0x8000000000000000 (9223372036854775808)

3.A.4 AccessCheck constants #

From uapi/pkm/access.h.

Full size of kacs_access_check_args the current kernel copies.

ConstantValue
KACS_ACCESS_CHECK_ARGS_SIZE136

Minimum caller_size the kernel accepts (the v1 / v0.20 layout).

ConstantValue
KACS_ACCESS_CHECK_ARGS_V1_SIZE40

Byte size of one kacs_object_type_entry in the object-type tree array.

ConstantValue
KACS_OBJECT_TYPE_ENTRY_SIZE20

Largest object-audit-context buffer the kernel accepts.

ConstantValue
KACS_ACCESS_CHECK_MAX_AUDIT_CONTEXT_LEN4096

Largest @Local claims blob the kernel accepts (local_claims_ptr).

ConstantValue
KACS_ACCESS_CHECK_MAX_LOCAL_CLAIMS_LEN65536

Largest object-type tree entry count the kernel accepts.

ConstantValue
KACS_ACCESS_CHECK_MAX_OBJECT_TYPE_COUNT1024

Claim value types — the discriminant of one attribute in the @Local claims array (local_claims_ptr).

ConstantValue
KACS_CLAIM_TYPE_INT640x0001 (1)
KACS_CLAIM_TYPE_UINT640x0002 (2)
KACS_CLAIM_TYPE_STRING0x0003 (3)
KACS_CLAIM_TYPE_SID0x0005 (5)
KACS_CLAIM_TYPE_BOOLEAN0x0006 (6)
KACS_CLAIM_TYPE_OCTET0x0010 (16)

Claim attribute flags.

ConstantValue
KACS_CLAIM_ATTR_CASE_SENSITIVE0x0002 (2)
KACS_CLAIM_ATTR_USE_FOR_DENY_ONLY0x0004 (4)
KACS_CLAIM_ATTR_DISABLED0x0010 (16)

Central Access Policy (CAAP) spec wire format — the (spec, spec_len) buffer kacs_set_caap (SYS_KACS_SET_CAAP) consumes. A non-empty spec replaces the policy identified by the call's policy SID; a NULL/zero spec removes it. The buffer is a fixed prefix followed by rule_count per-rule sections, and is consumed exactly (trailing bytes are rejected). It is not a C struct (the rule sections are variable-length); read it as raw bytes: __u8 version must be KACS_CAAP_SPEC_VERSION __le32 rule_count number of rules that follow (<= max) rule_count * { __le32 applies_to_len [__u8 applies_to[applies_to_len]] conditional-expression bytecode; 0 = always __le32 effective_dacl_len [__u8 effective_dacl[...]] binary ACL; length MUST be nonzero __le32 effective_sacl_len [__u8 effective_sacl[...]] (0 = none) __le32 staged_dacl_len [__u8 staged_dacl[...]] (0 = none) __le32 staged_sacl_len [__u8 staged_sacl[...]] (0 = none) } Every length- prefixed field uses a little-endian __u32 length and is bounded by KACS_CAAP_MAX_FIELD_BYTES; ACL payloads additionally parse under the security-descriptor size limit (KACS_CAAP_MAX_ACL_BYTES). ACLs use the binary ACL format from <pkm/sd.h>; applies_to is conditional-ACE bytecode.

ConstantValue
KACS_CAAP_SPEC_VERSION0x01 (1)
KACS_CAAP_MAX_SPEC_BYTES262144
KACS_CAAP_MAX_RULE_COUNT256
KACS_CAAP_MAX_FIELD_BYTES65536
KACS_CAAP_MAX_ACL_BYTES65535

Byte offsets of the fixed CAAP-spec prefix fields.

ConstantValue
KACS_CAAP_SPEC_OFF_VERSION0
KACS_CAAP_SPEC_OFF_RULE_COUNT1

Byte length of the fixed CAAP-spec prefix (version + rule_count).

ConstantValue
KACS_CAAP_SPEC_PREFIX_BYTES5

3.A.5 File and open constants #

From uapi/pkm/file.h.

Minimum caller-supplied size accepted for each argument block.

ConstantValue
KACS_OPEN_HOW_MIN_SIZE16
KACS_MOUNT_POLICY_ARGS_MIN_SIZE16

Create dispositions (kacs_open_how.create_disposition).

ConstantValue
KACS_DISPOSITION_SUPERSEDE0
KACS_DISPOSITION_OPEN1
KACS_DISPOSITION_CREATE2
KACS_DISPOSITION_OPEN_IF3
KACS_DISPOSITION_OVERWRITE4
KACS_DISPOSITION_OVERWRITE_IF5

Create options (kacs_open_how.create_options).

ConstantValue
KACS_CREATE_OPT_DIRECTORY0x0001 (1)
KACS_CREATE_OPT_DELETE_ON_CLOSE0x0002 (2)

kacs_open_how.flags bits.

ConstantValue
KACS_BACKUP_INTENT0x00000001 (1)
KACS_RESTORE_INTENT0x00000002 (2)

File and directory object-specific access rights (the low 16 bits of a file access mask).

The directory aliases name the same bit as the file right it acts as for a directory object.

ConstantValue
KACS_FILE_READ_DATA0x00000001 (1)
KACS_FILE_WRITE_DATA0x00000002 (2)
KACS_FILE_APPEND_DATA0x00000004 (4)
KACS_FILE_READ_EA0x00000008 (8)
KACS_FILE_WRITE_EA0x00000010 (16)
KACS_FILE_EXECUTE0x00000020 (32)
KACS_FILE_DELETE_CHILD0x00000040 (64)
KACS_FILE_READ_ATTRIBUTES0x00000080 (128)
KACS_FILE_WRITE_ATTRIBUTES0x00000100 (256)
KACS_FILE_LIST_DIRECTORY1
KACS_FILE_TRAVERSE32
KACS_FILE_ADD_FILE2
KACS_FILE_ADD_SUBDIRECTORY4

Mount-policy values (kacs_mount_policy_args.policy).

ConstantValue
KACS_MOUNT_POLICY_UNMANAGED1
KACS_MOUNT_POLICY_DENY_MISSING2
KACS_MOUNT_POLICY_SYNTHESIZE_EPHEMERAL3
KACS_MOUNT_POLICY_SYNTHESIZE_PERSISTENT4

Status word kacs_open writes back, describing what happened to the file.

ConstantValue
KACS_STATUS_OPENED1
KACS_STATUS_CREATED2
KACS_STATUS_OVERWRITTEN3
KACS_STATUS_SUPERSEDED4

3.A.6 Process access rights #

From uapi/pkm/process.h.

KACS process object-specific access rights (the low 16 bits of a process access mask).

Named for the Windows process rights; an access check folds the generic bits (<pkm/sd.h>) into these via the process generic mapping.

ConstantValue
KACS_PROCESS_TERMINATE0x00000001 (1)
KACS_PROCESS_SIGNAL0x00000002 (2)
KACS_PROCESS_VM_READ0x00000010 (16)
KACS_PROCESS_VM_WRITE0x00000020 (32)
KACS_PROCESS_DUP_HANDLE0x00000040 (64)
KACS_PROCESS_SET_INFORMATION0x00000200 (512)
KACS_PROCESS_QUERY_INFORMATION0x00000400 (1024)
KACS_PROCESS_SUSPEND_RESUME0x00000800 (2048)
KACS_PROCESS_QUERY_LIMITED0x00001000 (4096)

3.A.7 Process mitigation bits #

From uapi/pkm/psb.h.

Process Security Block (PSB) process-mitigation bits.

The mitigations argument of kacs_set_psb (SYS_KACS_SET_PSB) is a bitmask of these flags. Setting a bit is activation-backed: KACS either places the target process in the protected state (or verifies it already satisfies the invariant) before committing, and rejects later operations that would disable the protection. Each mitigation is enforced at its own enforcement point and persists across exec. Only the bits in KACS_MIT_ALL are valid; any other bit set in the request is rejected. KACS_MIT_CFI is a legacy alias: requesting it sets both KACS_MIT_CFIF and KACS_MIT_CFIB, and the alias bit itself is not retained.

ConstantValueNotes
KACS_MIT_WXP0x001 (1)Write-XOR-Execute protection
KACS_MIT_TLP0x002 (2)Trusted Library Paths
KACS_MIT_LSV0x004 (4)Library Signature Verification
KACS_MIT_CFI0x008 (8)legacy alias: CFIF | CFIB
KACS_MIT_UI_ACCESS0x010 (16)UI interaction (reserved)
KACS_MIT_NO_CHILD0x020 (32)cannot fork (one-way)
KACS_MIT_CFIF0x040 (64)forward-edge CFI (Intel IBT)
KACS_MIT_CFIB0x080 (128)backward-edge CFI (shadow stack)
KACS_MIT_PIE0x100 (256)reject non-PIE binaries at exec
KACS_MIT_SML0x200 (512)speculation mitigation lock

All valid mitigation bits OR'd together — the accepted-request mask.

ConstantValue
KACS_MIT_ALL0x3FF (1023)

3.A.8 Security descriptor constants #

From uapi/pkm/sd.h.

Byte length of the self-relative security-descriptor header.

ConstantValue
KACS_SD_HEADER_BYTES20

SECURITY_INFORMATION selector bits — which components of a security descriptor a kacs_get_sd / kacs_set_sd call reads or writes.

ConstantValue
KACS_SECINFO_OWNER0x00000001 (1)
KACS_SECINFO_GROUP0x00000002 (2)
KACS_SECINFO_DACL0x00000004 (4)
KACS_SECINFO_SACL0x00000008 (8)
KACS_SECINFO_LABEL0x00000010 (16)

SECURITY_DESCRIPTOR_CONTROL bits — the SD header control field.

ConstantValue
KACS_SD_OWNER_DEFAULTED0x0001 (1)
KACS_SD_GROUP_DEFAULTED0x0002 (2)
KACS_SD_DACL_PRESENT0x0004 (4)
KACS_SD_DACL_DEFAULTED0x0008 (8)
KACS_SD_SACL_PRESENT0x0010 (16)
KACS_SD_SACL_DEFAULTED0x0020 (32)
KACS_SD_DACL_TRUSTED0x0040 (64)
KACS_SD_SERVER_SECURITY0x0080 (128)
KACS_SD_DACL_AUTO_INHERIT_REQ0x0100 (256)
KACS_SD_SACL_AUTO_INHERIT_REQ0x0200 (512)
KACS_SD_DACL_AUTO_INHERITED0x0400 (1024)
KACS_SD_SACL_AUTO_INHERITED0x0800 (2048)
KACS_SD_DACL_PROTECTED0x1000 (4096)
KACS_SD_SACL_PROTECTED0x2000 (8192)
KACS_SD_RM_CONTROL_VALID0x4000 (16384)
KACS_SD_SELF_RELATIVE0x8000 (32768)

Access-mask bits — standard rights (bits 16-24) and generic rights (bits 28-31).

The low 16 bits of a mask are object-class specific; see <pkm/file.h> and <pkm/token.h> for those.

ConstantValue
KACS_ACCESS_DELETE0x00010000 (65536)
KACS_ACCESS_READ_CONTROL0x00020000
KACS_ACCESS_WRITE_DAC0x00040000
KACS_ACCESS_WRITE_OWNER0x00080000
KACS_ACCESS_SYNCHRONIZE0x00100000
KACS_ACCESS_ACCESS_SYSTEM_SECURITY0x01000000
KACS_ACCESS_MAXIMUM_ALLOWED0x02000000
KACS_ACCESS_GENERIC_ALL0x10000000
KACS_ACCESS_GENERIC_EXECUTE0x20000000
KACS_ACCESS_GENERIC_WRITE0x40000000
KACS_ACCESS_GENERIC_READ0x80000000

ACE types — the ace_type byte of an ACE header (MS-DTYP 2.4.4.1).

ConstantValue
KACS_ACE_TYPE_ACCESS_ALLOWED0x00 (0)
KACS_ACE_TYPE_ACCESS_DENIED0x01 (1)
KACS_ACE_TYPE_SYSTEM_AUDIT0x02 (2)
KACS_ACE_TYPE_SYSTEM_ALARM0x03 (3)
KACS_ACE_TYPE_ACCESS_ALLOWED_COMPOUND0x04 (4)
KACS_ACE_TYPE_ACCESS_ALLOWED_OBJECT0x05 (5)
KACS_ACE_TYPE_ACCESS_DENIED_OBJECT0x06 (6)
KACS_ACE_TYPE_SYSTEM_AUDIT_OBJECT0x07 (7)
KACS_ACE_TYPE_SYSTEM_ALARM_OBJECT0x08 (8)
KACS_ACE_TYPE_ACCESS_ALLOWED_CALLBACK0x09 (9)
KACS_ACE_TYPE_ACCESS_DENIED_CALLBACK0x0A (10)
KACS_ACE_TYPE_ACCESS_ALLOWED_CALLBACK_OBJECT0x0B (11)
KACS_ACE_TYPE_ACCESS_DENIED_CALLBACK_OBJECT0x0C (12)
KACS_ACE_TYPE_SYSTEM_AUDIT_CALLBACK0x0D (13)
KACS_ACE_TYPE_SYSTEM_ALARM_CALLBACK0x0E (14)
KACS_ACE_TYPE_SYSTEM_AUDIT_CALLBACK_OBJECT0x0F (15)
KACS_ACE_TYPE_SYSTEM_ALARM_CALLBACK_OBJECT0x10 (16)
KACS_ACE_TYPE_SYSTEM_MANDATORY_LABEL0x11 (17)
KACS_ACE_TYPE_SYSTEM_RESOURCE_ATTRIBUTE0x12 (18)
KACS_ACE_TYPE_SYSTEM_SCOPED_POLICY_ID0x13 (19)
KACS_ACE_TYPE_SYSTEM_PROCESS_TRUST_LABEL0x14 (20)
KACS_ACE_TYPE_SYSTEM_ACCESS_FILTER0x15 (21)

ACE header ace_flags byte — inheritance and audit control.

ConstantValue
KACS_ACE_FLAG_OBJECT_INHERIT0x01 (1)
KACS_ACE_FLAG_CONTAINER_INHERIT0x02 (2)
KACS_ACE_FLAG_NO_PROPAGATE_INHERIT0x04 (4)
KACS_ACE_FLAG_INHERIT_ONLY0x08 (8)
KACS_ACE_FLAG_INHERITED0x10 (16)
KACS_ACE_FLAG_SUCCESSFUL_ACCESS0x40 (64)
KACS_ACE_FLAG_FAILED_ACCESS0x80 (128)

Mandatory-label policy bits — the __le32 access mask of a KACS_ACE_TYPE_SYSTEM_MANDATORY_LABEL ACE. They control which DACL- granted rights a caller whose integrity level does not dominate the object's label (the "up" direction) is denied. Each bit suppresses the rights mapped from the corresponding generic class; unknown bits MUST be ignored.

ConstantValue
KACS_SYSTEM_MANDATORY_LABEL_NO_READ_UP0x00000001 (1)
KACS_SYSTEM_MANDATORY_LABEL_NO_WRITE_UP0x00000002 (2)
KACS_SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP0x00000004 (4)

Object-ACE body Flags field — the __le32 at object-ACE body offset 8, distinct from the 1-byte ace_flags header field above. Indicates which optional GUIDs the object-ACE body carries.

ConstantValue
KACS_ACE_OBJECT_TYPE_PRESENT0x00000001 (1)
KACS_ACE_INHERITED_OBJECT_TYPE_PRESENT0x00000002 (2)

3.A.9 SID constants #

From uapi/pkm/sid.h.

Largest sub_authority_count a valid SID may declare.

ConstantValue
KACS_SID_MAX_SUB_AUTHORITIES15

Encoded byte length of a SID with the given sub-authority count.

ConstantValue
KACS_SID_BYTE_LEN(count)(8 + 4 * (count))

SID_AND_ATTRIBUTES "Attributes" bits (MS-DTYP 2.4.4).

These describe how a group or restricted SID participates in an access check. MS-DTYP names them for groups; they apply to any SID_AND_ATTRIBUTES entry.

ConstantValue
KACS_SID_GROUP_MANDATORY0x00000001 (1)
KACS_SID_GROUP_ENABLED_BY_DEFAULT0x00000002 (2)
KACS_SID_GROUP_ENABLED0x00000004 (4)
KACS_SID_GROUP_OWNER0x00000008 (8)
KACS_SID_GROUP_USE_FOR_DENY_ONLY0x00000010 (16)
KACS_SID_GROUP_INTEGRITY0x00000020 (32)
KACS_SID_GROUP_INTEGRITY_ENABLED0x00000040 (64)
KACS_SID_GROUP_RESOURCE0x20000000
KACS_SID_GROUP_LOGON_ID0xC0000000

3.A.10 Tracepoint diagnostic codes #

From uapi/pkm/trace.h.

kacs_access_decision reason — why a KACS access hook took the return path it did.

Emitted by the kacs:kacs_file_access / _file_open / _native_open _inode_file_access / _inode_permission events. Verdict (allow vs deny) is a separate signal, read from the ret field (0 == allow).

ConstantValueNotes
KACS_TR_DECISION0resolved allow/deny
KACS_TR_BAD_ARGS1NULL/zero argument guard
KACS_TR_NO_ISEC2inode has no i_security blob
KACS_TR_UNMANAGED3superblock mount policy UNMANAGED
KACS_TR_PIP_CONTEXT4current PIP context unavailable
KACS_TR_NO_TOKEN5no effective subject token
KACS_TR_NO_DENTRY_ALIAS6inode has no dentry alias yet
KACS_TR_DELETE_ON_CLOSE_PENDING7open of a delete-on-close file
KACS_TR_NATIVE_STAMP8native-open granted-access stamp
KACS_TR_NATIVE_ARM9native-open delete-on-close arm
KACS_TR_STAMP10legacy-open granted-access stamp
KACS_TR_LAZY_DENTRY_RELOOKUP11native create lazy re-lookup
KACS_TR_NEGATIVE_AFTER_CREATE12negative dentry after create
KACS_TR_CHANGE_NOTIFY_PRIV13traverse via CHANGE_NOTIFY priv
KACS_TR_CHANGE_NOTIFY_PRIV_EXHAUSTED14CHANGE_NOTIFY priv use exhausted

kacs_sd_cache reason — the inode security-descriptor cache outcome.

The lookup miss codes disambiguate the three cache-absent paths that were previously an indistinguishable NULL return (no cache / stale generation missing-but-synthesis-required); the corrupt codes name why a stored SD was rejected. Emitted by kacs:kacs_sd_cache_lookup / _corrupt.

ConstantValueNotes
KACS_SDC_HIT0current, valid cache present
KACS_SDC_MISS_NONE1no cache attached
KACS_SDC_MISS_STALE_GEN2cache present but stale generation
KACS_SDC_MISS_NEEDS_SYNTH3missing SD requires synthesis
KACS_SDC_CORRUPT_EMPTY_OR_OVERSIZE4stored SD zero-length or oversize
KACS_SDC_CORRUPT_VALIDATE_FAIL5stored SD failed validation

kacs_process_access reason — the outcome of a cross-process access decision (signal, ptrace, scheduler/attribute, prlimit). The reason distinguishes the paths that all surface as -EACCES: an SD denial, a PIP-based denial, a denial rescued (or not) by SeDebugPrivilege, and a PIP-dominance failure. Emitted by kacs:kacs_process_access.

ConstantValueNotes
KACS_PA_ALLOW0access granted
KACS_PA_BAD_ARGS1NULL subject/target guard
KACS_PA_NO_TARGET2target has no process state/SD
KACS_PA_NO_SD3target process SD unavailable
KACS_PA_SD_ERROR4SD check failed (non-EACCES)
KACS_PA_PIP_DENIED5denied by process-integrity policy
KACS_PA_DEBUG_RESCUE6SD denial rescued by SeDebugPrivilege
KACS_PA_DEBUG_DENIED7denied; no usable SeDebugPrivilege
KACS_PA_PIP_DOMINANCE8caller PIP does not dominate target

kacs_exec reason — an exec/bprm credential or PIP transition.

Distinguishes the uid/gid-change gate outcomes, the exec primary-token derivation paths and their failures, the exec file integrity-label lookup failures, and the two commit-time transitions. Verdict is the ret field. Emitted by kacs:kacs_exec.

ConstantValueNotes
KACS_EXEC_CREDS_ALLOW0exec cred transition allowed
KACS_EXEC_BAD_ARGS1NULL cred/token guard
KACS_EXEC_ID_CHANGE_NO_TOKEN2uid/gid change, no subject token
KACS_EXEC_ID_CHANGE_PRIV_UNSUPPORTED3id change + ASSIGN_PRIMARY_TOKEN priv
KACS_EXEC_TOKEN_NPM_DERIVED4exec token derived via new-process-min
KACS_EXEC_TOKEN_CLONE5exec token via primary clone fallback
KACS_EXEC_NPM_NO_FILE6new-process-min needs file, none supplied
KACS_EXEC_NPM_DERIVE_FAIL7new_process_min_exec derivation failed
KACS_EXEC_TOKEN_INSTALL_FAIL8install token ref on new cred failed
KACS_EXEC_TOKEN_CLONE_FAIL9primary token clone returned NULL
KACS_EXEC_INTEGRITY_NO_ISEC10exec file inode has no i_security
KACS_EXEC_INTEGRITY_NO_CACHE11exec file SD cache absent
KACS_EXEC_INTEGRITY_INVALID_SD12exec file cached SD invalid/empty
KACS_EXEC_IMPERSONATION_REVERT_FAIL13bprm impersonation revert failed
KACS_EXEC_PIP_COMMITTED14pending exec PIP committed at commit
KACS_EXEC_UMH_NOT_TCB15usermodehelper exec below PeiosTcb trust
KACS_EXEC_SIGNATURE_UNVERIFIABLE16exec refused: signature could not be verified

kacs_signing reason — code-signature verification outcomes and the distinct reject reasons of the signing-material probe. Only source enum, verified flag, PIP tier codes, file length, reason, and ret are recorded — never key, signature, xattr, or file bytes. Emitted by kacs:kacs_signing_verify _crypto / _probe.

ConstantValueNotes
KACS_SIG_UNSIGNED0material source NONE (unsigned)
KACS_SIG_BAD_KEY_TABLE1key table malformed / bad args
KACS_SIG_NO_KEY_MATCH2no key verified the signature
KACS_SIG_VERIFIED3a key verified; trust assigned
KACS_SIG_CRYPTO_UNAVAILABLE4mldsa65 tfm allocation failed
KACS_SIG_CRYPTO_MISMATCH5set-pubkey/verify returned nonzero
KACS_SIG_PROBE_FOUND6valid signature material committed
KACS_SIG_ELF_MAGIC_READ7failed reading ELF magic
KACS_SIG_ELF_SHORT_EHDR8file too short for Elf64_Ehdr
KACS_SIG_ELF_EHDR_READ9failed reading ELF header
KACS_SIG_ELF_BAD_IDENT10unsupported e_ident class/data/version
KACS_SIG_ELF_BAD_SHTABLE11bad shentsize/shstrndx
KACS_SIG_ELF_SHDRS_RANGE12section-header table offset/len out of range
KACS_SIG_ELF_SHSTR_READ13failed reading shstrtab section header
KACS_SIG_ELF_STRTAB_RANGE14shstrtab offset/len out of range
KACS_SIG_ELF_SHDR_READ15failed reading a section header
KACS_SIG_ELF_NAME_READ16failed reading a section name
KACS_SIG_ELF_BAD_SIG_SECTION17sig section wrong type/size/range
KACS_SIG_ELF_BAD_BLOB18sig blob read failed or invalid
KACS_SIG_ELF_HASH_FAIL19hashing failed for ELF sig
KACS_SIG_XATTR_BAD_BLOB20xattr sig blob invalid
KACS_SIG_XATTR_HASH_FAIL21hashing failed for xattr sig
KACS_SIG_SIZE_CHANGED22file size changed during probe (TOCTOU)

kacs_socket reason — the outcome of an AF_UNIX socket SD / impersonation hook.

Distinguishes the guard, not-applicable, and verdict paths that otherwise collapse into an indistinguishable -EACCES. Verdict is the ret field. No address, pathname, or SD bytes are recorded.

ConstantValueNotes
KACS_SOCK_BAD_ARGS0NULL/guard argument rejected
KACS_SOCK_NOT_UNIX1not AF_UNIX / unsupported type
KACS_SOCK_NO_SECURITY2sock has no sk_security blob
KACS_SOCK_NO_TOKEN3no effective subject/client token
KACS_SOCK_BAD_LEVEL4invalid impersonation level
KACS_SOCK_WRONG_STATE5socket state forbids the op
KACS_SOCK_NO_PEER_TOKEN6no captured peer token present
KACS_SOCK_PIP_CONTEXT7caller PIP context unavailable
KACS_SOCK_SD_DECISION8socket-SD check produced verdict
KACS_SOCK_NO_SD9no socket SD; allowed without check
KACS_SOCK_HAVE_SD10socket SD present; check performed
KACS_SOCK_ALREADY_BOUND11socket SD already installed
KACS_SOCK_BIND12abstract-socket SD bind result
KACS_SOCK_CONNECT13unix_stream_connect result
KACS_SOCK_LEVEL_SET14impersonation level updated
KACS_SOCK_OPEN_TOKEN15open peer-token fd result
KACS_SOCK_IMPERSONATE16impersonate peer result

kacs_namespace stage — which sub-decision of a namespace-mutation hook a record describes.

Single-decision ops report PRIMARY; multi-stage ops (link, rename, delete fallback) tag each distinct verdict. Verdict is the ret field. Never records a pathname. Emitted by the kacs:kacs_inode_* events.

ConstantValueNotes
KACS_NS_PRIMARY0the op's principal decision
KACS_NS_PARENT_FALLBACK1delete: parent DELETE_CHILD fallback
KACS_NS_SOURCE2link/rename source-side decision
KACS_NS_DEST3link/rename destination-parent add
KACS_NS_DELETE_EXISTING4rename: delete pre-existing dest

kacs_psb reason — which process-security-baseline path an event marks: mitigation activation (apply) or a W^X / LSV / PIE / prctl-lock enforcement denial. The ok-vs-deny verdict is read from ret. Emitted by kacs:kacs_psb_*.

ConstantValueNotes
KACS_PSB_APPLY_OK0mitigations applied; result_bits set
KACS_PSB_APPLY_NORMALIZE1requested mask bad or unsupported (EINVAL/ENODEV)
KACS_PSB_APPLY_MM_ACQUIRE2could not acquire target mm (EACCES)
KACS_PSB_APPLY_CFIF3forward-CFI (IBT) activation failed
KACS_PSB_APPLY_SML4speculative-mitigation-lock activation failed
KACS_PSB_APPLY_CFIB5backward-CFI (shadow stack) activation failed
KACS_PSB_WXP_MMAP6W^X blocked a W+X mmap
KACS_PSB_WXP_MPROTECT7W^X blocked an mprotect transition
KACS_PSB_WXP_EXISTING_VMA8W^X activation blocked by an existing W+X vma
KACS_PSB_LSV_PROBE9LSV signing probe of the image failed
KACS_PSB_LSV_VERIFY10LSV signature not verified/trusted
KACS_PSB_LSV_PIP_DOMINANCE11LSV image PIP does not dominate process PIP
KACS_PSB_PIE_ET_EXEC12PIE blocked a non-PIE ET_EXEC image
KACS_PSB_PRCTL_SML13prctl blocked by SML lock
KACS_PSB_PRCTL_CFIB14prctl blocked by shadow-stack (CFIB) lock
KACS_PSB_PRCTL_PIP15prctl set-dumpable blocked by process PIP

kacs_token_ioctl cmd — which token-fd ioctl verb a record describes.

The verdict (allow vs deny) is read from the ret field (0 == allow); the access-mask-gate rejections surface as ret == -EACCES. token is an opaque numeric id (never token bytes). Emitted by kacs:kacs_token_ioctl.

ConstantValueNotes
KACS_TOK_QUERY0KACS_IOC_QUERY
KACS_TOK_ADJUST_PRIVS1KACS_IOC_ADJUST_PRIVS
KACS_TOK_ADJUST_GROUPS2KACS_IOC_ADJUST_GROUPS
KACS_TOK_DUPLICATE3KACS_IOC_DUPLICATE
KACS_TOK_INSTALL4KACS_IOC_INSTALL
KACS_TOK_RESTRICT5KACS_IOC_RESTRICT
KACS_TOK_LINK6KACS_IOC_LINK_TOKENS
KACS_TOK_GET_LINKED7KACS_IOC_GET_LINKED_TOKEN
KACS_TOK_IMPERSONATE8KACS_IOC_IMPERSONATE
KACS_TOK_ADJUST_DEFAULT9KACS_IOC_ADJUST_DEFAULT
KACS_TOK_ADJUST_INTERACTIVITY_SCOPE10KACS_IOC_ADJUST_INTERACTIVITY_SCOPE
KACS_TOK_UNKNOWN11unrecognised ioctl verb (-ENOTTY)

kacs_token_ref reason — a token-fd reference lifecycle transition.

TO_FD is a token installed into a fresh anon-inode handle; RELEASE is the handle teardown that drops the token ref; BIND clones a token onto an existing file; OPEN is the checked/fixed-access open path that clones the target token. token is an opaque numeric id, never token bytes. Emitted by kacs:kacs_token_ref.

ConstantValueNotes
KACS_TREF_TO_FD0token installed into a new fd (ret == fd)
KACS_TREF_RELEASE1token-fd released; ref dropped
KACS_TREF_BIND2token cloned + bound onto an existing file
KACS_TREF_OPEN3token cloned for a token-open path

kacs_logon_session reason — a session/token creation-surface outcome.

The *_DENIED codes name the privilege-gate rejections (the value); the plain op codes mark the successful op. Verdict is also in ret. No token/spec bytes are recorded. Emitted by kacs:kacs_logon_session.

ConstantValueNotes
KACS_SES_CREATE0create_logon_session published a session
KACS_SES_CREATE_PRIV_DENIED1create_logon_session: TCB privilege gate denied
KACS_SES_DESTROY2destroy_empty_logon_session outcome
KACS_SES_DESTROY_PRIV_DENIED3destroy: TCB privilege gate denied
KACS_SES_CREATE_TOKEN4create_token issued a token fd
KACS_SES_CREATE_TOKEN_PRIV_DENIED5create_token: CREATE_TOKEN privilege denied

kacs_cred reason — a credential-security lifecycle transition: LSM cred prepare/transfer/alloc/free, explicit token-ref install, the clone-time primary-token lifecycle (CLONE_THREAD share vs fork deep-copy), and the project-linux-cred rejection paths. old_token/new_token are opaque token pointer ids (0 when absent); clone_flags is set only on the clone paths. Verdict/outcome is the ret field. Emitted by kacs:kacs_cred.

ConstantValueNotes
KACS_CRED_PREPARE0cred_prepare token clone
KACS_CRED_TRANSFER1cred_transfer token clone
KACS_CRED_ALLOC_BLANK2cred_alloc_blank cleared sec
KACS_CRED_FREE3cred_free released token/state
KACS_CRED_INSTALL_TOKEN_REF4install token ref on a cred
KACS_CRED_CLONE_THREAD_SHARE5CLONE_THREAD shares parent primary cred
KACS_CRED_CLONE_FORK_COPY6fork deep-copies parent primary token
KACS_CRED_PROJECT_UID0_BLOCKED7uid0 projection not allowed by token
KACS_CRED_PROJECT_GROUPS_ALLOC_FAIL8groups_alloc failed (ENOMEM)
KACS_CRED_PROJECT_E2BIG9supplementary gid count > NGROUPS_MAX

kacs_setid reason — the KACS gate on a Linux setid projection (task_fix_setuid / _setgid / setgroups). Each op has two distinct denials that otherwise collapse: NO_TOKEN (-EACCES, no effective subject token) and PRIV_GATE (-EOPNOTSUPP, holder of ASSIGN_PRIMARY_TOKEN privilege). flags is the LSM_SETID* mask (0 for setgroups). Emitted by kacs:kacs_setid.

ConstantValueNotes
KACS_SETID_SETUID_NO_TOKEN0setuid gate: no subject token
KACS_SETID_SETUID_PRIV_GATE1setuid gate: ASSIGN_PRIMARY priv
KACS_SETID_SETGID_NO_TOKEN2setgid gate: no subject token
KACS_SETID_SETGID_PRIV_GATE3setgid gate: ASSIGN_PRIMARY priv
KACS_SETID_SETGROUPS_NO_TOKEN4setgroups gate: no subject token
KACS_SETID_SETGROUPS_PRIV_GATE5setgroups gate: ASSIGN_PRIMARY priv

kacs_task reason — a task-security lifecycle transition.

task_alloc reports a NO_CHILD-mitigation clone block, a process-state inherit ENOMEM, or success; task_free marks teardown. process_state is an opaque process-state pointer id (0 when absent); clone_flags is set on the alloc paths. Outcome is ret. Emitted by kacs:kacs_task.

ConstantValueNotes
KACS_TASK_ALLOC_NO_CHILD_BLOCKED0clone blocked by NO_CHILD mitigation
KACS_TASK_ALLOC_INHERIT_ENOMEM1process-state inherit failed (ENOMEM)
KACS_TASK_ALLOC2task_alloc completed
KACS_TASK_FREE3task_free teardown

kacs_primary_install reason — a primary-token / impersonation credential transition.

Distinguishes the install commit, the user-SID-change process-SD reallocation and its ENOMEM, the commit_creds apply, the impersonation override/revert, and the sibling-thread taskwork requeue/failure. old_primary and new_primary are opaque token identity ids (never token bytes). Verdict is the ret field. Emitted by kacs:kacs_primary_install.

ConstantValueNotes
KACS_PRIM_INSTALL_OK0primary token install committed
KACS_PRIM_SD_REALLOC1user-SID changed; process SD reallocated
KACS_PRIM_SD_ALLOC_FAIL2process SD realloc failed (ENOMEM)
KACS_PRIM_APPLY_COMMIT3new real creds committed (commit_creds)
KACS_PRIM_IMPERSONATE_INSTALL4impersonation token installed (override_creds)
KACS_PRIM_IMPERSONATE_REVERT5impersonation reverted (revert_creds)
KACS_PRIM_SIBLING_REQUEUE6queued sibling install re-queued on ENOMEM
KACS_PRIM_SIBLING_FAILED7queued sibling install failed after apply

kacs_process_token_open reason — the outcome of opening a process/thread primary or effective token (kacs_open_process_token / _thread_token and the proc inspection files). Distinguishes the bad access-mask reject, the no-target-token path, the process access-check denial, the self vs cross inspection verdicts, and the successful open. subject_token/target_token are opaque token identity ids (0 when unknown at the emit site); access_mask is the requested mask. Verdict is ret (>=0 fd == allow). Emitted by kacs:kacs_process_token_open.

ConstantValueNotes
KACS_PTO_OPEN_OK0token fd opened
KACS_PTO_BAD_ARGS1NULL subject/state/task guard
KACS_PTO_NO_TARGET2target has no token
KACS_PTO_BAD_ACCESS3invalid access mask rejected
KACS_PTO_ACCESS_DENIED4process access check denied
KACS_PTO_SELF5self-target inspection allowed
KACS_PTO_CROSS6cross-process inspection authorized

kacs_process_state reason — a process-state / process-SD lifecycle or PIP transition.

Covers process-state alloc/free, the CLONE_THREAD share vs fork inheritance split, the no-child clone block, the pending-exec-PIP stage/commit and dumpable hardening, and the process-SD alloc/wrap/replace primitives. process_state is an opaque state-object id (0 when none in scope, e.g. the process-SD primitives); pip_type/pip_trust carry the PIP tier. ret is the outcome (0 == ok). No token or SD bytes. Emitted by kacs:kacs_process_state.

ConstantValueNotes
KACS_PST_ALLOC0process state allocated
KACS_PST_ALLOC_FAIL1process state alloc failed (ENOMEM)
KACS_PST_FREE2process state freed (refcount hit 0)
KACS_PST_INHERIT_SHARE3CLONE_THREAD: parent state shared
KACS_PST_INHERIT_FORK4fork: new state allocated from parent
KACS_PST_EXEC_PIP_STAGE5pending exec PIP staged
KACS_PST_EXEC_PIP_COMMIT6pending exec PIP committed to state
KACS_PST_DUMPABLE7exec dumpable hardened by PIP
KACS_PST_CLONE_BLOCKED_NOCHILD8clone blocked by NO_CHILD mitigation
KACS_PST_SD_ALLOC9default process SD allocated
KACS_PST_SD_ALLOC_FAIL10process/socket SD alloc failed
KACS_PST_SD_WRAP_FAIL11process SD wrapper alloc failed (ENOMEM)
KACS_PST_SD_REPLACE12process SD replaced on state
KACS_PST_SOCKET_SD_ALLOC13default socket SD allocated

kacs_mount_policy reason — the outcome of a mount-policy set (TCB-gated) or get.

SET_OK marks a committed policy change (generation bumped); the guard codes name the pre-commit rejects that otherwise collapse into a bare -EINVAL/-EOPNOTSUPP/-EPERM. GET_OK/GET_NO_SECURITY are the snapshot paths. Verdict is also in ret. Emitted by kacs:kacs_mount_policy_set / _get.

ConstantValueNotes
KACS_MP_SET_OK0policy committed; generation bumped
KACS_MP_BAD_ARGS1NULL subject/sb/args guard (EINVAL)
KACS_MP_NO_SECURITY2superblock has no s_security (EOPNOTSUPP)
KACS_MP_UNMANAGED3magic-derived UNMANAGED; not settable (EOPNOTSUPP)
KACS_MP_VALIDATE4mount-policy args validation failed (EINVAL)
KACS_MP_TEMPLATE_INVALID5template SD bytes failed validation (EINVAL)
KACS_MP_TCB_DENIED6SeTcbPrivilege gate denied (EPERM)
KACS_MP_GET_OK7policy snapshot returned
KACS_MP_GET_NO_SECURITY8get: no s_security; magic-derived policy returned
KACS_MP_FIXED_POLICY9filesystem fixes its policy class (EOPNOTSUPP)

kacs_sd_syscall target_kind — which SD-bearing object a query/set record describes, resolved by the get_sd/set_sd syscall target-kind fallthrough. ACCESS_CHECK tags the AccessCheck ingress events (kacs_access_check*), whose other scalar fields are 0 at the ingress boundary.

ConstantValueNotes
KACS_SDS_KIND_TOKEN0token-fd target
KACS_SDS_KIND_FILE1file/inode target
KACS_SDS_KIND_PROCESS2pidfd process target
KACS_SDS_KIND_PATH3path-resolved file target
KACS_SDS_KIND_ACCESS_CHECK4AccessCheck ingress (not an SD get/set)

kacs_sd_syscall reason — the outcome of an SD query/set core.

QUERY_OK/SET_OK are the success paths; the remaining codes name the guard / denial paths that otherwise surface as an indistinguishable -EINVAL/-EACCES/-EOPNOTSUPP. Verdict is also in ret. Emitted by kacs:kacs_sd_query / _set.

ConstantValueNotes
KACS_SDS_QUERY_OK0SD subset extracted and returned
KACS_SDS_SET_OK1SD merged/replaced
KACS_SDS_BAD_ARGS2NULL/zero argument guard (EINVAL)
KACS_SDS_UNMANAGED3superblock UNMANAGED (EOPNOTSUPP)
KACS_SDS_ACCESS_DENIED4SD access check denied (EACCES)
KACS_SDS_NO_SD5target has no usable SD (EACCES)
KACS_SDS_RESTORE_BYPASS6set via SeRestorePrivilege bypass
KACS_SDS_QUERY_FAIL7subset extraction failed after auth

kacs_access_check reason — the AccessCheck kernel-ingress outcome, above the closed Slice 15 ABI bridge. OK is a completed ingress; the remaining codes name the ingress-time rejects (token-eval-context gate, token resolution, and caap-cache lock acquisition). Verdict is also in ret. Emitted by kacs:kacs_access_check / _list.

ConstantValueNotes
KACS_ACK_OK0ingress dispatched to the ABI bridge
KACS_ACK_EVAL_CONTEXT1token-eval-context gate denied (EACCES)
KACS_ACK_TOKEN_RESOLVE2token/args resolution failed
KACS_ACK_CAAP_LOCK_FAIL3caap-cache lock acquisition failed

kacs_file_snapshot op — which snapshot-grant file operation an event marks.

The allow-vs-deny verdict is read from ret; reason names why a deny path was taken. Emitted by kacs:kacs_file_snapshot (file_access.c).

ConstantValueNotes
KACS_FSOP_ACCESS0generic snapshot-grant access check
KACS_FSOP_PERMISSION1file_permission hook
KACS_FSOP_IOCTL2file ioctl snapshot
KACS_FSOP_LOCK3file lock snapshot
KACS_FSOP_FCNTL4file fcntl snapshot
KACS_FSOP_TRUNCATE5file truncate snapshot
KACS_FSOP_FALLOCATE6file fallocate snapshot
KACS_FSOP_MMAP7file mmap snapshot
KACS_FSOP_MPROTECT8file mprotect snapshot
KACS_FSOP_WRITE_INTENT9write-intent snapshot
KACS_FSOP_SYSFS_WRITE_GATE10unmanaged sysfs write gate

kacs_file_snapshot reason — why a snapshot-grant op took its return path.

DECISION is the resolved allow/deny (verdict in ret); the remaining codes name the distinct deny causes. Emitted by kacs:kacs_file_snapshot.

ConstantValueNotes
KACS_FSR_DECISION0resolved allow/deny (grant compare)
KACS_FSR_SIGNED_EXEC1signed-exec content mutation denied
KACS_FSR_GRANT_DENY2granted access lacked required right
KACS_FSR_APPEND_DENY3append/write intent lacked write grant
KACS_FSR_UNMANAGED_SYSFS4unmanaged fd: sysfs write gate applied
KACS_FSR_AUDIT_EMIT_FAIL5continuous-audit emit failed

kacs_metadata reason — the file-metadata (getattr/setattr/xattr/getsecurity) decision path.

DECISION/CONSUME_HIT/BEGIN_BUSY mark the begin/consume decision lifecycle; the remaining codes name the distinct deny reasons of the xattr setattr hooks. op_class carries the internal PKM_KACS_METADATA_OP_* value; matched is the consume match flag. Emitted by kacs:kacs_metadata (file_metadata.c).

ConstantValueNotes
KACS_META_DECISION0generic metadata decision
KACS_META_CONSUME_HIT1consumed a pre-staged decision
KACS_META_BEGIN_BUSY2begin failed: a decision already active
KACS_META_CANONICAL_SD3canonical SD xattr access denied
KACS_META_CAPS_XATTR4capability xattr mutation denied (EPERM)
KACS_META_ACL5POSIX ACL xattr denied
KACS_META_SIGNED_EXEC6signed-exec xattr/size mutation denied
KACS_META_BAD_ARGS7NULL name / dentry guard
KACS_META_INTERNAL_SD8internal SD read/write re-entry allowed
KACS_META_GETSECURITY9inode_getsecurity outcome

kacs_native_open_ext reason — a widening decision inside the native (kacs_open) create/open machinery. The PREPARE_* codes name the arg- validation reject buckets of pkm_kacs_prepare_native_open; RESOLVE / BUILD_CREATED_SD DELETE_ON_CLOSE_ARM name the later stage outcomes (verdict in ret). Emitted by kacs:kacs_native_open_ext (native_open.c).

ConstantValueNotes
KACS_NOX_PREPARE_OK0prepare accepted the request
KACS_NOX_PREPARE_BAD_FLAGS1flags/create_options/__pad rejected
KACS_NOX_PREPARE_BAD_SD_ARGS2sd_ptr/sd_len/disposition-sd combo bad
KACS_NOX_PREPARE_BAD_DISPOSITION3create_disposition out of range
KACS_NOX_PREPARE_BAD_ACCESS4desired-access mask invalid/empty
KACS_NOX_PREPARE_UNSUPPORTED5valid but unsupported combination
KACS_NOX_RESOLVE6resolve-existing-path outcome
KACS_NOX_BUILD_CREATED_SD7build-created-file-SD outcome
KACS_NOX_DELETE_ON_CLOSE_ARM8delete-on-close arm outcome

kacs_object reason — which object-lifecycle verdict a record marks.

Only the high-value transitions are traced (pure inode/file/sb alloc/free are not). ret is the outcome (0 == ok). Emitted by kacs:kacs_object.

ConstantValueNotes
KACS_OBJ_DELETE_ON_CLOSE_UNLINK0file_release delete-on-close unlink attempt
KACS_OBJ_SIGNED_EXEC_PIN1inode pinned as signed-exec (immutable)
KACS_OBJ_SIGNED_EXEC_MUTATION_BLOCKED2content mutation of a signed-exec-pinned inode denied

kacs_securityfs reason — which securityfs endpoint path a record marks.

The sessions_read codes disambiguate the deny rungs that otherwise collapse into an errno; open_self / init report the endpoint outcome. Verdict is ret. Emitted by kacs:kacs_securityfs.

ConstantValueNotes
KACS_SFS_LOGON_SESSIONS_NO_TOKEN0sessions read: no effective subject token
KACS_SFS_LOGON_SESSIONS_PIP_CONTEXT1sessions read: caller PIP context unavailable
KACS_SFS_LOGON_SESSIONS_ACCESS_CHECK2sessions read: rust access check denied
KACS_SFS_OPEN_SELF3open of kacs/self self-token file outcome
KACS_SFS_INIT4securityfs kacs/ endpoint init outcome

kacs_caap reason — which CAAP policy-cache path a record marks.

SET carries the post-set cache_len (an insert grows it; an evict/replace may shrink it); INIT/DESTROY are cache lifecycle; TCB_GATE is the SeTcbPrivilege gate deny. Emitted by kacs:kacs_caap. No SID or spec bytes — lengths only.

ConstantValueNotes
KACS_CAAP_TCB_GATE0SeTcbPrivilege gate denied the caller
KACS_CAAP_SET1cache set (insert/evict); cache_len is post-set count
KACS_CAAP_INIT2CAAP cache created
KACS_CAAP_DESTROY3CAAP cache destroyed

kacs_capability reason — the capability->privilege gate verdicts and the capability LSM-hook outcomes. ALLOW_GRANT is an auto-granted allow-cap; HARD_DENY is the SETPCAP/SETFCAP/MAC_OVERRIDE hard block; PRIV_NOT_ENABLED USE_MARK_FAIL are the mapped-privilege gate failures; CAPSET / PRCTL_GUARD CAPABLE / CAPGET report the corresponding hook outcome. Emitted by kacs:kacs_capability. Verdict is ret.

ConstantValueNotes
KACS_CAP_ALLOW_GRANT0allow-cap auto-granted (no privilege needed)
KACS_CAP_HARD_DENY1SETPCAP/SETFCAP/MAC_OVERRIDE hard-denied
KACS_CAP_PRIV_NOT_ENABLED2mapped privilege not enabled on token
KACS_CAP_USE_MARK_FAIL3privilege use-mark failed
KACS_CAP_CAPSET4capset core outcome
KACS_CAP_PRCTL_GUARD5prctl capability-guard outcome
KACS_CAP_CAPABLE6capable() hook guard-deny outcome
KACS_CAP_CAPGET7capget for-task outcome

kacs_privilege reason — the require_enabled_privilege gate rungs plus two standalone privilege-path markers. NULL_OR_ZERO is a null- token/zero-mask guard; NOT_ENABLED / USE_MARK_FAIL are the gate failures; CHANGE_NOTIFY marks the open_by_handle_at SeChangeNotifyPrivilege check outcome; RCU_ENOMEM_ FALLBACK marks the deferred-free ENOMEM synchronize_rcu fallback. Emitted by kacs:kacs_privilege. Verdict is ret.

ConstantValueNotes
KACS_PRIV_NULL_OR_ZERO0null token or zero privilege mask
KACS_PRIV_NOT_ENABLED1privilege not enabled on token
KACS_PRIV_USE_MARK_FAIL2privilege use-mark failed
KACS_PRIV_CHANGE_NOTIFY3open_by_handle_at CHANGE_NOTIFY gate outcome
KACS_PRIV_RCU_ENOMEM_FALLBACK4deferred-free kmalloc failed; sync-rcu fallback

kacs_tlp reason — the trusted-launch-path decisions.

CHECK_PATH marks a no-prefix-match executable-transition deny (path_len

  • prefix_count only, NEVER path or prefix bytes); REPLACE marks a prefix-table replacement. Emitted by kacs:kacs_tlp. Verdict is ret.
ConstantValueNotes
KACS_TLP_CHECK_PATH0executable transition denied: no prefix match
KACS_TLP_REPLACE1TLP prefix table replaced

Appendix 3.B Departures from MS-DTYP

Peios / Advanced Peios / PKM / KACS

KACS uses the binary formats MS-DTYP specifies, so a descriptor authored by a Windows domain controller and replicated through Samba is evaluated without translation. PCDS specifies those formats normatively.

Evaluator behaviour is a separate question. Given the same token, descriptor and desired mask, KACS generally reaches the same decision MS-DTYP describes — which is what makes policy authored in an AD environment behave predictably here — but it departs deliberately in the following places.

AreaDepartureWhy
Conditional ACE @Local.Resolved from an AccessCheck parameter rather than a token fieldThe context is per-call and varies between checks.
Virtual groups in expressionsMember_of({S-1-3-4}) returns true for the ownerKeeps the SID matcher and the expression evaluator semantically consistent.
INT64/UINT64 promotionRelational operators promote between the twoWithout promotion, UINT64 claims cannot be used in conditions at all.
Member_of filteringFiltered by ACE polarity, so deny-only groups do not satisfy allow-ACE conditionsConsistent with deny-only group semantics everywhere else.
Exists scopeExtended to all four attribute namespacesNo reason to restrict existence tests to Local and Resource.
ACE mask mappingACE masks are mapped through GenericMapping at evaluation timeRequired for GENERIC_ALL in central access policy recovery ACEs (§3.8.8).
MAXIMUM_ALLOWEDFirst-writer-wins for targeted and maximum-allowed requestsEliminates disagreement between "what can I do?" and "can I do this?" on a non-canonically ordered DACL.
Zero desired maskSucceeds rather than returning access denied"Asked for nothing, got nothing" is a valid answer.
Alarm ACEsRepurposed for continuous per-operation auditing (§3.8.9)Reserved but never implemented in the reference model.
Multiple scoped policy ACEsSeveral permitted per SACLAND semantics make composition safe.
Mandatory policy mutabilitymandatory_policy is immutable on the token (§3.2.2)A mutable policy reduces MIC to advisory.
Impersonation integrity ceilingEnforced unconditionally; SeImpersonatePrivilege does not bypass it (§3.5.2)MIC is a real boundary precisely because the mandatory policy is immutable.
Impersonation origin checkDroppedEliminates hidden impersonation paths.
PIP determinationKernel-only, from the binary signature, with no parent input (§3.3.2)One input, one answer, no ambiguity.
Object type list validationDuplicate GUIDs and level gaps rejected (§3.8.5)Prevents node lookup returning the wrong node and propagation becoming undefined.
Composite equalityElement-wise ordered comparisonNever over-grants.

3.B.1 Features handled elsewhere #

Several capabilities relevant to a complete security posture are not KACS's, and are named here so their absence is not mistaken for a gap. Kerberos and NTLM authentication, S4U, and credential storage and protection belong to authd, as do Resource-Based Constrained Delegation and Authentication Policies and Silos through the KDC. Active Directory replication is Samba's. Group Policy distribution goes through the registry and roles. Network share permissions belong to the Samba SMB layer. An Encrypting File System is a future service.

Appendix 3.C Audit Event Schemas

Peios / Advanced Peios / PKM / KACS

KACS emits its audit records through KMES with origin class 2 (§2.2). Each event's payload is a msgpack map; the shared subject and process sub-maps are attached at emission time from the resolved call context rather than by the evaluation pipeline (§3.8.9).

This appendix covers which events KACS emits and from where. The field-by-field payload schemas are in the Peios Events Index, which is canonical for them.

3.C.1 Event families #

Event typeEmitted by
access-auditThe SACL walk, and token audit-policy forcing.
continuous-auditEnforcement points, per operation, against a handle's continuous audit mask.
privilege-usePrivilege-use auditing, for the five AccessCheck-influencing privileges.
caap-policy-diagnosticA CAAP rule SACL error, or a staged-versus-effective mismatch.
logon-session-destroyedLogonSession teardown (§3.2.7).
corrupt-sdA descriptor xattr that exists but fails structural validation (§3.9.5).
STRATAFS_COPY_UPStrataFS copy-up lifecycle and failure (§3.9.7).
STRATAFS_MUTATION_REFUSEDA StrataFS arrangement refusal.

The privilege field of a privilege-use event carries a canonical name, and only five are representable — SeSecurityPrivilege, SeTakeOwnershipPrivilege, SeBackupPrivilege, SeRestorePrivilege and SeRelabelPrivilege. Any other bit fails the encoder closed rather than emitting an unnamed privilege, which is consistent with those being the only five that can produce such an event at all (§3.4.1).

continuous-audit carries an operation naming the enforcement point: file.access, file.mmap, file.mprotect, file.permission, file.write, file.ioctl, file.lock, file.fcntl, file.truncate and file.fallocate. Its object_context field is always nil.

3.C.2 Delivery #

Audit and privilege-use events are delivered before any result is written back to the caller, and a delivery failure fails the syscall with EIO or EOPNOTSUPP. An audit event cannot be suppressed by handing the call a bad output pointer.

Three emissions are best-effort by contrast, and drop silently rather than failing the operation that caused them: logon-session-destroyed where the authentication package name is not valid UTF-8; the two StrataFS events on an allocation failure or an over-long operation string; and any self-emitted payload that would exceed its encoding buffer.

The transport itself — ring buffer delivery, buffering and drop accounting — is KMES's (§2.5, §2.7).

Appendix 3.D KACS ABI Notes

Peios / Advanced Peios / PKM / KACS

§3.A is generated from pkm/uapi/pkm/ and holds only what a compiler can measure. This appendix holds the rest: the two ACE types that have a constant and no behaviour, the payload shapes behind the token query classes, the specification spellings a reader may arrive holding, what is deliberately documented elsewhere, and the kernel configuration KACS is built by.

The split is structural rather than editorial. gen-kacs-abi.py overwrites §3.A wholesale on every run, so anything written there is lost the next time the ABI changes — which is exactly what happened to two sections of this one before they were moved here.

3.D.1 ACE types with no evaluator behaviour #

Two of the ACE type constants in §3.A have a constant and nothing behind it. The ACE parser in kacs-core dispatches on 0x00–0x03, 0x05–0x14 and classifies every other value as opaque, so an ACE of type 0x04 or 0x15 is skipped during evaluation and written back byte-for-byte on serialisation. The constants exist so that a decoder can put a name to the byte. libpeios' SDDL codec does, printing 0x15 as SYSTEM_ACCESS_FILTER; the sd utility does not, and renders both as OTHER(0x04) and OTHER(0x15). PCDS §5.4 records the same state normatively.

3.D.2 Token query payloads #

The class numbers come from the header and are tabulated in §3.A; these are the payloads each one returns. Sizes are in bytes; a variable-length payload uses the shapes below. An invalid class returns EINVAL.

Two repeating shapes appear throughout. A SID array is [count:u32le] followed by count entries of [sid_len:u32le][sid_bytes][attributes:u32le], and reports a count of zero when the array is empty rather than an empty payload. A claims array is [count:u32le] followed by count entries of [entry_len:u32le][entry_bytes]. A bare SID is the SID bytes alone, and an absent optional SID or ACL is zero bytes.

ClassPayload
USERBare SID.
GROUPSSID array.
PRIVILEGES32 bytes: present, enabled, enabled-by-default and used, four u64 in that order.
TYPEu32, 4 bytes.
INTEGRITY_LEVELThe mandatory-label SID S-1-16-<level>, 12 bytes.
OWNERBare SID, resolved through the owner index: 0 is the user SID, N is groups[N-1].
PRIMARY_GROUPBare SID, resolved the same way.
INTERACTIVITY_SCOPEu32, 4 bytes.
RESTRICTED_SIDSSID array; count 0 on an unrestricted token.
SOURCE16 bytes: an 8-byte name followed by a u64 LUID.
STATISTICS40 bytes: token id, LogonSession id, modified id, token type, a reserved zero, and expiration.
ORIGINu64, 8 bytes.
ELEVATION_TYPEu32, 4 bytes.
DEVICE_GROUPSSID array.
APPCONTAINER_SIDBare SID; empty when the token is unconfined.
CAPABILITIESSID array.
MANDATORY_POLICYu32, 4 bytes.
LOGON_TYPEu32, 4 bytes, read from the LogonSession.
LOGON_SIDBare SID, derived from the LogonSession id.
DEFAULT_DACLBinary ACL; empty when none is set.
IMPERSONATION_LEVELu32, 4 bytes.
USER_CLAIMSClaims array.
DEVICE_CLAIMSClaims array.
PROJECTED_SUPPLEMENTARY_GIDS[count:u32le] followed by count u32 GIDs.

Nine token fields have no query class at all: created_at, token_guid, audit_policy, write_restricted, user_deny_only, isolation_boundary, confinement_exempt, the projected UID and GID — only the supplementary GIDs are reportable — restricted_device_groups, and the LCS registry credentials.

3.D.3 Names that differ from the specifications #

This manual uses the names uapi/pkm/ declares, and the generated tables of §3.A are authoritative for them. A reader may instead arrive holding the name PCDS uses, which is MS-DTYP's — a legitimate spelling, not an obsolete one, and the one a third party implementing PCDS will have. This table maps those onto the headers.

PCDS / MS-DTYPuapi name
ACCESS_ALLOWED_ACE_TYPE, SYSTEM_AUDIT_ACE_TYPE, ...KACS_ACE_TYPE_ACCESS_ALLOWED, KACS_ACE_TYPE_SYSTEM_AUDIT, ... (the qualifier moves to the front)
KACS_REAL_TOKENKACS_TOKEN_OPEN_REAL
KACS_LEVEL_*KACS_IMLEVEL_*
KACS_FILE_SUPERSEDE, _OPEN, ...KACS_DISPOSITION_*
OWNER_SECURITY_INFORMATION, ...KACS_SECINFO_*
SE_PRIVILEGE_ENABLED / _REMOVEDKACS_PRIVILEGE_ATTR_ENABLED / _REMOVED
KACS_PRIV_RESET_ALL_DEFAULTSKACS_PRIVILEGE_RESET_ALL_DEFAULTS
KACS_RESTRICT_WRITE_RESTRICTEDKACS_TOKEN_RESTRICT_WRITE_RESTRICTED
SE_GROUP_*KACS_SID_GROUP_*
TOKEN_CLASS_*KACS_TOKEN_CLASS_*

The PIP tiers have no public names at all. The Protected type (512) and the PeiosTcb trust level (8192) exist only as kernel-private constants, and nothing in uapi/pkm/ defines None, Protected or Isolated. A program reasoning about tiers compares the numbers (§3.7).

3.D.4 What is not here #

Required rights, error codes and validation rules are properties of the implementation rather than of the headers, so they are documented with the operations themselves: token rights and the per-ioctl requirements in §3.2.8, the file rights in §3.9, the process rights in §3.3.3, and the privileges in §3.4.2.

Two neighbouring ABIs are generated or documented separately. uapi/pkm/trace.h is a versioned, append-only ABI of tracepoint reason, operation and state codes intended for tooling. uapi/pkm/kmes.h and uapi/pkm/lcs.h belong to their own chapters.

3.D.5 Build configuration #

CONFIG_SECURITY_PKM=y and CONFIG_RUST=y are required, as are CONFIG_STRICT_DEVMEM=y and CONFIG_MODULE_SIG_FORCE=y -- the last two enforced at initialisation rather than only at build (§3.7). CONFIG_SECURITY_SELINUX, _APPARMOR, _SMACK and _TOMOYO are refused by Kconfig dependency; CONFIG_BPF_LSM is refused only at runtime, so a kernel enabling both configures and builds and then fails to initialise. CONFIG_LSM is never parsed.

Two further symbols gate large bodies of code: CONFIG_SECURITY_PKM_KUNIT, which compiles in the test harness and, in the signing path, a different and publicly known verification key (§3.6); and CONFIG_STRATAFS_FS, without which the copy-up API of §3.9.7 is inert.

4.1 Overview

Peios / Advanced Peios / PKM / stratafs

stratafs presents an ordered set of existing directories — its strata — as one merged directory tree. Each stratum is an ordinary directory on an ordinary filesystem, owned and written by whatever agent owns it, with no coordination with stratafs and no notification to it. The merged view reflects changes to any stratum without a remount.

It is a stacking filesystem in the strict sense: it stores no file data, no directory entries, and no security descriptors. Every object reachable through a stratafs mount is a real object on a real stratum, and every data and metadata operation is performed against that object. stratafs inodes carry no address-space operations, so there is no second page cache to keep coherent; reads, writes, mappings and splices are forwarded to a backing file opened on the provider.

Unlike overlayfs, stratafs has no whiteouts and no opaque-directory markers. There is no mechanism anywhere in the filesystem for recording that a name should be absent. That single omission accounts for most of what is unusual about it: removing a name can leave the name visible, some names cannot be removed at all, and several operations that succeed on an ordinary filesystem are refused here rather than faked. §4.8 collects the consequences.

4.1.1 Where it sits #

stratafs is not part of PKM. It is staged into the kernel tree as fs/stratafs, built by CONFIG_STRATAFS_FS — a boolean option, so it is linked into vmlinux — and registered by an fs_initcall. The option depends on CONFIG_SECURITY_PKM, because stratafs reaches KACS for every access decision and for the whole of the copy-up context. That reach is through <linux/kacs_stratafs.h>, a kernel-private header staged into include/linux whose symbols are deliberately not exported to modules: there is no userspace surface to the interface between the two, and no way for anything but the in-tree filesystem to enter it.

The filesystem type registers under the name stratafs, with the superblock magic 0x53545241 — ASCII STRA — and the flags FS_USERNS_MOUNT and FS_RENAME_DOES_D_MOVE. It takes no device; superblocks come from get_tree_nodev, so the device identifier reported for every object in a mount is an anonymous one belonging to the mount rather than to any stratum.

A small part of the filesystem is written in Rust. The crate stratafs-core is staged into the kernel alongside PKM's own Rust cores and holds three pure, allocation-free decisions: validating the stack-wide flag rules, selecting the provider from a presence bitmap, and routing one modifying operation. Everything else — the VFS glue, resolution, enumeration, copy-up — is C.

4.1.2 The model #

A mount is defined by its stratum stack: an ordered list of strata, highest-precedence first, fixed for the life of the mount. A stratum is identified by its path, not by the directory that path resolved to at mount time, which is what allows a package transaction to replace a whole stratum by renaming trees around underneath a live mount.

For a given name in a given directory, the provider is the highest-precedence stratum that holds it. A name whose provider is a directory, and which lower strata also hold as a directory, resolves to a merged directory whose entries are the union of theirs. A name whose provider is anything else masks every lower entry of that name completely, subtree and all.

At most one stratum carries the create flag. That create stratum receives newly created objects and is the destination of copy-up — the replication of an object into the create stratum so that a modification can be applied without touching the stratum that provides it. A stack may have no create stratum, in which case nothing can be created and nothing copied up.

Routing a modification is a decision about strata alone. It happens when the modifying operation is performed, never at open, and it never consults the caller: by the time an operation reaches stratafs, KACS has already decided the caller was entitled to perform it. §4.5.1 sets out why no other formulation is implementable.

4.1.3 What it delegates #

stratafs holds no security descriptors, so it makes no access decisions of its own about the objects it presents. The descriptor evaluated for an operation is the one on the object the operation will be performed against, and KACS reads it through the ordinary extended-attribute path, which the stacking layer forwards down to the provider. A stratafs mount cannot grant access its provider stratum would refuse.

A merged directory is the exception, because it stands for several real directories with several descriptors and forwarding yields only the provider's. Those checks stratafs performs itself, against every participating directory, requiring all of them to succeed (§4.6.2).

Copy-up is the other exception, in the opposite direction. It is machinery serving an operation that was already authorised, not an operation a caller requests, so it must introduce no new checks against whichever task happens to execute it. KACS provides a kernel-internal copy-up context for exactly this, described in full at §3.9.7; §4.6.3 covers the stratafs half.

4.1.4 This chapter #

§4.2 covers the stack, the mount options that define it, what a mounter must be entitled to, and absent strata. §4.3 covers resolution: providers, merging, type conflicts, and enumeration. §4.4 covers coherency — how uncoordinated change is observed, and the inode identity presented over it. §4.5 covers mutation: routing, copy-up, creation, removal, rename, links, locking and durability. §4.6 covers the security seam with KACS, and §4.7 the one interface stratafs synthesises for userspace. §4.8 collects the failure modes, including the divergences from ordinary filesystem behaviour that follow from having no whiteouts, and §4.A the constants.

4.2.1 The Stratum Stack

Peios / Advanced Peios / PKM / stratafs / Strata and Mounting

A mount is defined by its stratum stack: an ordered, non-empty list of strata, highest-precedence first. The stack is fixed for the life of the mount. Nothing reorders it, and precedence never varies by path, by caller, or by operation.

Index 0 is the highest-precedence stratum. That convention runs all the way through: strata are stored in a fixed array in mount-option order, presence across the stack is a u64 bitmap indexed by the same position, and selecting the provider is the trailing-zero count of that bitmap — the lowest set index, which is the highest-precedence stratum that holds the name.

The array is STRATAFS_MAX_STRATA entries, which is 16. A stack longer than that is refused at parse time. Absence never renumbers anything: an absent stratum simply has its bit clear, keeping its slot and its precedence.

4.2.1.1 A stratum is a path #

What is stored for each stratum is a string and a flag word — nothing else. No reference is held on a stratum's directory, and no resolution result is retained across operations. Every time a name is resolved, the stratum's path string is joined with the relative path of the name and walked from scratch.

This is what makes a stratum follow a wholesale replacement. A package transaction that renames the old tree aside and the new tree into place leaves the original directory object intact and referenced by anything that held it; a stratum defined as that object would go on serving the replaced tree. Defined as a path, it follows.

The cost is that every lookup does the walk. §4.4.2 covers what that means in practice, because the implementation does not cache resolutions at all.

4.2.1.2 The resolution context #

Stratum paths are joined absolute and walked from a root captured when the mount was created — the creating process's own filesystem root — under credentials captured at the same moment. Both are pinned for the life of the mount and released only when the superblock is freed.

The credential override matters as much as the root. A stratum path is resolved with the mounter's credentials, not with those of whatever process later touches the mount, so a caller in a different mount namespace cannot shift what a stratum denotes, and the paths reported by the origin attribute (§4.7) always name the same things.

Symbolic links and mounts along a stratum's path are followed as they stand at the moment of resolution, since the walk is an ordinary filename_lookup with no restricting flags. A mount established inside a stratum is therefore part of that stratum's tree, and one stratum can span several filesystems — which §4.4.3 has to account for when deriving inode numbers.

4.2.1.3 What a stratum's filesystem must provide #

Nothing beyond ordinary directory and file operations. stratafs probes no capability at mount time: it checks only that each stratum path resolves, names a directory, is not a duplicate of another stratum, and does not push the composed stack past the kernel's maximum stacking depth. There is no test for a usable directory version value, because nothing in the implementation would use one.

4.2.1.4 Flags #

Each stratum carries zero or more flags, declared with it in the mount options and stored as a bit field.

FlagBitMeaning
createSTRATAFS_F_CREATEThis stratum receives newly created objects and is the destination of copy-up.
roSTRATAFS_F_ROstratafs does not modify this stratum.
amSTRATAFS_F_AMThis stratum's directory may be absent.

The stack-wide rules are decided in Rust, in stratafs-core: the stack must be non-empty, must not exceed 16 strata, must contain no unrecognised flag bit, must not carry create twice, and must not carry create and ro on one stratum. The crate distinguishes five error cases, but the C boundary collapses all of them to EINVAL, so the distinction is not observable to a caller.

4.2.1.4.1 create #

At most one stratum carries create, and a stack may carry none, in which case create_index is -1 and every creation and every copy-up is refused with EROFS. Modification of an object provided by a stratum that accepts modification is unaffected, because routing tests the provider before it consults the create stratum at all (§4.5.1).

create does not mean "writable", and it is not the only stratum stratafs writes to. It designates where objects that do not yet exist in any stratum are created, and where an object is copied when its provider will not accept a modification. A stratum carrying neither create nor ro is modified in place, and any number of such strata may sit above the create stratum, below it, or both.

4.2.1.4.2 ro #

ro is a stratafs-level assertion, independent of whether the stratum's filesystem is itself read-only. It is one of three terms in the predicate that decides whether a stratum accepts modification of an object it provides:

  • the stratum does not carry ro;
  • the provider's mount is not read-only;
  • the provider's inode is not marked immutable.

The predicate takes only the superblock, the stratum index, and the provider path. It reads no credentials, consults no security descriptor, and calls into KACS not at all — which is what stops a caller who has been refused write access from provoking a copy-up (§4.5.1).

Note the third term is specifically the immutable inode flag. A file that is unwritable by its mode bits is not excluded by this predicate; the write is routed in place and the underlying filesystem refuses it.

4.2.1.4.3 am #

Without am, a stratum's directory must exist when the mount is created, and a mount whose stratum directory is absent fails with ENOENT. With am, an absent directory is accepted at mount time.

The flag governs mount time only. At runtime the resolver never consults it: a stratum whose directory has gone is skipped exactly the same way whether or not it carries am. §4.2.4 covers what absence means once the mount is live.

4.2.2 Mount Options

Peios / Advanced Peios / PKM / stratafs / Strata and Mounting

stratafs registers exactly one filesystem-specific mount parameter, strata, and rejects every other name. There is no option to select a security descriptor, an access-check behaviour, an inode-numbering scheme, or a caching mode.

strata=<stratum>[:<stratum>]...
<stratum> := <path>[+<flag>]...
<flag>    := create | ro | am

Strata are separated by : and given highest-precedence first. Each stratum is an absolute path, optionally followed by flags, each introduced by +.

strata=/system/retc:/lcl/etc+create:/usr/etc+ro

: separates strata rather than , because a mount options string is itself comma-separated when passed through the legacy mount interface, which would otherwise split the value.

4.2.2.1 Escaping #

Within a path, a literal :, +, , or \ is escaped by a preceding \. Those four characters are the entire escapable set; the escaped byte is stored literally, and the backslash is consumed.

Because the legacy mount(2) data is one comma-separated string, stratafs replaces the VFS's monolithic option splitter with one that honours backslash escapes, so that an escaped comma inside a stratum path survives the split rather than being read as an option boundary.

4.2.2.2 Parse failures #

Every malformed value is refused with EINVAL. The parser consumes the whole string and errors on any byte it cannot classify; there is no skip-and-continue path, so nothing it does not understand is silently ignored.

Condition
The strata= option is absentThere is no default stack
Its value is empty
An element between two separators is empty, or the value begins or ends with a separator
A path is not absoluteTested on the raw first byte, which is exact since / is not escapable
A path is empty after unescapingDefensive; the absolute-path test already guarantees one byte
An unescaped , appears in a path, or inside a flag tokenThe option string is comma-separated at the outer level
A \ appears at the end of the value, or before a character that is not :, +, , or \A dangling or meaningless escape
A + is followed by no flag, or by an unrecognised oneFlag names are matched by exact length and content
The same flag appears more than once on one stratum
More than 16 strataThe array bound is reached mid-parse
strata= appears twice in one option string

Two paths return ENOMEM rather than EINVAL, both allocation failures during parsing. Nothing bounds a stratum path at parse time: an over-long one is accepted here and fails later with ENAMETOOLONG when the joined path exceeds PATH_MAX during a resolution.

The stack-wide conditions — an empty stack, two create strata, a stratum carrying both create and ro — are checked separately, after parsing and before any path is resolved. They depend on nothing but the option string, so they are reported whatever the caller's access (§4.2.3).

4.2.2.3 Generic mount flags #

Generic flags apply as they do to any filesystem, with one addition. A stratafs mount may be mounted read-only, and the superblock's read-only state is the first term of the routing decision, short-circuiting before the provider or the create stratum is considered at all. It therefore refuses every mutation with EROFS regardless of the stratum stack, and is both independent of and stricter than a stack with no create stratum.

Locking and synchronising are unaffected. Neither modifies an object, neither consults the superblock's read-only state, and a reader of a merged tree may need both.

4.2.2.4 Remount #

A remount may alter generic mount flags. It may not alter the stack: any remount that supplies strata= at all is refused with EINVAL.

The check is on the presence of the parameter rather than on its value, so a remount that replays the current stack byte-for-byte is refused too — which matters, because that is what a tool reconstructing options from the mount table will do.

4.2.2.5 The mount table #

The stack is reported in the filesystem options the kernel exposes for mounts, so it is discoverable by anything that can read the mount table, which on Linux is unprivileged. What is stored for this purpose is the caller's own option string, kept verbatim at parse time: nothing is abbreviated, no stratum is omitted, no path is canonicalised, and an absent stratum is reported like any other, because the string is fixed at mount and never filtered by what currently exists.

The filesystem type is reported as stratafs.

The value is emitted through the kernel's seq_show_option, which applies its own escaping — octal for ,, \, and whitespace — on top of the escaping the value already carries. For a path containing none of : + , \ or whitespace, which is the ordinary case, the reported value is byte-identical to what was supplied. For a path containing any of them it is not reconstructable: a stored \: is re-escaped to \134:, which reads back as a dangling escape. This is tracked as a defect.

4.2.3 Mount Admission

Peios / Advanced Peios / PKM / stratafs / Strata and Mounting

A stratafs mount is configured entirely at mount time. This section covers what the caller must be entitled to, the conditions a configuration has to satisfy, and the order in which those conditions are evaluated.

4.2.3.1 Entitlement #

Establishing a mount with no create stratum takes no privilege of its own. The filesystem type carries FS_USERNS_MOUNT, so an unprivileged mount in a user namespace is permitted; what the caller needs is only the access that resolving the stratum paths already requires, which falls out of the resolution itself.

A stack that carries create is different. Copy-up is authorised by the outer handle and deliberately requires no add-entry right on the create stratum (§4.6.2), so the mount configuration carries authority to materialise names in a real directory outside the mount. The caller establishing such a stack must be operating in the initial user namespace and hold CAP_SYS_ADMIN there; anything else fails with EPERM.

The test is made in get_tree, before the tree is built and therefore before any stratum path is resolved, and it is stricter than requiring the capability alone: the credential's own user namespace must be the initial one, not merely a namespace in which the capability resolves. This is the closure the KACS copy-up context depends on. The check is made once, when the immutable stack is established, rather than at each copy-up, so descriptor delegation cannot reintroduce authorisation against the acting task, and no reconfiguration can re-supply the strata list.

There is no explicit per-stratum entitlement check in the mount path. Both halves come out of the ordinary machinery: traverse rights are enforced by the path walk, which runs under the mounting caller's credentials, and the right to read a stratum directory's attributes is enforced by using the security-checking vfs_getattr rather than its unchecked variant, which reaches KACS's getattr hook and demands FILE_READ_ATTRIBUTES.

One seam is worth recording: the path walk uses the credentials captured on the filesystem context, while vfs_getattr runs outside that override and so uses the acting task's. For a mount established the ordinary way the two are the same task. For one established with fsopen and fsconfig from different tasks they need not be.

Nothing re-checks entitlement if a stratum directory later appears. That is sound because every access through the mount is checked against the providing object in any case (§4.6.1), so a caller who mounts a stratum they cannot read still cannot read it.

4.2.3.2 Validity #

ConditionError
The stratum stack is emptyEINVAL
The stack exceeds 16 strataEINVAL
More than one stratum carries createEINVAL
A stratum carries both create and roEINVAL
The same directory appears as more than one stratumEINVAL
A malformed strata= value (§4.2.2)EINVAL
A create-bearing stack from outside the initial user namespace, or without CAP_SYS_ADMIN thereEPERM
A stratum's path names something other than a directoryENOTDIR
A stratum's directory is absent and the stratum does not carry amENOENT
The composed stack reaches the kernel's maximum stacking depthELOOP
A stratum lies within the mount point, or within another stratafs mount whose strata include this mount pointELOOP
Sixteen consecutive collisions allocating a mount cookieEAGAIN

Two strata are the same directory when they resolve to the same directory object, not merely when their paths are equal as strings: the comparison is on resolved inodes, so two paths reaching one directory through different symbolic links or bind mounts are caught. Absent strata are skipped and never compared.

4.2.3.3 Evaluation order #

The conditions that depend on nothing but the option string are decided first, during parsing and stack-wide validation, and are reported whatever the caller's access. The EPERM admission test comes next, in get_tree, still before any path is touched. Only then are the strata resolved.

The purpose of that ordering is to stop the validity conditions being an oracle: a caller with no right to traverse a directory should not be able to name it as a stratum and learn from the errno whether it exists and whether it is a directory.

For a single stratum, that holds. The path walk runs under the caller's credentials, so a path the caller cannot resolve returns EACCES from the walk itself, before the type test or the duplicate test is reached, and the EACCES is propagated unchanged.

Across the stack it does not. The strata are checked in one loop — resolve, stat, type-test, compare against earlier strata — so stratum 0 is fully judged before stratum 1 is resolved at all. A caller who names a readable stratum first and an unreadable one second learns the first stratum's ENOTDIR or ENOENT rather than the EACCES they would have been given had the whole stack been checked for entitlement first. This is tracked as a defect; the disclosure is bounded to paths the caller could resolve, but the specified ordering is stack-wide.

The mount-point loop condition is evaluated in a different call entirely, after the tree has been built, and so always follows every entitlement check.

4.2.3.4 Loops #

A stratum inside the mount point is detected directly. The indirect case — a stratum inside another stratafs mount whose own strata include this mount point — is detected by recursing into any stratum whose superblock carries the stratafs magic, bounded by a visited- superblock set and by the kernel's maximum stacking depth. This runs through a Peios-added super_operations hook, validate_mountpoint, wired into the new-mount, bind and move-mount paths by the patch series; it is not an upstream interface.

That check cannot bind after the fact: a mount established within a stratum, or a bind mount of this mount into one of its own strata, can create a cycle later. Resolution is guarded separately, and differently — not by a depth counter but by a per-task, per-superblock re-entrancy list. A task that is already resolving in a superblock and re-enters it gets ELOOP immediately, so a cycle spanning any number of stratafs mounts terminates the moment it returns to one it is already inside.

4.2.3.5 Immutability and mount identity #

The stack is fixed for the life of the mount; changing one is expressed by unmounting and mounting again (§4.2.2).

Two stratafs mounts may name the same directory as a stratum. Each resolves independently and neither is aware of the other; where both have a create stratum in common they mutate the same objects, with the same result as any two writers of one directory. There is no registry of stratum paths to make them aware of each other.

There is a registry of mounts, but it exists for a different purpose. Each mount draws a random non-zero cookie and inserts itself into a global table, retrying up to sixteen times on collision; a second random non-zero cookie is drawn once per boot. The pair identifies which live mount owns a staging entry, so that a mount sharing a create stratum can distinguish another mount's copy-up in flight from an orphan left by a crash (§4.5.2).

A mount succeeds even when no stratum root is present at all — a stack whose only strata are absent am strata is legal. The root inode is constructed with no provider, and reports mode S_IFDIR with no permission bits.

4.2.4 Absent Strata

Peios / Advanced Peios / PKM / stratafs / Strata and Mounting

A stratum's directory may not exist. It may be absent when the mount is created — which requires am (§4.2.3) — or exist at mount time and be removed while the mount is live, which nothing prevents for any stratum.

4.2.4.1 While absent #

An absent stratum holds no names, participates in no merged directory, and contributes no entries to any enumeration. It keeps its position and its precedence; only its presence bit is clear.

The mechanism is a single test in the resolver. Resolving one stratum either succeeds, or fails with ENOENT or ENOTDIR, in which case the stratum is skipped and its bit is left clear. Any other error — EACCES, EIO, ELOOP, ENAMETOOLONG, ESTALE — is not treated as absence and fails the whole resolution instead. So a stratum that is unreadable for a reason other than not being there masks the name for every stratum, rather than being passed over.

An absent create stratum additionally causes every operation that would create or copy up to fail with EROFS. The create stratum's root is re-resolved at the point of use and its ENOENT is mapped to EROFS by each of the paths that needs it — the copy-up parent walk, the creation authorisation pre-check, and the routing decision, which computes create-stratum presence live and requires the result to be a directory. stratafs does not create the stratum's own directory to satisfy such an operation: establishing that directory, with the security descriptor it should have, belongs to whatever provisions the system, and a mount that minted it would be choosing that descriptor.

4.2.4.2 Appearing and disappearing #

Neither event is detected. Both are simply observed, because there is nothing to invalidate.

A stratum's directory is never held; only its path string is. Every resolution walks that string afresh, so a directory that comes into existence is picked up by the very next lookup, and one that is removed, renamed away, or replaced by another directory is picked up just as immediately — the walk finds nothing, or finds the replacement.

The specification describes this in terms of a version tuple recorded over the nearest existing ancestor of an absent stratum's path, and an identity comparison for a stratum that disappears. Neither exists in the implementation, and neither is needed: they are the machinery for knowing when a cached resolution has gone stale, and nothing is cached. §4.4.2 covers the trade that represents.

The am flag is not consulted at runtime at all. It governs whether the mount is allowed to be established with the directory missing, and nothing more; a stratum without it that vanishes afterwards behaves exactly like one with it.

4.2.4.3 Reappearance #

A stratum that disappears and reappears is the same stratum in the same stack position, and nothing about the old directory is remembered. Resolutions are recomputed against whatever the new directory holds.

One piece of state does survive the gap, though it is not resolution memory. The inode-number identity map (§4.4.3) is keyed on the provider inode object and pins it for the life of the mount, so if the same underlying inode is reached again — because the directory was renamed away and back rather than replaced — it receives the same inode number it had before. That is exactly what the identity rule requires: equal numbers for one provider object, whatever path reached it.

4.3.1 Lookup

Peios / Advanced Peios / PKM / stratafs / Name Resolution

Resolution is defined for one name in one directory. Every path operation is a sequence of such resolutions, each independent of the last.

4.3.1.1 The provider #

To resolve a name in a stratafs directory, the corresponding directory of each stratum is examined in precedence order, and the first stratum holding an entry of that name is its provider. If no stratum holds it, the resolution produces a negative dentry and the VFS reports ENOENT.

Mechanically, each stratum's path string is joined with the relative path of the name and walked in full, once per stratum. A walk that succeeds sets that stratum's bit in a presence bitmap; a walk that fails with ENOENT or ENOTDIR leaves it clear and the stratum is skipped. Selecting the provider is then the trailing-zero count of the bitmap, computed in stratafs-core.

Any other error from a stratum's walk — EACCES, EIO, ELOOP, ENAMETOOLONG, ESTALE — is not treated as absence. It fails the whole resolution, so a stratum that is unreadable for a reason other than not being there masks the name entirely rather than being passed over.

A joined stratum path, or a child relative path, that would exceed PATH_MAX fails with ENAMETOOLONG.

4.3.1.2 Ancestors #

Resolution does not consult a parent's provider to find a child's. A name's provider is chosen afresh across all strata, so if /a is provided by stratum 2, /a/b may still be provided by stratum 1 — provided stratum 1 also holds /a as a directory and the two therefore merge (§4.3.2).

What resolution does consult is whether any ancestor of the path is masked. Before resolving the final component, every proper prefix of the relative path is resolved across all strata and its merged provider computed; a prefix whose provider is not a directory aborts the whole resolution with ENOTDIR. That is what makes masking total (§4.3.3), and it is why a lookup costs one full walk per stratum for the name itself plus one merged resolution per path component above it.

4.3.1.3 Independence from the caller #

Resolution runs under the credentials captured at mount, against the root captured at mount, and takes no operation argument. It does not depend on the calling token, on what the caller is trying to do, or on whether the operation will ultimately be permitted.

A name whose provider the caller may not access therefore resolves normally and is then refused. It does not fall through to a lower stratum — which would let a caller's rights change which file they read, a considerably worse property than a denial.

4.3.1.4 Reaching the object #

Once a name resolves to a non-directory provider, the object is the provider's object and stratafs does not interpose on its contents. The outer inode takes the provider's mode and, from it, the operations tables for a regular file, symlink or special file; reads, writes, mappings, splices, locks and ioctls are forwarded to a backing file opened on the provider.

Symbolic links are forwarded rather than followed. The outer inode's get_link calls the provider inode's own and returns the raw target verbatim; stratafs deliberately does not use vfs_get_link, which would demand a read right the caller need not hold to traverse a link. The VFS then interprets the target in the caller's own namespace, so an absolute target resolves from the process's root and may re-enter this mount, another stratafs mount, or none.

Where a mount is established at a path within a stratum, stratafs follows it: the stratum walk is an ordinary filename_lookup with no flag restricting it to one filesystem, and everything downstream operates on the inner mount it returns.

4.3.1.5 Staging entries #

One class of name is invisible to resolution. While a copy-up is in flight, its staged object may exist under a name in the create stratum; that name is dropped from resolution for the mount that owns it, so an incomplete copy is never reachable through the merged view (§4.5.2). The suppression applies only to the create stratum, and only within the owning mount — a second stratafs mount sharing that directory, and any direct reader of it, sees an ordinary entry.

Staged names begin with .stratafs-stage-, and a lookup of any name with that prefix triggers a recovery scan of the create-stratum parent before resolving. A resolution can therefore have the side effect of removing orphaned staging entries from the create stratum.

4.3.1.6 Recursion #

A task that is already resolving inside a superblock and re-enters the same superblock fails immediately with ELOOP. The guard is a global list of task-and-superblock pairs, not a depth counter, so a cycle formed after mount — a stratafs mount established inside one of its own strata, or bind-mounted into one — terminates the moment resolution returns to a mount it is already inside.

4.3.2 Directory Merge

Peios / Advanced Peios / PKM / stratafs / Name Resolution

Where a name's provider is a directory, and lower-precedence strata hold the same name as a directory, the resolved object is a merged directory.

4.3.2.1 Participation #

The strata participating in a merged directory are, in precedence order, those whose corresponding directory both exists and is a directory. A stratum holding the name as a non-directory does not participate and is masked entirely (§4.3.3); a stratum not holding the name simply does not participate.

Every consumer of a merged directory applies the same two-part filter — the presence bit is set, and the resolved dentry is a directory — in a single ascending loop over stratum indices with a continue for non-participants. Nothing compacts or re-sorts, so relative precedence within a merged directory is always the relative precedence in the stack.

Merging is recursive, and it is recomputed rather than cached: there is no merged-directory object anywhere. Each lookup and each directory open rebuilds the full per-stratum path set from the relative path string, so any child that is a directory in more than one stratum merges at its own level by the same rule.

The root of a mount is a merged directory whose participants are the mount's strata. The root has one special case: where the ordinary provider rule would pick a stratum root that is present but not a directory, the root instead takes the first stratum that is both present and a directory, so an absent or non-directory stratum root cannot change the synthetic root's type. A mount whose stratum roots are all absent still has a root inode, with no provider and no permission bits.

4.3.2.2 The create stratum of a merged directory #

A merged directory's create stratum is the correspondingly-named subdirectory of the mount's create stratum, at the same path relative to the mount root — whether or not that subdirectory currently exists. It follows that a merged directory has a create stratum even when the mount's create stratum holds no part of that path, and creation still routes there.

The derivation is positional and mount-wide: create_index is a single integer on the superblock, fixed at mount, and every creation and copy-up site reads it directly. Nothing re-derives it from which strata happen to participate.

That matters because the alternative — taking the create stratum to be the highest-precedence participating writable directory — would make the destination of a write depend on which directories happened to exist, so creating a file in a subdirectory could land in a different stratum from creating one beside it.

Where the create stratum's counterpart of a merged directory does not exist, the path is materialised on demand, from the mount root downwards, at the point an operation first needs it (§4.5.2). The authorisation for creating into it is evaluated against the descriptor that directory will carry once materialised, which is the corresponding provider directory's (§4.6.2).

4.3.2.3 Symbolic links and participation #

Two resolution entry points disagree about the final component of a stratum path, and the difference is visible.

Ordinary lookup resolves without following the final component, so a symbolic link at a name resolves to the link itself and the VFS follows it. Building the participant set for a merged directory follows the final component, and so do the emptiness scan, the foreign-entry scan, the permission check and directory fsync.

The consequence is that a stratum holding a name as a symlink to a directory participates in the merged directory, contributing the target's entries to the merged listing and to emptiness tests, while a stratum holding a dangling symlink at that name participates in resolution but not in the participant set. Whether that is intended is an open question against the specification, which describes a stratum holding a non-directory as masked; it is tracked as a defect.

4.3.3 Type Conflicts

Peios / Advanced Peios / PKM / stratafs / Name Resolution

Two strata may hold the same name with different types — a directory in one, a regular file in another. The provider's type is the type of the resolved object, and the outer inode is given the provider's mode and the operations tables that follow from it.

  • Where the provider is a directory, lower-precedence strata that hold the name as a directory participate in a merged directory (§4.3.2). Lower-precedence strata that hold the name as anything else are masked.
  • Where the provider is not a directory, every lower-precedence entry of that name is masked, whatever its type. Only the provider's path is retained on the dentry; the other strata's references are released as soon as the provider is chosen.

4.3.3.1 Masking is total #

A masked entry is unreachable through the mount. Where the masked entry is a directory, its entire subtree is unreachable: no path beneath the masked name resolves, regardless of what the masked directory contains and regardless of whether some other stratum holds part of that subtree.

This is enforced by the ancestor pass described in §4.3.1. Each proper prefix of a relative path is resolved across every stratum and its merged provider computed; a prefix whose merged provider is not a directory returns ENOTDIR before the final component is considered. Because the test uses the merged answer rather than a per-stratum one, another stratum holding the subtree cannot rescue it.

A regular file at /x in a high-precedence stratum therefore hides a whole /x/… tree in a lower one. That is severe, and deliberate: the alternative — resolving /x as a file but /x/y through the masked directory — would make a path's meaning depend on how far along it the caller looked.

Masking modifies nothing. Resolution and enumeration are read-only throughout, no marker is written to any stratum, and the masked entry remains present and unchanged in its own stratum, reachable by any path that does not traverse the mount.

4.3.3.2 Stability #

Type conflicts resolve identically for every caller and every operation, because provider selection is a pure function of the presence bitmap and consults neither.

An operation that would only be valid against the masked type does not cause the masked entry to be selected. It fails against the provider instead, with whatever error that type produces — typically ENOTDIR from the ancestor pass, or EISDIR raised by the generic VFS against an outer inode carrying the provider's mode.

4.3.4 Enumeration

Peios / Advanced Peios / PKM / stratafs / Name Resolution

Enumerating a merged directory yields the union of the names held by its participating strata, with each distinct name appearing exactly once.

4.3.4.1 Capture at open #

The whole listing is built when the directory is opened, and the enumeration is served from that capture for the life of the descriptor. Each participant is opened and iterated in ascending stratum order, and its entries are appended to one list.

Deduplication is global rather than per-stratum: before an entry is recorded, the whole accumulated list is scanned for the same name, and a match causes the entry to be dropped. Because participants are visited highest-precedence first, the entry that survives is always the provider's. . and .. are dropped from every participant and synthesised once.

Each surviving entry carries the name, its length, the directory entry type reported by the providing participant, and an inode number obtained by looking the child up in that same participant and mapping the result through the identity table (§4.4.3), so getdents and stat agree. The final component is not followed during that lookup, so a symlink entry reports its own identity rather than its target's.

Two details of that: an entry that vanishes between the participant's own readdir and the follow-up lookup is silently dropped, which is ordinary provider behaviour; and a participant filesystem that reports DT_UNKNOWN has that propagated unchanged, even though the child path is in hand and the real type could be derived.

Shadowed entries are neither reported nor otherwise detectable. No second record is ever allocated, so nothing about them survives into the listing, and the entry count reflects distinct names only.

The order in which names are reported is stratum-ascending and then each stratum's own readdir order — deterministic for one capture, and not otherwise specified.

4.3.4.2 Consistency #

There is exactly one capture per descriptor. Nothing appends to the list after the directory is opened, and nothing re-captures — a rewind replays the original capture, since the directory uses the generic llseek. A change to any participating stratum after the open is therefore invisible to that descriptor for its lifetime.

What that bounds is only what stratafs itself contributes. Each participating directory is enumerated by its own filesystem, with whatever consistency that filesystem offers its own callers, and participants are read sequentially with no cross-stratum lock or barrier. A merged listing is no better than the listings it is assembled from, and nothing claims otherwise.

4.3.4.3 The settled participant set #

For the purpose of enumeration, the participating set is settled when the directory is opened and does not change for the life of the descriptor. What is settled is the set of participating directory objects, not the set of stratum positions: the descriptor holds a struct path reference on each participant, pinned until release, and nothing re-resolves those positions by path. A participant that has since been removed and replaced by another directory at the same path is a different object and contributes nothing.

The settled set has exactly three consumers: the enumeration itself, the access check performed when the directory is opened (§4.6.2), and the origin attribute read through that descriptor (§4.7). It extends to nothing else. Resolving a name relative to the descriptor — opening, removing, renaming, linking — is an ordinary live resolution under §4.3.1, performed against the strata as they are at that moment.

So a descriptor opened before a stratum began to hold the directory will not list that stratum's names, but openat through the same descriptor will resolve them. The two answers differ deliberately: enumeration is a bulk disclosure whose authorisation was decided once, when the descriptor was opened; a resolution is a fresh operation that carries its own check.

Freezing the participant set is what keeps that open-time check meaningful. Were a later re-read free to admit a stratum that joined afterwards, its names would reach the caller without its directory's descriptor ever having been consulted — and a stratum owner could make a directory participate precisely to expose it. A caller that wants the current participant set reopens.

4.3.4.4 Positions #

Offsets 0 and 1 are . and ... Offset 2 + k is the k-th element of the captured list — a plain ordinal. The offset each participant filesystem supplies is discarded: no provider cookie, no stratum index, and no name hash is encoded.

A position is therefore meaningful only within one open file description. On close and reopen — and so across a remount or a reboot — the capture is rebuilt from each stratum's current contents and current readdir order, and ordinal 2 + k may name a different entry or none. telldir and seekdir across descriptors are unreliable on a stratafs directory, as is NFS re-export of one.

4.3.4.5 Access #

Enumeration requires traverse and list rights on every participating directory, checked before the capture is built. The check returns on the first refusal, and a refusal aborts the open entirely, so no partial listing covering only the readable strata can be produced.

4.4.1 The Coherency Model

Peios / Advanced Peios / PKM / stratafs / Coherency

Every stratum of a mount may be modified at any time by an agent that does not know stratafs exists. stratafs observes such changes without being told of them.

4.4.1.1 No coordination #

There is no notification machinery anywhere in the filesystem — no fsnotify registration, no inotify, nothing a stratum's filesystem is expected to report. No writer announces a change, quiesces, or participates in any protocol. Every resolution is performed from scratch by re-walking the stratum path string, so nothing has to be told anything.

4.4.1.2 What can change a resolution #

A resolution depends only on which names each participating stratum directory holds and what type each entry has. It does not depend on the contents of any file.

A change to an object's contents therefore requires no action at all. stratafs inodes carry no address-space operations; there is no second page cache, and every data operation is forwarded to a backing file opened on the provider. A change to that object is observed through the mount immediately and by construction, because there is nothing to invalidate.

A change to the structure of a participating stratum directory — an entry created, removed, renamed, or replaced by one of another type — may change which stratum provides a name. §4.4.2 covers how that is handled, which is by not caching anything.

4.4.1.3 The guarantee #

A structural change in a stratum becomes visible to any resolution begun after the stratum's own filesystem exposes that change to an ordinary lookup. stratafs adds no delay of its own: there is no timeout, no jiffies comparison, no generation counter, and no resolution cache to serve a stale answer from.

It cannot anticipate a change the underlying filesystem is not yet reporting. A network filesystem holding an attribute cache does not show a change to stratafs any sooner than to any other caller, and nothing claims otherwise.

Resolutions already completed are not revisited. Every regular-file open is detached onto a descriptor-private dentry and inode holding their own reference to the provider, and I/O runs against the file opened at that time, so later masking or removal in any stratum cannot reach that descriptor. It is not re-pointed and it does not fail. A process holding a configuration file open across a package upgrade continues to read the file it opened.

The one exception is copy-up, which §4.4.3 covers: a descriptor whose own write caused a copy-up keeps its inode while that inode's backing object becomes the copy.

4.4.1.4 Live strata #

Because resolutions are made against current state, and because a stratum is a path rather than a directory object (§4.2.1), a stratum's directory may be replaced wholesale — by a package transaction, by a reconciler, by an administrator — while the mount is live, with no remount and no interruption to callers. That is the requirement the filesystem exists to satisfy, and §4.4.2 is the whole of its cost.

4.4.2 Revalidation

Peios / Advanced Peios / PKM / stratafs / Coherency

The specification permits an implementation to cache resolutions, subject to a version tuple recorded per stratum and an identity comparison on reuse. This implementation caches nothing, and so implements neither.

4.4.2.1 Always invalidate #

d_revalidate returns 0 for every dentry except the mount root. The VFS therefore discards the dentry and re-enters lookup on every path walk, and the lookup re-resolves the parent and the child from their relative path strings across every stratum.

Nothing is memoised. No version value is recorded, no directory identity is retained for comparison, no nearest-existing-ancestor walk happens, and no i_version is read anywhere in the filesystem. The per-dentry provider path that is retained is not a cache of a resolution: it is dropped when the dentry is released, and the dentry is released on the next walk.

The specification's machinery exists to know when a cached resolution has gone stale. With no cached resolution, the questions it answers do not arise:

What the tuple would detectWhy it is unnecessary here
A stratum gaining or losing a nameThe next walk resolves the name afresh
A stratum's directory appearingThe next walk finds it
A stratum's directory removed, renamed away, or renamed overThe next walk finds nothing, or finds the replacement

The result is strictly stronger than the specification requires, in the safe direction. The two internal counters the superblock does carry — the inode-number allocator and the staging-name counter — are consulted by nothing in this path.

4.4.2.2 What it costs #

The cost is real and lands on the hottest path in the kernel.

d_revalidate refuses RCU-walk unconditionally: a lookup in RCU mode returns ECHILD and the walk is retried in ref-walk mode, and the directory permission check does the same. RCU-walk is therefore never used on a stratafs mount, and the fallback is taken always rather than only where it genuinely cannot proceed.

What replaces it, per path component, is one full filename_lookup per stratum — up to sixteen — plus one merged resolution per proper prefix of the path, for the ancestor-masking test of §4.3.3. Where a mount is established over a directory of executables, that lies on the path of every program execution, and there is no dentry cache hit to avoid it.

This is the one part of the filesystem whose cost is worth measuring rather than assuming, and closing the gap — recording enough per resolution to reuse one safely, and making the comparison RCU-safe — is tracked as work in its own right.

4.4.3 Inode Identity and Lifecycle

Peios / Advanced Peios / PKM / stratafs / Coherency

stratafs presents its own inodes, allocated from its own superblock. Each stands for a provider object and forwards operations to it; none holds file data, directory entries, or a security descriptor.

4.4.3.1 Reported identity #

The device identifier reported for any object in a mount is the stratafs superblock's own anonymous device, not the provider's. getattr calls the provider's directly and then overrides dev, ino and, for directories, nlink; all four inode-operations tables install that same getattr, so there is no path around it.

The inode number is allocated, not derived. A per-mount monotone counter hands out a number for each distinct provider inode the first time it is seen, and the pair is recorded in an xarray on the superblock keyed on the provider inode, holding a reference on it so the object cannot be freed and its address reused while the mount lives. The counter starts at 1 and is pre-incremented, so the first number handed out is 2. The provider's own inode number and device are never read for this purpose.

That satisfies the identity requirements — two names compare equal exactly when they resolve to one provider object, since the map is keyed on the object itself and not on the stratum that reached it, so hard links compare equal and one directory reached through two strata compares equal too. It does not follow the specification's advice to derive the number from the provider's, and it therefore pays the cost that advice exists to avoid: the map is never evicted from, and it pins every provider object ever reached through the mount until unmount. A mount whose strata would not have provoked the fallback pays it anyway.

Two mechanical details. The map key is the provider inode's kernel address shifted right by three; a collision would be caught by the stored back-pointer and turned into an allocation failure rather than a false equality, so it fails rather than lying. And the stored number and i_ino are unsigned long while the counter is 64-bit, so on a 32-bit build the number truncates.

4.4.3.1.1 The mount root #

The root inode is created once, when the superblock is filled, and its number is never recomputed — d_revalidate returns 1 for the root, so it is never replaced. Its provider is re-resolved live on every use, so the root's mode, owner and timestamps are always current; only the number is not.

When the root's provider changes — a higher-precedence stratum root appears, or the mount-time one is removed — stat on the mount point keeps reporting the number allocated for the mount-time provider. Where no stratum root existed at mount, it reports a bare counter value corresponding to no provider object at all. If the root's current provider is also reachable at some other merged path, that path reports the object's mapped number while the root reports its stale one, so two paths naming one object disagree. This is tracked as a defect.

4.4.3.2 Attributes of merged directories #

A merged directory's owner, group, mode and timestamps are its provider's — the highest-precedence participating directory — taken straight from a getattr on the provider path, so what is reported is a real directory's attributes rather than a composite.

Its link count is forced to 1, both in the cached inode and in the reported stat. The true count of subdirectories spans strata and cannot be maintained, so the value carries no meaning beyond indicating that the object is a directory. Nothing should infer a subdirectory count from it.

The security descriptor of a merged directory is not a single descriptor; §4.6.2 defines which participating directory's descriptor governs each operation.

4.4.3.3 Provider change #

When the provider for a path changes — because a higher-precedence stratum gained the name, because the previous provider's entry was removed, or because copy-up produced a new object — a resolution of that path yields a new inode. An inode reached by resolution is never re-associated with a different provider.

That is required because per-inode state is populated from the provider the inode was resolved against and is not in general re-derivable. In particular KACS caches a security descriptor against the outer stratafs inode; applying it to a different provider's object would govern access to one object by another object's descriptor.

The implementation enforces this bluntly. The one function that re-associates an inode with a new provider is reachable from exactly one call site, on the descriptor-private dentry created at open, and never on a hashed dentry reached by resolution. A copy-up performed for a path rather than a descriptor drops the dentry instead of rebinding it. Provider change by masking or removal needs no special handling at all, because the unconditional invalidation of §4.4.2 forces a fresh lookup and a fresh inode.

An object's reported inode number therefore changes when its provider changes. That is expected: it is the same change a caller would observe if the file had been replaced, which is what has happened.

4.4.3.3.1 Descriptors already open #

A descriptor open at the moment its own operation copies its object up is not re-pointed. It keeps the inode it was opened against, and that inode's backing object becomes the copy: the provider path and provider inode are swapped in place under a per-inode lock, the attributes are refreshed, and the backing file is replaced, so subsequent operations through the descriptor reach the copy.

This is the one case in which an inode's backing object changes, and it is safe for the reason the general rule exists: copy-up preserves the source's security descriptor exactly (§4.6.3), so the descriptor cached on that inode remains correct for the copy.

At open, the descriptor-private inode is deliberately given the path inode's number, so before any copy-up fstat and stat agree. After one they do not: the descriptor keeps the number allocated for the pre-copy-up provider, while a fresh resolution of the path allocates a number for the copy. Both name the copy; the numbers disagree for as long as that descriptor lives. §4.8 records it.

4.4.3.4 Lifetime #

A stratafs inode holds a reference on its provider inode for as long as it lives, released on eviction. The dentry additionally holds a full path reference, and the identity map a third, held until unmount.

Releasing the last reference to a stratafs inode modifies nothing: eviction truncates its own empty mapping, clears the inode, drops the provider reference, and frees its private state.

4.5.1 Write Routing

Peios / Advanced Peios / PKM / stratafs / Mutation

An operation that modifies an existing object is performed against exactly one stratum. This section covers which, and when the decision is made.

4.5.1.1 Accepting modification #

A stratum accepts modification of an object it provides when all three of the following hold:

  • the stratum does not carry ro;
  • the provider's mount is not read-only;
  • the provider's inode is not marked immutable.

The predicate is a property of the stratum and the object alone. It takes the superblock, the stratum index and the provider path, and nothing else — no credentials, no security descriptor, no access check.

That restriction is load-bearing. Were the predicate to take the caller's rights into account, a caller refused write access by the provider's descriptor could still provoke a copy-up: the write would fail, but the copy would have been published, and from then on the merged path would resolve to a snapshot the provider's legitimate writer could no longer update. A caller with no write access at all could freeze any file in the mount.

Note the third term is the immutable inode flag specifically, not unwritability in general. A file that is unwritable by its mode bits is routed in place and refused by the underlying filesystem.

4.5.1.2 The rule #

The decision itself is route_existing in stratafs-core, which takes the provider index, whether it accepts modification, the create index and whether the create stratum is present, whether the object is of a copyable type, and whether the stratafs mount itself is read-only. It returns one of three routes.

  1. If the mount is read-only, the route is read-only. This term short-circuits everything else.
  2. If the provider accepts modification, the operation is performed against the provider's object.
  3. Otherwise, if the object is copyable, a create stratum exists, is present, and has strictly higher precedence than the provider, the object is copied up and the operation performed against the copy.
  4. Otherwise the route is read-only and the operation fails with EROFS.

The strict create_index < provider comparison is what stops a modification being placed where something already present would shadow it. Where a high-precedence stratum provides a name it will not accept a write for, there is no lower stratum that can take the write without the result vanishing behind the provider, so EROFS is the honest answer — reported at the moment of the write rather than discovered later.

Where the name is held by no stratum, the operation is a creation and §4.5.3 applies instead.

4.5.1.3 When the decision is made #

Routing happens when a modifying operation is performed, not when a descriptor is opened. Every mutating entry point calls route_existing afresh against the provider it currently holds; nothing caches a route decision, and a descriptor opened before the predicate changed is not revisited.

OperationRoutes
Writing or appendingYes
Truncating, or any other setattrYes
Changing mode, owner, timestamps, or the security descriptorYes
Setting or removing an extended attributeYes
fallocateYes
splice into the fileYes
copy_file_range and remap_file_rangeYes
Establishing a shared writable mappingYes
Reading contents, attributes, or extended attributesNo
Opening, for any accessNo
Taking or releasing a lock, or a leaseNo

The security descriptor and the system access control list are both reached as extended attributes, so both route through the setxattr path like any other attribute; there is no descriptor-specific code in stratafs at all.

An open is not itself a routing trigger. open computes a route, but uses it only to decide the flags of the backing open — a non-in-place route downgrades the provider open to read-only and strips O_TRUNC, deferring rather than deciding. The one case in which an open acts is O_TRUNC on a regular file: that is a modification, so it routes, and copies up or fails with EROFS there and then. The truncation itself is applied afterwards by the VFS through setattr, which routes again and lands on the copy.

Routing at operation time rather than at open is what makes the rule implementable. A filesystem cannot see what an open asked for in access-mask terms — the caller's descriptor is stamped by KACS before the filesystem's own open method runs, and its mask lives in a private blob a filesystem cannot reach. Nor does it need to: the access check has already happened by the time an operation reaches stratafs, so a modifying operation arriving here is one its caller was entitled to perform, and routing it is a decision about strata alone.

4.5.1.4 Shared writable mappings #

Establishing a shared writable mapping routes, even though no bytes have been written, because stores through such a mapping reach the object without any further filesystem operation — establishment is the last point at which routing can occur.

The test is on VM_SHARED together with VM_MAYWRITE, the "could become writable" bit rather than the "is writable" bit. A PROT_READ shared mapping taken from a writable descriptor therefore routes, because it can acquire write access later through mprotect with no filesystem operation in between. A private mapping, and a shared mapping that cannot acquire write access, do not route. Where routing yields read-only, the mapping is refused with EROFS.

4.5.1.5 What a copy-up does to an open descriptor #

A copy-up performed for one descriptor changes which object provides the name. That descriptor refers to the copy from then on: it keeps the inode it was opened against while the inode's backing object becomes the copy (§4.4.3), and its backing file is replaced.

Every other descriptor already open against the original continues to refer to the original, and any fresh resolution of the path yields a new inode standing for the copy. The pre-copy-up file is not closed — it is retained so that locks and leases taken before the copy-up keep working (§4.5.7).

4.5.1.6 Special files #

A FIFO, socket, or device node is opened and written without the filesystem object being modified: what is written passes to a pipe, a socket, or a driver, not to the object's contents. Writing to such an object therefore does not route, and such an object is never copied up — every routing site gates on the object being a regular file, and the copyable flag excludes anything that is not a regular file, directory or symlink.

Copying up a FIFO would sever it: a reader holding the original and a writer that arrived after the copy would hold two unrelated pipes. A device node survives copying only by the accident that the copy names the same device.

Opens, reads and writes are forwarded to the provider whether or not it accepts modification, since the write-mode downgrade and the O_TRUNC refusal both apply to regular files only. Operations that modify the object itself — its mode, its descriptor, its extended attributes — do route, and where the provider does not accept modification they fail with EROFS, because the copy-up branch cannot apply to an object that is never copied up.

4.5.1.7 ioctl #

ioctl on a regular file is refused unconditionally with ENOTTY, and its compat form with ENOIOCTLCMD. Stored-file ioctls can mutate data and would need command-by-command routing, which is not implemented; refusing is the conservative stand-in. ioctl on a non-regular file is forwarded to the provider.

4.5.2 Copy-Up

Peios / Advanced Peios / PKM / stratafs / Mutation

Copy-up replicates an object from its provider stratum into the create stratum, at the same path relative to the mount root, so that a modification can be applied without modifying the provider.

Every step runs inside a KACS copy-up context, which exempts the mechanics from caller authorisation without granting anything. §3.9.7 describes that context in full; §4.6.3 covers the stratafs side of the bargain.

4.5.2.1 Parents #

Where the create stratum does not hold the directories containing the object's path, they are created first, walking the relative path component by component from the create-stratum root downwards. Each is created as an empty directory with the mode of the corresponding merged provider directory, and with that directory's security descriptor, installed by KACS during the create phase rather than inherited.

Contents are not copied. The directories exist to hold the copied object; the entries they hold in lower strata continue to be reached by merging.

Where the create stratum holds one of those components as something other than a directory, the copy-up fails with ENOTDIR and the operation requiring it fails. The blocking entry is not removed or replaced — there is no unlink, rmdir or rename anywhere on the parent-materialisation path.

Materialised parents receive a mode and a descriptor, and nothing else: no extended attributes and no timestamp preservation.

4.5.2.2 What is replicated #

Provider typeResult in the create stratum
Regular fileA regular file with the same contents and mode
Symbolic linkA symbolic link with the same target
DirectoryAn empty directory with the same mode; contents are not copied

A device node, FIFO, or socket is never copied up. Anything that is not a regular file, directory or symlink is refused with EROFS before a copy-up begins.

The security descriptor is carried by KACS rather than replicated as an attribute (§4.6.3). Every other extended attribute is copied, with three exclusions: the canonical descriptor attribute, anything in the system.stratafs. namespace, and the staging marker attribute. Each is copied with XATTR_CREATE, and security.capability goes through a dedicated KACS entry point rather than a raw write.

No attribute is silently discarded. Any per-attribute failure aborts the copy-up, as does a listing that fails or exceeds XATTR_LIST_MAX; in every case the error reported is EIO, whatever the underlying one was.

Modification timestamps are preserved for regular files and for directories, and not for symbolic links, which receive the current time. That is a divergence from the specification's SHOULD and is tracked as a defect. Access and change times are not preserved for any type.

Ownership is not preserved. The staged object is created with the calling task's credentials, and the metadata copy transfers only the mode and the modification time — there is no uid or gid transfer anywhere. The KACS security descriptor, including its owner SID, is preserved, so the descriptor-level owner is the source's; the POSIX owner is the caller's. Since disk quota keys on the POSIX owner, a copy is accounted to the caller who caused it rather than to the owner of the object it was copied from, which is the opposite of what §4.5.8 describes. This is tracked as a defect.

Hard links are not preserved: an object with several links in its stratum is copied up as a single independent object, and the other links continue to refer to the original.

Contents are copied through a 64 KiB buffer, one read and one write per iteration, with the inner write loop retrying short writes. Where the copy-up was provoked by a path rather than a descriptor, the source is reopened for each chunk; the staged file is reopened for each chunk either way.

4.5.2.3 The source must still be the provider #

Before beginning a copy-up on behalf of a descriptor, the object the descriptor refers to is verified still to be the provider of that name, comparing both the path and the provider inode. Where it is not, the copy-up is not performed and the operation fails with ESTALE.

The verification is repeated immediately before publication, under the create directory's lock, and publication itself uses an operation that fails if the target name already exists: linking an anonymous object into place, or a RENAME_NOREPLACE. Both additionally test the target dentry explicitly, and an EEXIST from either is normalised to ESTALE.

That is decisive for the case that matters. A competing copy-up publishes into the same create-stratum directory, where the filesystem's own atomicity applies, so exactly one of two racing copy-ups succeeds and the other fails ESTALE.

It cannot extend further, and nothing tries to. Strata may be on different filesystems, and stratafs can neither lock them together nor inspect them at one instant. A change in some other stratum after the second verification — a direct writer creating the name in a higher-precedence stratum, say — is an ordinary concurrent structural change, visible to the next resolution, and is neither an error nor detected.

This is not a rare case. Every descriptor other than the one that caused a copy-up still refers to the original object, so a second descriptor opened before the copy-up meets this rule the first time it writes. A caller therefore has to be prepared for a write to fail ESTALE on a descriptor that was valid when it was opened and has done nothing wrong. §4.8 records it.

4.5.2.4 Staging and atomicity #

A copy-up is never observable in a partial state. All content, attribute and metadata work happens on an object that is not reachable through the mount, and publication is a single step.

Two arrangements are used, chosen by type:

  • A regular file is staged as an anonymous object on the create stratum's filesystem — a kernel tmpfile — and linked into place when complete. No reader can reach it.
  • A directory or symlink is staged under a name in the create stratum, since neither has an anonymous form, and published by a no-replace rename.

A staged name is .stratafs-stage- followed by the mount cookie and a per-stage identifier, in a 64-byte buffer, with up to eight retries on collision. It is excluded from resolution and from enumeration for the mount that owns it, and removed if the copy-up fails. The exclusion is local: a second stratafs mount sharing the create stratum, and every direct reader of that directory, sees an ordinary entry containing a partial copy.

4.5.2.4.1 Identifying staging entries #

A staged name alone is not proof of ownership, so each staged object also carries a marker in the extended attribute security.peios.stratafs_staging. The marker is a 24-byte little-endian structure: a magic of 0x53544731 — ASCII STG1 — a version, its own size, a per-boot cookie, and the cookie of the mount that created it.

Recovery of an orphan is therefore precise. A staged entry whose marker is valid and whose owning mount is not live is removed; one belonging to a live mount is left alone, and so is one whose marker is missing, short, or carries the wrong magic, version or size. That matters because two mounts may share a create stratum, and an unqualified cleanup would have each new mount destroy the other's copy-up in flight.

The scan runs in batches of 128 names, with resume, and is triggered from five places: mounting, opening a directory, looking up a name beginning with the staging prefix, copying up into a directory, and the emptiness test of §4.5.4 where it finds a directory empty but saw staging entries in it.

The fifth exists because the other four only reach directories somebody visits. An orphan in a directory that is never opened, looked up, or copied into would otherwise never be removed, and the emptiness test — which filters staging entries — would report its parent empty and let an rmdir proceed that then failed at the provider. Recovering on that path costs nothing until someone actually meets the case, and reaches directories that a recursive walk of the create stratum at mount would have to visit every one of to find.

The marker is removed after publication. A failure to remove it is only warned about, leaving the marker on the published copy until a later lookup cleans it.

Where a copy-up fails for any reason, the create stratum is left with no new entry at the target path and the operation fails. Nothing falls back to a weaker replication: an object whose descriptor or extended attributes could not be preserved is never published. A published copy whose descriptor handoff then fails is rolled back.

4.5.2.5 Concurrent modification #

The provider may be modified while it is being copied, by a writer that does not know stratafs exists.

Copy-up reads the provider as any reader would, with no locking of the source and no snapshot, and offers no stronger consistency than an ordinary read of that object. Where the provider is modified during the copy, the copy may contain bytes from more than one state of the source, exactly as a concurrent read of the same object may.

Nothing detects that and restarts — there is no retry loop, no generation check. Equally, nothing blocks waiting for a quiescent source, and nothing fails a copy-up merely because the source is being written.

What is guaranteed is that the published object never appears in a partial state, because staging and publication are properties of the create stratum's filesystem, which stratafs does control.

Once published, the copy is independent of its source. Subsequent modifications to the object it was copied from are not reflected in it, and are not visible through the mount for as long as the copy provides the name.

4.5.3 Creation

Peios / Advanced Peios / PKM / stratafs / Mutation

Creating a name held by no participating stratum places it in the create stratum.

  1. If there is no create stratum, or it is absent, the operation fails with EROFS.
  2. Any directories of the create stratum containing the name's path that do not exist are materialised as §4.5.2 requires for copy-up parents. Where the create stratum holds one of those components as something other than a directory, the operation fails with ENOTDIR; the blocking entry is not removed or replaced.
  3. The name is created there.

All six kinds of creation — regular file, directory, symbolic link, device node, FIFO, socket — go through one helper and one path.

The ENOTDIR case arises because creation routes positionally, into the create stratum's subdirectory at the same path whether or not it exists (§4.3.2). Where the create stratum holds that path as a file, and a higher-precedence stratum provides it as a directory, the merged directory exists and is reachable while its create-stratum counterpart is blocked.

4.5.3.1 The security descriptor #

A created object has no provider to inherit from, so its descriptor is established by the ordinary creation semantics of the create stratum's filesystem — by inheritance from the directory it is created in, exactly as if it had been created there directly. The create is an ordinary vfs_create, vfs_mkdir, vfs_symlink or vfs_mknod against the real create-stratum parent, with the KACS creation decision bound to that same parent.

This is cleanly separated from copy-up inside KACS: the copy-up branch of the inode security initialisation is taken first and short-circuits the inheritance builder entirely. Ordinary creation in a create stratum inherits; copy-up preserves.

Where the creating interface lets the caller supply a descriptor — the native open path does — it is honoured rather than replaced by an inherited one. That is entirely KACS's doing; stratafs has no descriptor parameter and cannot express it. All stratafs contributes is re-anchoring the pending native create request onto the create-stratum parent.

4.5.3.2 Dispositions that delete #

A creating interface may offer a disposition that replaces an existing object rather than opening it. In Peios that is the supersede disposition of the native open path: KACS creates a temporary file through the mount, opens it, and renames it onto the target, at which point stratafs diverts the rename into a dedicated supersede path.

Such a disposition is a removal followed by a creation, and both halves apply. Before anything is created, the removal is validated: the target's provider must accept modification, or the operation fails with EROFS. Before anything is removed, the replacement is checked for reachability: if any stratum strictly above the create stratum, other than the provider being removed, holds the name, the operation fails with EROFS.

Without that guard the disposition could report success while leaving the caller's new object invisible. The removal takes the name out of the stratum that provided it; the creation puts the replacement in the create stratum; and if some stratum between the two also holds the name, it now outranks the replacement.

The creation half is treated as a creation throughout. It is never reclassified as a modification of a newly-surfaced provider and never copies one up: the caller asked for a new object, and its descriptor is derived as above.

Three restrictions are implementation choices rather than consequences of the model. Supersede applies to regular files only, and anything else is refused with EOPNOTSUPP. The source must be in the create stratum, and source and destination must share a parent. And the two halves are not atomic: the lower entry is unlinked first and the staged file renamed into place afterwards, so a failure in between leaves the name having lost its old provider. The caller receives the error; the window itself is recorded only in the audit trail.

4.5.3.3 Deferred deletion #

A request to delete an object when the last descriptor to it is closed applies to the object the descriptor resolved to, not to whatever provides that name at close time. Because removal (§4.5.4) is defined over the current provider of a name, the deferred case has its own path.

When the deletion is attempted, stratafs locates the entry at the path the descriptor was opened against, in the stratum that provided it at that time — the descriptor's private dentry carries both — and resolves the parent in that same stratum. Four conditions end the attempt quietly, reporting success because the deletion is already complete: the parent is gone, the name is gone, the name now identifies a different inode, or the unlink raced.

That stratum must accept modification, or the deletion fails with EROFS. Then the entry is removed, and no other. In particular, where another caller's copy-up has published a new object at that name in a higher stratum, that object is not the one being deleted and is left alone.

Because the attempt has no caller to report to, any failure is audited — and for a deferred deletion, every non-zero result is audited, not only the arrangement errors §4.6.5 covers for ordinary refusals. The object is left in place.

One part of the model is not implemented as specified. The right to delete an entry is checked when the delete-on-close request is armed, against the requesting token, which is correct. But it is checked against the merged parent directory rather than against the directory of the stratum where the entry actually lives, and no check is made at deletion time. The specification requires the check to name that stratum's directory specifically. This is tracked as a defect.

Deferred deletion is restricted to non-directories, and at arm time to regular files on a managed mount.

4.5.3.4 Exclusive creation #

Where creation is requested exclusively, the name must not exist in any participating stratum, and a name provided by a lower stratum causes EEXIST even though the create stratum does not hold it.

Nothing in stratafs implements this, and nothing needs to. The merged lookup instantiates a positive dentry whenever any stratum holds the name, and the VFS refuses O_EXCL on a dentry it did not create. The excl argument stratafs receives is ignored. There is a race backstop in the create path — a positive dentry appearing in the create stratum yields EEXIST — but it sees only that one stratum.

Exclusive creation asks whether the name is free, and through this mount it is not: a caller that created it anyway would find their object shadowing a file they did not know was there, or masking a whole subtree.

4.5.3.5 Non-exclusive creation over a shadowed name #

Where creation is not exclusive and a lower stratum provides the name, the merged dentry is positive, so the VFS never calls the create path at all. The operation is an open, and §4.5.1 routes it.

Where the provider is a regular file that does not accept modification and the create stratum has higher precedence, the result is a copy-up followed by the requested modification, including truncation where O_TRUNC was requested. O_TRUNC is stripped from the backing open on a non-in-place route precisely so that the copy-up source is not destroyed before it is read.

Where the provider is a FIFO, socket, or device node, no copy-up occurs and the open is forwarded to the provider. Such an open succeeds whether or not the provider accepts modification: opening a special file does not modify it.

4.5.3.6 Unnamed files #

A file may be created without a name, for later linking into place. In a merged directory this is supported, and the file is created on the create stratum's filesystem, recorded with the create stratum's index and marked unnamed.

Where the mount has no create stratum, or it is absent, the operation fails with EROFS. Parent materialisation applies as for a named creation: the create stratum's counterpart of the directory the operation named is materialised, and the new file's descriptor is derived by inheritance from it, because that directory is what the descriptor must come from. Linking such a file into the mount is governed by §4.5.6.

4.5.4 Removal

Peios / Advanced Peios / PKM / stratafs / Mutation

stratafs has no whiteouts and cannot record that a name should be absent. Removal can therefore only remove an entry that is actually there, in a stratum it may write to.

4.5.4.1 Unlinking #

To remove a non-directory name from a merged directory: if the provider accepts modification (§4.5.1), the entry is removed from the provider; otherwise the operation fails with EROFS. The parent is resolved in the provider's stratum, and the unlink is performed there.

The provider need not be the create stratum. Any stratum that accepts modification may have an entry removed from it, by the same rule that allows an object it provides to be modified in place.

Where a lower-precedence stratum also holds the name, that entry becomes the provider once the higher one is removed, and the name remains visible, now resolving to the lower stratum's object. That is not an error and is not reported as one: the removal succeeded, and the entry it removed is gone. The dentry is dropped on success, so the next lookup finds the lower entry.

Removing an object that a lower stratum also provides is how a modification is undone. Where a file was copied up in order to be edited, removing it through the mount discards the edit and restores the original, which remained untouched in its own stratum throughout. Callers expecting POSIX removal will find the name still present afterwards; §4.8 records this as an intended divergence.

Refusal because the provider does not accept modification is EROFS, with one exception: where the provider's inode is immutable it is EPERM. The outer inode carries the provider's inode flags, so the VFS refuses the removal before stratafs's own EROFS test is reached — and EPERM is what every filesystem returns for an immutable file, so the distinction is the useful one rather than an accident worth papering over. The ro flag and a read-only provider mount both still produce EROFS.

4.5.4.2 Removing directories #

The same rule applies, with one additional condition: the merged directory must be empty. A directory is empty for this purpose only if no participating stratum holds any entry within it, so a directory that is empty in the provider but not in another participant fails with ENOTEMPTY.

Because determining this reads the contents of every participating stratum, the caller must hold traverse and list rights on each of them. That check completes over all participants before any of them is enumerated, so a refusal yields EACCES without disclosing whether the directory was empty. Without that ordering the emptiness test would be a disclosure channel: a caller who may not enumerate a protected participant could learn whether it contains anything by attempting rmdir and distinguishing ENOTEMPTY from success.

The emptiness scan filters staging entries, as enumeration does, so an in-flight copy-up in the create stratum does not make rmdir fail on a directory that looks empty through the mount.

Because it filters them, a directory holding nothing but orphaned staging entries reports empty — and the removal would then fail at the provider, on entries the caller cannot see and has no way to remove. So a scan that finds the directory empty but saw staging entries runs orphan recovery on the create stratum's copy of it before returning (§4.5.2). Only entries belonging to no live mount are removed; one owned by a live mount survives, and the provider's rmdir then fails ENOTEMPTY, which is the right answer while another mount is copying up there.

Where the directory is removed and lower strata hold the same name as a directory, the name remains visible as a merged directory of the remaining strata — which, by the emptiness condition, is empty.

4.5.4.3 What removal never does #

Removal touches exactly one stratum, the provider's. The other strata are opened read-only for the emptiness scan and nothing else. No entry, marker, or object is created in any stratum to suppress a lower entry; no whiteout machinery exists anywhere in the filesystem.

Success is never reported for a name whose provider entry was not removed. The one place zero is returned without a removal is the deferred path of §4.5.3, where the entry the descriptor named is already gone and the deletion is genuinely complete.

4.5.5 Rename

Peios / Advanced Peios / PKM / stratafs / Mutation

Rename both removes a name and creates one. Because stratafs cannot make a name disappear (§4.5.4), the constraint on the source is the same as for removal, and the constraint on the destination follows from the read-after-write direction of §4.5.1.

For a rename of a source to a destination within one mount, letting P be the source's provider:

  1. P must accept modification. Otherwise EROFS — the source cannot be removed, so the rename would leave it still visible and amount to a copy.
  2. The destination must not be provided by a stratum of higher precedence than P. Otherwise EROFS — the renamed object would be shadowed at its destination and unreadable through the path it was renamed to.
  3. P must hold the directory containing the destination. Otherwise EXDEV. That directory is not created to satisfy the condition, whether or not P is the create stratum: parent materialisation exists to receive a copy-up, and a rename is not one.
  4. Where the source is a directory, the merged directory must contain no entries provided by a stratum other than P. Otherwise EXDEV, since those entries cannot move with it. Determining this reads every participating stratum, so traverse and list rights are required on each, checked before any enumeration begins; where that is refused the rename fails with EACCES without disclosing whether other strata contributed.
  5. Where the destination is provided, its type must match the source's. A non-directory onto a directory provider fails with EISDIR; a directory onto anything else fails with ENOTDIR. Only the provider's type is compared, since a lower stratum's entry of a different type is masked and contributes no inode.
  6. Where the destination's provider is a directory, that merged directory must contain no entries at all — the same merged-emptiness test rmdir uses, with the same access requirement. Otherwise ENOTEMPTY.
  7. The rename is performed within P: both parents are resolved at the provider's index, and a single vfs_rename is issued there.

Where the destination is provided by a stratum of lower precedence than P and both are non-directories, the rename succeeds and the renamed object shadows it. Where the destination is held by P itself, it is replaced, as on any filesystem.

Where the renamed object and the destination are both directories, the result is not a shadowing: by §4.3.2 the renamed directory merges with the lower strata's directories of that name. Condition 6 is what keeps that tolerable — those directories are empty, so the merged result is the renamed directory's own contents.

P need not be the create stratum. A rename is performed in whichever stratum provides the source, provided that stratum accepts modification. A rename whose source and destination are in different mounts fails with EXDEV, refused by the VFS before stratafs is consulted, and stratafs additionally refuses one spanning two mounts inside the provider stratum.

The immutable-provider caveat of §4.5.4 applies to condition 1 as well: an immutable source yields EPERM from the VFS rather than EROFS.

4.5.5.1 Replacing atomically #

A rename onto an existing destination is the operation by which most software replaces a file safely, and it works through a stratafs mount for a destination provided by a lower stratum. That case is permitted by condition 2: the destination is shadowed by the renamed object rather than removed, so no whiteout is required, and the replacement is a single vfs_rename within one directory pair of one stratum — atomic on the filesystem holding P.

Where the source of such a rename is a temporary file the caller created in the same directory, §4.5.3 placed it in the create stratum, which never carries ro. Conditions 1 and 3 are then satisfied.

The contrast with the supersede disposition (§4.5.3) is worth noticing: that spans two strata and is not atomic.

4.5.5.2 Flags #

Conditions 1 and 4 concern whether the source can be moved at all, and apply under every flag. Conditions 2, 3, 5 and 6 concern the destination.

FlagBehaviour
RENAME_NOREPLACEThe destination must not be held by any participating stratum, not merely by P. Conditions 2, 5 and 6 are not evaluated, since each presupposes a destination that exists; where any stratum holds the destination the rename fails with EEXIST.
RENAME_EXCHANGEBoth names must be provided by the same stratum, and that stratum must accept modification; otherwise EROFS. Conditions 1 and 4 apply to both names. Conditions 2, 5 and 6 are not evaluated: an exchange swaps two names that both already exist, so neither type matching nor emptiness is required of either, exactly as on any filesystem. Condition 3 is subsumed. Where either name is a directory, no stratum other than the providing one may hold entries under either name; otherwise EXDEV.
RENAME_WHITEOUTFails with EINVAL, checked before everything else. stratafs has no whiteouts and cannot represent one.

Exempting RENAME_EXCHANGE from conditions 5 and 6 is what leaves the flag usable. Its ordinary purpose is to swap two populated directories atomically, which condition 6 would refuse outright and condition 5 would refuse whenever the two differ in type. The stranded-entries condition is the only destination constraint an exchange genuinely needs, because it is the only one that arises from the names spanning strata rather than from what the names hold.

4.5.5.2.1 The RENAME_NOREPLACE ordering #

A RENAME_NOREPLACE whose destination is held by any participating stratum fails with EEXIST, and that EEXIST takes precedence over conditions 1, 3 and 4. It is not stratafs that decides this. For RENAME_NOREPLACE the VFS looks the destination up with LOOKUP_EXCL and returns EEXIST for a positive dentry before vfs_rename runs at all, and stratafs's merged lookup makes the dentry positive whenever any stratum holds the name. namei.c is unpatched, so there is no point inside ->rename from which the order could be changed.

stratafs nonetheless evaluates conditions 1, 3 and 4 first, and the code says so. That ordering is reachable only as a race backstop — where the destination appeared between the VFS lookup and the rename — so a caller should not expect EROFS, EXDEV or EACCES from a RENAME_NOREPLACE whose destination already existed.

4.5.5.3 Other behaviour #

Unknown flags bits are neither rejected nor masked; they pass through to the VFS. Where the two provider-level names resolve to one inode the rename is a no-op returning success. A dentry whose private state is missing, or whose provider identity no longer matches, yields ESTALE. Because the filesystem sets FS_RENAME_DOES_D_MOVE, stratafs performs the d_move or d_exchange itself, together with swapping the dentries' recorded relative paths.

4.5.6 Links

Peios / Advanced Peios / PKM / stratafs / Mutation

4.5.6.1 Hard links #

To create a hard link from an existing name to a new name within one mount, letting P be the source's provider:

  1. P must accept modification. Otherwise EXDEV.
  2. The destination must not be held by any participating stratum. Otherwise EEXIST.
  3. P must hold the directory containing the destination. Otherwise EXDEV. That directory is not created to satisfy the condition, whether or not P is the create stratum.
  4. The link is created in P.

P need not be the create stratum: a link is made in whichever stratum provides the source, since that is the only stratum in which the two names can share an object. Condition 2 ensures the new link is not shadowed at its destination, since no stratum — above P or below it — holds that name.

Condition 2 is enforced by the VFS rather than by stratafs for a named source: the link path looks the destination up with LOOKUP_EXCL, and stratafs's merged lookup makes the dentry positive whenever any stratum holds the name. stratafs's own test covers only the provider stratum and is a race backstop. For an unnamed source it does check every stratum explicitly.

A link request is never satisfied by copying the source up. The result would be a link to the copy rather than to the object named by the source, so the two names would not share an object — which is the whole of what a hard link is for. Refusing is the only correct answer.

EXDEV is used rather than EROFS because it is the error callers already handle when a link cannot be made between two locations, and because it is accurate: the link would have to span two strata, which through this mount are two filesystems.

Where installing the outer inode fails after the lower link succeeded, the link is rolled back; a failed rollback is audited.

4.5.6.2 Linking an unnamed file #

An unnamed file created under §4.5.3 is linked into the mount by the rules for creation, not by those above: it has no provider, so conditions 1 and 3 have no subject.

The link is created in the create stratum — a source recorded with any other index is refused with EXDEV — and the operation follows §4.5.3: the name must not be held by any participating stratum, parent directories are materialised in the create stratum, and a create stratum that is absent or does not exist fails with EROFS.

Linking an unnamed file into a different mount fails with EXDEV, checked both by superblock and by mount, and by the VFS as well.

4.5.6.3 Symbolic links #

Creating a symbolic link is an ordinary creation and follows §4.5.3: the link is created in the create stratum, with a security descriptor established by inheritance there.

A symbolic link's target is stored and returned verbatim. The target string is passed through untouched on creation, and on read the outer inode's get_link calls the provider inode's own and returns its result unmodified. No rewriting exists anywhere: a target naming a path inside a stratum is not rewritten to name the corresponding path inside the mount, nor the reverse.

Resolution of a symbolic link found in a stratum follows §4.3.1. The raw string goes back to the VFS, which interprets it as any filesystem's target is interpreted, so an absolute target resolves from the process's root and may re-enter this mount, a different stratafs mount, or none. A link created directly in a stratum by that stratum's owner is followed as written; the rule constrains only what stratafs itself does, which is nothing.

4.5.7 Locking

Peios / Advanced Peios / PKM / stratafs / Mutation

Advisory file locks — whole-file and record locks alike — exist so that several writers of one file can coordinate. A stratafs mount is established over directories that have their own writers, so a lock taken through the mount and a lock taken directly on the same object must be the same lock.

4.5.7.1 The rule #

A lock taken through a stratafs mount is held on the provider's object, in the same lock space as a lock taken on that object by any other path. Both the POSIX and the flock paths retarget the request onto the descriptor's provider file, so the lock lands on the provider inode's own lock context.

stratafs maintains no lock space of its own. There is no lock list, no fallback onto the outer inode, and no per-inode lock state. Two callers that lock the same provider object — one through the mount, one through the stratum directly, or two through different stratafs mounts sharing that stratum — contend with each other. Open-file-description locks are re-owned onto the provider file so that their per-description semantics are preserved.

Taking a lock does not modify an object, so it does not route (§4.5.1) and cannot itself cause a copy-up. Locking requires no write access either, so a read-only descriptor can carry an exclusive lock.

4.5.7.2 Locks and a changing provider #

A lock is held on the object a descriptor resolved to. That object may cease to be the provider afterwards — because a higher-precedence stratum gains the name, because the object is removed from its stratum, or because a copy-up produced a new object.

In every such case the lock remains held on the object it was taken on. It is not transferred, and it does not begin to guard the new provider.

Two callers may therefore hold exclusive locks on one merged path without contending, whenever they opened it either side of a change of provider. Neither is wrong about the object it locked; they locked different objects, and each lock is honoured by everything else holding that object. The merged path is what stopped naming one thing. §4.8 records it.

A caller that must be sure it holds a lock on the current provider has to reopen and re-take it, which is the same discipline required of anything that locks a path another process may replace by rename. The exposure here is that a copy-up is a replacement the caller did not perform and cannot see.

4.5.7.3 The retired provider #

Copy-up does not close the file it copied from. The pre-copy-up provider file is moved aside into the descriptor's private state specifically to keep locks and leases taken before the copy-up alive, and the fan-out that follows is invisible to the specification but visible in behaviour:

  • A POSIX or flock unlock is applied to both the retired file and the copy; a non-unlock request goes only to the copy.
  • A lease release is applied to both. A lease acquisition is redirected to the retired file when that file already holds a lease of the same flavour.
  • Querying a lease returns the stronger of the two files' lease types, ordering write above read above none.
  • Closing the descriptor runs the underlying flush and removes POSIX locks on both files, and releasing it breaks leases on both, using the outer file as the owner identity.

All of it is serialised by the descriptor's mutation lock.

4.5.7.4 Leases and mandatory locking #

Leases are established on the provider's object, in that object's own lock space, and every result is the provider's own, returned unchanged. stratafs neither adds to nor removes from whatever semantics the provider's filesystem gives them, and never reports a lock as established where the provider's filesystem refused it.

Mandatory locking has no subject here: the platform does not offer it, and stratafs contains nothing that would obstruct it. The outer inode copies the provider's inode flags wholesale.

4.5.7.5 Internal locks #

The specification names no internal lock and fixes no acquisition order. The implementation's hierarchy, outermost first:

  1. The per-open-file mutation lock, taken by every operation that may copy up.
  2. The per-outer-inode rebind lock, taken inside it whenever an inode's provider is swapped.
  3. The dentry lock or the inode lock, taken inside the rebind lock and never overlapping each other.

The superblock's identity lock and staging lock are each taken alone. copy_file_range and remap_file_range deliberately do not nest the two files' mutation locks: the input file's is taken, a reference grabbed, and released before the output file's is taken.

For mutations the ordering is: the KACS decision, then parent materialisation, then write access on the target mount, then the parent's directory lock through the VFS's create, remove or rename helpers. Refusal auditing takes the dentry lock with an atomic allocation first and falls back to the rebind lock only if that fails, so the two are never nested.

4.5.8 Durability and Accounting

Peios / Advanced Peios / PKM / stratafs / Mutation

stratafs holds no storage, so durability is the providers' and the accounting is theirs too. Two operations nonetheless need a rule, because a merged directory has no single provider to forward to.

4.5.8.1 Synchronising an object #

Synchronising a non-directory is forwarded to the object the descriptor resolved to — the descriptor's own provider file — and reports what that object's filesystem reports. No path re-resolution happens, so it is never forwarded to whichever object currently provides the path. Where the two differ, the data the caller wrote is on the object it opened, and synchronising anything else would report success while leaving that data unsynchronised.

Where a copy-up has occurred through this descriptor, the descriptor's object is the copy, and it is the copy that is synchronised. The retired pre-copy-up file (§4.5.7) is not.

4.5.8.2 Synchronising a merged directory #

Synchronising a merged directory synchronises the corresponding directory in every stratum of the mount that holds it at the time of the call, and fails if any of them fails. The loop continues past a failure, so every stratum is still attempted, and the first error is what is returned.

The set is evaluated when the operation runs, not when the descriptor was opened: the directory is re-resolved across all strata rather than read from the participant set settled at open (§4.3.4). Both halves of that matter:

  • Evaluating at call time catches a directory the create stratum did not hold when the descriptor was opened and does now — which is exactly what happens when a file is created through that descriptor and §4.5.3 materialises its parent.
  • Covering every stratum rather than the provider alone is required by the atomic-replace pattern of §4.5.5, whose rename is performed in the stratum that provided the source, which need not be the stratum providing the merged directory.

Durability is a question about what is on disk now, which is why this is the one place a merged directory is treated as its current set of real directories rather than as the thing a descriptor was opened against.

4.5.8.3 Freezing #

A stratafs mount has no storage to quiesce. Freezing returns EOPNOTSUPP and propagates nothing to any stratum's filesystem; no unfreeze, freeze-super or thaw-super operation is registered at all. Freezing the filesystem a stratum lives on is done through that filesystem, and affects the merged view as it affects any other reader of that stratum.

4.5.8.4 Accounting #

Storage consumed by an object created through the mount, or copied up into the create stratum, is consumed on the create stratum's filesystem and accounted there.

Which principal it is accounted to is not what the specification describes. Disk quota keys on the POSIX owner, and copy-up does not preserve it: the staged object is created with the calling task's credentials, and the metadata copy transfers only the mode and the modification time. A copy is therefore accounted to the caller who caused it, not to the owner of the object it was copied from.

What is preserved is the KACS security descriptor, including its owner SID (§4.6.3), so the descriptor-level owner is the source's. The two notions of owner diverge here, and only the descriptor one behaves as §4.6.3 requires. This is tracked as a defect.

No code alters ownership to redirect accounting; the divergence is one of omission. The audit record of §4.6.5 is where the causing caller is recorded.

4.6.1 Access Check Delegation

Peios / Advanced Peios / PKM / stratafs / Security

stratafs stores no security descriptors. It allocates its outer inodes bare and never runs the inode security initialisation over them, and neither its inode state nor its superblock state has anywhere to put a descriptor. Every object reachable through a mount has its descriptor on its own stratum, and that descriptor is what governs access to it.

4.6.1.1 The rule #

An access check for an operation on an object reachable through a stratafs mount evaluates the security descriptor of the object the operation will be performed against. For an operation on a non-directory, that is the provider's object; for a merged directory, §4.6.2 defines which participants' descriptors apply.

stratafs synthesises no descriptor, supplies none of its own, and applies no mount-level template. It could not: constructing one would require a synthesising mount policy class, and stratafs is pinned to the class that denies where a descriptor is missing (§4.6.4).

Because the descriptor evaluated is the provider's own, a stratafs mount cannot grant access that the provider's stratum would refuse. That property is structural rather than a matter of care in implementation: there is no descriptor for stratafs to get wrong, because it holds none.

The guarantee is one-directional, which is the direction that matters. Where the provider carries no descriptor there is nothing to evaluate and the mount's own policy decides, which is to refuse — so such an object is unreachable through the mount even where its own filesystem's policy would have admitted it.

4.6.1.2 Who performs which check #

For an object with a single provider, the descriptor comes back through ordinary forwarding. KACS reads the canonical descriptor attribute the same way it does for any file, the stacking layer forwards the getxattr down to the provider, and the descriptor that comes back is the provider's. For metadata and extended-attribute operations, stratafs re-targets the pending one-shot KACS decision onto the provider inode, so the check is made against the object the operation will reach.

A merged directory is not that case. It stands for several directories with several descriptors, and forwarding yields only the provider's. Those checks are stratafs's own: it walks every present participating directory and evaluates each one's descriptor, failing on the first refusal.

KACS cooperates by standing down on stratafs inodes entirely. Its inode_permission hook, and its create, mkdir, mknod, symlink, link, unlink, rmdir and rename hooks, all return success immediately for a superblock carrying the stratafs magic. The checks that matter are made by stratafs against the real objects, or by KACS against the real objects once stratafs has resolved them.

4.6.1.3 Timing #

The descriptor evaluated is the provider's at the time the check runs, and the open-time grant is frozen into the file's KACS state — the check-at-open principle applies unchanged, and copy-up transfers that immutable snapshot to the new backing file rather than deciding again.

Two caching seams are worth recording precisely, because the specification requires the value evaluated to be the provider's current descriptor.

For the merged-directory checks stratafs performs itself, it is exact: the provider's attribute is re-read on every call, with no cache.

For file opens it is not. KACS caches the resolved descriptor against the outer stratafs inode, and a cache entry sourced from an attribute read is never revalidated — the only things that replace it are an explicit descriptor set on that inode and a copy-up install. A later open of the same outer inode therefore evaluates the descriptor read at the first open rather than the provider's current one. This is tracked as a defect.

The second seam is narrower. When copy-up rebinds a descriptor's inode to the copy (§4.4.3), nothing invalidates that cached descriptor, so the value retained was literally read from the old provider. No wrong decision follows, because copy-up preserves the descriptor exactly (§4.6.3) and the two are equal — but the invariant is held by that coincidence rather than by the mechanism.

4.6.2 Checks on Merged Directories

Peios / Advanced Peios / PKM / stratafs / Security

A merged directory stands for several real directories, each with its own security descriptor. An operation on one is checked against every participating directory whose contents it depends on, and against the directory it modifies. Where more than one applies, all must succeed: the check walks participants in order and returns on the first refusal, and nothing degrades an operation to the subset of strata the caller may reach.

Two composite rights recur:

  • Search — traverse on every participating directory, since every one is searched to resolve a name. The permission hook maps the kernel's execute intent onto it.
  • Enumerate — Search plus list on every participating directory. The permission hook maps the read intent onto the list right, and directory open demands both together.
OperationChecked against
Resolving a nameSearch
EnumeratingEnumerate, before the listing is captured
Reading an object's attributesSearch, plus whatever the object's own descriptor requires
Reading the origin attribute on a merged directorySearch, plus read-EA on every participant (§4.7)
Creating a nameSearch, plus add-file or add-subdirectory on the create stratum's directory
Copy-upNothing beyond what the operation it serves already required
Removing a nameSearch, plus delete-child on the provider's directory
Removing a directoryEnumerate — emptiness is judged across every participant — plus delete-child on the provider's directory
Rename, sourceSearch, plus delete-child on the source provider's directory
Rename, destinationSearch, plus add-entry on the source provider's directory, which §4.5.5 requires to hold the destination, plus delete-child where that directory already holds the name
Rename of a directoryBoth of the above, plus Enumerate on the object being renamed, and Enumerate on the destination where its provider is a directory
RENAME_EXCHANGESearch on both, plus add-entry and delete-child on the directory of the stratum providing both names
LinkSearch on both, plus add-file on the source provider's directory
Creating or linking an unnamed fileSearch, plus add-file on the create stratum's directory

The mutating rights land on the directory that actually changes, which follows from §4.5: an entry appears in the create stratum's directory and disappears from the provider's, so in each case it is that directory's descriptor that decides. The two coincide whenever the create stratum is also the provider.

One right the table does not name is required anyway: the underlying vfs_link makes KACS demand write-attributes on the source object, which is an object-descriptor right outside the directory scope this section covers. Linking an unnamed file is exempt, since stratafs marks the source for that purpose.

The effective rights on a merged directory are therefore the intersection of the rights on its participants, with mutating rights additionally required on the directory that is actually modified. Requiring the intersection is fail-closed and admits no partial results: the alternative would let the names held by a restrictively protected directory be enumerated by a caller who could not enumerate it directly, because a permissive directory of higher precedence happened to provide the merged path.

One consequence is worth stating plainly. Because creation is governed by the create stratum's directory descriptor, and a created name shadows the same name in every lower stratum, the right to create in the create stratum's directory is the right to determine what every lower stratum's entry of that name resolves to.

4.6.2.1 Where the create stratum's directory does not exist #

A merged directory has a create stratum whether or not that subdirectory exists (§4.3.2), so a check naming "the create stratum's directory" has to be evaluated against a directory that is not there yet.

Every such check is performed before any part of the operation is carried out, and in particular before any directory is materialised: the authorisation call strictly precedes parent materialisation in creation, in tmpfile creation, and in the unnamed-link path. Where the checks fail, nothing has been created, so the create stratum is left exactly as it was.

Where the create stratum does not hold the path, the check falls back to the corresponding provider directory — the merged provider of that same relative path — which is the descriptor the directory will carry once materialised (§4.6.3). It is never skipped, and never substituted with an ancestor's descriptor. Where no provider exists either, the result is EROFS.

Materialising the intervening directories is part of the operation, not a separate one. No per-ancestor authorisation is taken; the mkdirs are exempted by the copy-up context. Checking against the descriptor the directory will have keeps the answer independent of how much of the create stratum happens to have been materialised already: the first caller to write into a deep path and the hundredth face the same check, against the same descriptor.

A create that fails after materialisation for a reason other than a check — EEXIST, ENOSPC — does leave the intervening directories in place.

4.6.2.2 Copy-up carries no separate authority #

A copy-up requires no right beyond those the operation that provoked it already required. In particular it does not require the caller to hold the right to read the provider's object, nor the right to add an entry to the create stratum's directory. Neither copy-up path takes any authorisation at all.

Copy-up is the mechanism by which an authorised modification is realised, not an operation a caller requests. The read of the provider is stratafs's own, and the copy carries the source's descriptor unchanged (§4.6.3), so the caller obtains nothing they did not already have: the same content, under the same descriptor, at the same path.

The alternative cannot be expressed. Routing happens when a modification is performed (§4.5.1), and by then the authority that governed the open is a mask cached on the descriptor, immutable and carrying no token — so there is nothing to evaluate a fresh right against. Checking the acting token instead would break descriptor delegation, since a descriptor passed to another process would stop working there.

What remains is a resource consideration rather than an access one: a caller entitled to write a file in a stratum that will not accept modification can cause an entry to appear in the create stratum without holding rights over that directory. They gain no access by it — though they do gain the space, since §4.5.8 records that the copy is accounted to them rather than to the preserved owner.

That exemption is only enforceable because KACS provides a copy-up context; without it the access-control layer would check the caller against the create stratum's directory at exactly the point where the authority to check no longer exists. §3.9.7 describes the context, its phase binding and its exhaustive list of exempt operations. What matters here is what stratafs must hold up its end of: the context exempts caller authorisation only, so every mutation still goes through the ordinary VFS path under write access on the target mount, a read-only filesystem still refuses, and filesystem errors still propagate. Nothing is performed under a borrowed or elevated identity — every copy-up mutation runs with the calling task's own credentials.

One adjacent path does borrow one. The stale-staging recovery scan opens the create-stratum directory with the mounter's credentials rather than the caller's, and since the KACS token derives from the current credentials, that read is authorised as the mounter. It runs inside a copy-up context in any case, so it changes no decision.

4.6.3 Descriptors on Copy-Up

Peios / Advanced Peios / PKM / stratafs / Security

Copy-up produces a second instance of an existing object. Its security descriptor is the source's — owner, group, discretionary list, system list and integrity label alike — and it is KACS that carries it, not stratafs.

4.6.3.1 How it is carried #

When a copy-up context is created, the provider's complete effective descriptor is resolved and pinned as a byte string on the context. Each create phase copies those bytes into the phase, and the inode security initialisation for the created object installs them verbatim instead of running inheritance. Nothing is reconstructed field by field: it is a copy of the source bytes, so all five components are preserved together.

The canonical descriptor attribute is deliberately excluded from stratafs's own extended-attribute replication, precisely so that the two mechanisms cannot disagree.

The separation inside KACS is clean and explicit. The inode security initialisation takes the copy-up branch first, and taking it short-circuits the inheritance builder entirely. Ordinary creation in a create stratum inherits from its parent (§4.5.3); copy-up preserves. There is no path on which a copied-up object receives an inherited descriptor.

Directories materialised in the create stratum to hold a copied-up object are handled the same way, each carrying the descriptor of the corresponding provider directory — the merged provider of that same relative path, which is what the pre-check of §4.6.2 evaluated against.

4.6.3.2 Failure #

Where the source descriptor cannot be replicated, the copy-up fails and the operation that required it fails. A missing, corrupt, unresolvable, oversized or unsupported descriptor fails the phase before the destination is created, and an absent pinned descriptor at install time fails the create. Nothing publishes a copied-up object carrying any other descriptor: the descriptor is installed at inode creation, before any content, and publication is a link or rename of an object already stamped, so there is no window in which a differently-protected object is reachable.

The failure errno is not the specified one. §4.8's table pairs descriptor failure with EIO alongside extended-attribute failure, and the extended-attribute half does report EIO. The descriptor half is carried by KACS, whose failures surface as EACCES, EOPNOTSUPP, EINVAL, ESTALE or ENOMEM; there is no EIO anywhere on that path. This is tracked as a defect.

4.6.3.3 Why preservation rather than inheritance #

Copy-up is reachable by any caller with the right to modify the source object. That right does not include the right to modify the source's descriptor, which is a separate right.

Were a copied-up object to receive an inherited descriptor, a caller holding only write access to a restrictively protected object could cause a copy of it to exist carrying the create stratum directory's inheritable entries — and that copy, having higher precedence, would become what the merged path resolves to. The object's confinement would have been replaced by the directory's, through an operation the caller was entitled to perform, without their ever holding the right to alter a descriptor.

Preserving the source descriptor closes that: a copy is exactly as reachable as its original, and copy-up changes which stratum holds an object without changing who may reach it.

4.6.3.4 Provenance #

Because the descriptor is preserved, the copy's descriptor-level owner is the owner of the object it was copied from, not the caller who caused the copy. Ownership therefore does not record who created the copy and cannot be relied on to; the audit record of §4.6.5 is where that is available.

Setting the copying caller as the descriptor owner would have preserved provenance, and was rejected: an owner holds implicit rights over an object's descriptor, so making the caller the owner would reintroduce the escalation this section exists to prevent by a slightly longer route.

The POSIX owner is a different matter, and is not preserved — §4.5.8 records what follows.

4.6.3.5 What "the source's descriptor" means #

The descriptor pinned at the start of a copy-up is the provider's effective descriptor rather than its raw stored attribute. On a provider mount whose own policy class synthesises, that would be the synthesised value. In practice that case is unreachable: reaching the object through the stratafs mount at all requires a real descriptor, under stratafs's own deny-missing class (§4.6.4). A provider on an unmanaged mount is refused outright.

4.6.4 Mount Policy

Peios / Advanced Peios / PKM / stratafs / Security

A stratafs mount carries the FACS mount policy class that denies access to an object with no readable security descriptor. The class is derived from the filesystem magic, not from an administrative choice: the policy resolver maps the stratafs magic to the deny-missing class, and falls back to that mapping whenever a cached policy value is not one of the valid ones.

It cannot be set to anything else. The set path rejects a superblock carrying the stratafs magic with EOPNOTSUPP before it validates its arguments or checks privilege, and there is no mount option to choose one — stratafs's parameter table holds exactly one entry, and anything else is refused. Reading a mount's policy is itself privileged, requiring TCB privilege, though for stratafs the answer is fixed.

Two classes are excluded for distinct reasons.

The unmanaged class declares that FACS does not apply to a mount and that the kernel governs it by rules particular to that filesystem. stratafs has no such rules: it delegates every decision to the provider (§4.6.1). An unmanaged stratafs mount would therefore have no access control at all — not delegated control, but none — for every object reachable through it. That is not theoretical: enforcement points consult the mount policy of the superblock an object belongs to before performing their check, and treat an unmanaged mount as requiring none. For a mount established over a directory of executables, that would place every program on the system beyond the execute check.

The synthesising classes would have stratafs supply a descriptor of its own for an object whose provider has none. Either consequence is disqualifying: the mount would grant access to an object that is unreachable through its own stratum, falsifying the guarantee the whole of §4.6 rests on; and the class that persists a synthesised descriptor would write it back onto the provider's object, which stratafs may not do to any stratum and certainly not to one carrying ro.

4.6.4.1 Missing descriptors #

Where a provider object has no descriptor, access through the stratafs mount is denied under the mount's own policy, and the provider filesystem's policy is not consulted. Both paths implement this: stratafs's own merged-directory check turns ENODATA or EOPNOTSUPP straight into EACCES rather than asking the provider's superblock to synthesise, and an object open resolves the missing-descriptor policy against the stratafs superblock, yielding a missing-descriptor cache entry and then EACCES.

A descriptor that is present but cannot be interpreted is a distinct case and is not routed to mount policy. It takes the ordinary corrupt-descriptor outcome: a corrupt cache entry and an emitted event on the open path, and a validation failure on the merged-directory path. The two produce the same errno by different routes, and mount policy is consulted in neither.

4.6.4.2 Consequence #

A stratafs mount is uniform in the sense a mount policy requires: every object reached through it is subject to the same policy, which is the mount's own. What varies between objects is the descriptor evaluated, which is a property of the object rather than of the policy.

Because the policy denies where a descriptor is absent, the divergence from direct access is always in the refusing direction. An object with no descriptor on a stratum whose own filesystem would synthesise one is refused through the stratafs mount while remaining reachable through its stratum path. An object reachable through its stratum path is never made more reachable by being merged. The same holds for a stratum on an unmanaged filesystem, whose objects carry no descriptors at all: merging one is permitted but yields nothing readable, and is not a useful arrangement.

4.6.5 Audit

Peios / Advanced Peios / PKM / stratafs / Security

Two classes of stratafs event carry information that cannot be recovered from the filesystem afterwards, and are recorded when they happen. Both are emitted through KACS's kernel-only emitter, so KMES stamps each with the effective token of the task whose operation caused it.

4.6.5.1 Copy-up #

Every copy-up emits a record, successful or not. The payload is a map of six keys:

Key
pathThe relative path within the mount, /-prefixed
provider_indexThe stratum the object was copied from
provider_stratumThat stratum's path
create_indexThe stratum it was copied into
create_stratumThat stratum's path
result_errnoZero on success, the failure otherwise

The caller's identity is not in this payload. It does not need to be: KMES stamps the effective, true and process token GUIDs onto every event header at ring-write time, and because copy-up runs in the caller's own context those are the caller's. The identity is carried in the envelope, once, for every event — duplicating it into the payload would give a reader a second copy that could disagree with the first.

Recording it matters because §4.6.3 preserves the source's descriptor, so nothing about the resulting object records who caused it to exist. A reader of these records must take the identity from the event header, not look for it among the keys.

The ENOTDIR of parent materialisation is reported through this event with its result_errno rather than through the refusal event below; the required fields are all present, under a different name.

4.6.5.2 Refused mutation #

A mutation refused because of how the mount is arranged emits a record with six keys: the path, the operation name, the provider index, the provider stratum path, the errno, and whether the refusal was deferred. The provider stratum is a string where a provider is known and msgpack nil where none is; see below.

What counts as an arrangement refusal is one explicit list — EROFS, EXDEV, ENOTDIR, EISDIR, ENOTEMPTY, EEXIST, EINVAL. Call sites cover every mutating path: writes, mappings, truncation, fallocate, splice, copy_file_range, remap_file_range, setattr, setxattr and removexattr, creation, tmpfile, unlink and rmdir, link, supersede, and rename.

EACCES is deliberately absent from that list. A refusal produced by an access check is audited by the mechanism that performed it, and stratafs does not duplicate those records.

These refusals report a mismatch between what a caller attempted and how the mount is arranged — software writing where it cannot, or an arrangement that does not admit an operation someone expected. That is diagnostic information about the system's configuration, and it is otherwise visible only as an error returned to a caller that may discard it.

Rollbacks are audited under the same event with the deferred flag set: a create or link whose outer bookkeeping failed and whose lower object could not be removed again, and a failed publication rollback after a copy-up.

A refusal raised before a provider is known — creation, tmpfile, the heads of link and rename — has no stratum to name. It reports a provider index of -1 and a provider_stratum of msgpack nil, so a reader can tell "no provider was involved" from "the provider's path is empty". The two fields agree: an index of -1 always accompanies a nil stratum.

4.6.5.2.1 One exception #

A refused deferred deletion is audited on any non-zero result, not only the arrangement errors, so one refused by an access check does get a stratafs record. That is the right resolution of the two rules, since the requirement to audit a deferred deletion is unconditional — nobody is left to receive the error — but it is a deliberate exception to the EACCES exclusion above.

4.6.5.3 What is not audited #

Resolution, revalidation and enumeration emit no records of their own. They occur on every path operation, they reveal nothing the resulting access check does not, and recording them would produce volume out of all proportion to their significance. There is no audit call anywhere in the lookup path.

Access checks performed against provider objects are audited by KACS, under its own rules.

4.7 The Origin Attribute

Peios / Advanced Peios / PKM / stratafs

A merged path does not reveal which stratum provided it. stratafs exposes that through one synthetic extended attribute, system.stratafs.origin, and deliberately through nothing else — a tool that instead re-implemented §4.3's resolution rules against the strata would be a second implementation of them, and would eventually disagree with the first.

Together with the mount table (§4.2.2), which gives the stratum stack, it is enough to explain any path in a mount without privilege beyond what reading the path itself requires.

4.7.1 The value #

Reading the attribute returns the absolute path of the object that provides it:

  • For a non-directory, the provider's path in its stratum.
  • For a merged directory, the paths of every participating directory in precedence order, separated by newlines.

Each element is the stratum's own path, then a / where the relative part is non-empty and the base does not already end in one, then the relative path. Within a path, a newline or a backslash is escaped by a preceding backslash; those two are the whole escape set, and the / stratafs itself inserts is not escaped. Stratum paths are absolute because the mount parser required them to be (§4.2.2).

There is no trailing newline and no trailing NUL. A null buffer returns the length required; an undersized one returns ERANGE.

The value is synthesised at each read from the current resolution. It is not stored, and it is not the value of any attribute on any stratum. Because non-root dentries are always invalidated (§4.4.2), a read by path always reflects a fresh resolution.

4.7.2 Constraints #

The attribute is not settable. Any set or removal in the reserved namespace fails with EPERM, before the provider is reached at all.

It is not reported by an attribute listing. The listing handler filters reserved names out of both its sizing pass and its copy pass, and the synthetic name is never added to any listing. Hiding it keeps archivers, copy tools and backup software from discovering it, attempting to preserve it, and failing.

The whole system.stratafs. namespace is reserved. No read, write or removal of any name in it is forwarded to the provider: a read of any name other than origin returns ENODATA, and a write or removal returns EPERM. Where a provider object carries a real attribute of one of these names, it is masked — the synthesised value is returned instead, and the provider's attribute is absent from listings through the mount.

Two details of the implementation are worth stating exactly. The namespace test is a fixed-length prefix comparison against system.stratafs. including the trailing dot, so the bare name system.stratafs is not reserved and would be forwarded to the provider. And the reserved set is one name wider than the namespace suggests: the staging marker attribute, security.peios.stratafs_staging, receives the same treatment — EPERM to write, ENODATA to read, hidden from listings, masked from providers — despite lying outside the system.stratafs. namespace.

4.7.3 Access #

Reading the attribute requires the access that reading an extended attribute of the object requires, and is refused where that would be refused. The right to read the object's stat attributes is not sufficient: the request is for the read-EA right, which KACS's getxattr hook demands on the stratafs dentry before stratafs's own handler runs.

For a merged directory the value names every participating directory, so it discloses more than any one of them. Reading it requires that access on every participating directory and is refused where any refuses — the same intersection §4.6.2 applies to enumeration, and for the same reason: a caller who may not know a restricted directory participates must not learn it from this attribute.

Where the attribute is read through a directory descriptor, the participating set is the one settled when that descriptor was opened (§4.3.4), and the value names that set rather than the current one. The access decision was likewise made at open and is recorded on the dentry, so the read consults a stored verdict rather than re-checking. A stratum that has joined since is not disclosed, because the check that would have covered it was never run; a settled participant that has since ceased to hold the directory is still named, for the same reason.

Where it is read by path, the participating set is the current one, resolved afresh, and the read-EA right is required against each of its members at that moment.

4.8 Failure Modes

Peios / Advanced Peios / PKM / stratafs

This section consolidates the conditions under which a stratafs operation fails and the error each produces. The sections above remain authoritative for the conditions themselves.

4.8.1 Mount-time #

ConditionError
Caller lacks the access to resolve a stratum path or read its attributesEACCES
Empty stratum stack, or strata= absentEINVAL
Stack longer than 16 strataEINVAL
More than one stratum carries createEINVAL
A stratum carries both create and roEINVAL
The same directory appears twice in the stackEINVAL
Any malformed strata= value (§4.2.2)EINVAL
strata= supplied on a remountEINVAL
An allocation failure while parsingENOMEM
A create-bearing stack established outside the initial user namespace, or without CAP_SYS_ADMIN thereEPERM
A stratum path names a non-directoryENOTDIR
A stratum is absent without amENOENT
A stratum lies within the mount point, or within another stratafs mount whose strata include this mount pointELOOP
The composed stack reaches the kernel's maximum stacking depthELOOP
Sixteen consecutive collisions allocating a mount cookieEAGAIN

The option-only conditions are decided before any path is touched, and the EPERM admission test before any path is resolved. The per-path conditions follow. §4.2.3 records the one place the specified ordering is not achieved.

4.8.2 Resolution #

ConditionError
No participating stratum holds the nameENOENT
An ancestor of the path is masked by a non-directory providerENOTDIR
Operation requires a non-directory; provider is a directoryEISDIR
Traverse or list refused on any participating directoryEACCES
A stratum walk fails for a reason other than absencethat error
A joined stratum path or child relative path exceeds PATH_MAXENAMETOOLONG
A task re-enters the same superblock while resolvingELOOP

4.8.3 Mutation #

ConditionError
Provider will not accept modification, and no create stratum has higher precedenceEROFS
Any mutation on a read-only-mounted stratafsEROFS
Creation, unnamed-file creation, or unnamed-file link with no create stratum, or it absentEROFS
Create stratum holds a path component as a non-directoryENOTDIR
Exclusive creation where any stratum holds the nameEEXIST
Copy-up cannot preserve an extended attributeEIO
Copy-up cannot preserve the descriptorEACCES, EOPNOTSUPP, EINVAL, ESTALE or ENOMEM, as KACS reported it
Copy-up for a descriptor whose object is no longer the provider, or whose target name is taken at publicationESTALE
Copy-up of a device node, FIFO or socketEROFS
Unlink where the provider will not accept modificationEROFS, or EPERM where the provider inode is immutable
Rmdir where another stratum holds entries withinENOTEMPTY
Rmdir or directory rename where list is refused on a participantEACCES
Modifying a special file's mode, descriptor or extended attributes where its provider will not accept modificationEROFS
Establishing a shared write-capable mapping where routing yields read-onlyEROFS
Replacing disposition where §4.5.4 would refuse the removal, or where a stratum above the create stratum would still hold the nameEROFS
Replacing disposition on anything but a regular fileEOPNOTSUPP
Rename where the source's provider will not accept modificationEROFS
Rename where a stratum above the source's provider provides the destinationEROFS
Rename where the source's provider does not hold the destination's directoryEXDEV
Rename of a directory containing entries from other strataEXDEV
Rename of a non-directory onto a directory providerEISDIR
Rename of a directory onto a non-directory providerENOTDIR
Rename onto a destination directory not empty across every stratumENOTEMPTY
RENAME_NOREPLACE where any stratum holds the destinationEEXIST
RENAME_EXCHANGE where the two names are not provided by one stratum that accepts modificationEROFS
RENAME_EXCHANGE where another stratum holds entries under either directory nameEXDEV
RENAME_WHITEOUTEINVAL
Hard link whose source's provider will not accept modification, or does not hold the destination's directoryEXDEV
Hard link where any stratum holds the destinationEEXIST
Linking an unnamed file into a different mountEXDEV
A dentry whose private state is missing, or whose provider identity no longer matchesESTALE
ioctl on a regular fileENOTTY, or ENOIOCTLCMD for the compat form
remap_file_range with flags outside dedupe and advisoryEINVAL

4.8.4 Interface #

ConditionError
Set or remove of any reserved attributeEPERM
Read of a reserved attribute other than originENODATA
Origin read where read-EA is refused on any participantEACCES
Origin read into an undersized bufferERANGE
Freezing the filesystemEOPNOTSUPP
Reading a mount's policy without TCB privilegerefused by KACS

4.8.5 Intended divergences #

None of the following is a defect. Each is described above, and each follows from stratafs having no way to record that a name should be absent, or from a merged path standing for objects in more than one stratum.

  • Removing a name may not remove it from the view. Where a lower stratum also holds the name, it becomes the provider and the name remains, now resolving to different content (§4.5.4).
  • A name may be unremovable. Where the provider will not accept modification, removal fails however the caller is privileged (§4.5.4).
  • Rename may be refused for an unmodified file. Where the source's provider will not accept modification, rename fails rather than silently copying (§4.5.5).
  • Hard links may be refused within one directory. Where the source's provider will not accept modification, linking fails with EXDEV (§4.5.6).
  • An object's inode number may change. Where the provider for a path changes, a new inode is presented (§4.4.3).
  • Two callers may hold non-contending locks on one path. Locks are held on the object a descriptor resolved to, so callers who opened either side of a change of provider — including one caused by a copy-up neither of them performed — have locked different objects (§4.5.7).
  • Copy-up severs hard links. Two names that shared an object in a lower stratum, and compared equal by inode number, refer to different objects once one of them is written through the mount; only the written one is copied up (§4.5.2).
  • An open may succeed where the first write then fails. Routing happens when a modifying operation is performed, not at open, so an open for writing against a provider that will not accept modification succeeds and the EROFS arrives at the write, the truncate, or the attempt to establish a shared writable mapping (§4.5.1).
  • A copy-up moves an object out from under descriptors already open on it. Only the descriptor whose operation caused the copy-up refers to the copy; others continue to refer to the original, and locks held on it stay there (§4.5.1, §4.5.7).
  • A write may fail ESTALE on a descriptor that was valid when opened. Where another descriptor, another mount, or a direct writer has since caused that name to be provided by a different object, a copy-up cannot proceed and the caller must reopen (§4.5.2).
  • fstat and stat may report different inode numbers for one file. After a copy-up, the descriptor that caused it keeps the inode it was opened against while a fresh resolution of the path yields a new one, so the two disagree for as long as that descriptor lives — though both name the copy (§4.4.3).
  • A directory's link count is always 1. The true count of subdirectories spans strata and cannot be maintained (§4.4.3).
  • Directory positions do not survive a reopen. A readdir offset is an ordinal into a per-descriptor capture, carrying no provider cookie (§4.3.4).

Every one of the first four is the result of refusing to invent a hidden record — a whiteout — that would make an entry in someone else's directory unreachable without their knowledge. The alternative buys POSIX fidelity at the cost of a stratum no longer meaning what its owner wrote in it, which is the property §4.2.1 exists to protect.

Appendix 4.A Constants

Peios / Advanced Peios / PKM / stratafs

Every value below is generated from the source by pkm/tools/gen-stratafs-constants.py. Nothing here is transcribed by hand, and the generator's --check mode fails if the two drift apart.

4.A.1 Filesystem identity #

ConstantValueMeaning
STRATAFS_MAGIC0x53545241Superblock magic, reported by statfs (§4.2.2)
STRATAFS_NAME"stratafs"The name the filesystem registers under
STRATAFS_MAX_STRATA16Longest stratum stack accepted (§4.2.1)

STRATAFS_MAGIC is an alias for STRATAFS_SUPER_MAGIC, which is declared in the header stratafs shares with KACS so that the mount policy class keyed on it cannot drift (§4.6.4).

4.A.2 Stratum flags #

ConstantValueMeaning
STRATAFS_F_CREATE0x1The create flag (§4.2.1)
STRATAFS_F_RO0x2The ro flag
STRATAFS_F_AM0x4The am flag

4.A.3 Extended attributes #

ConstantValueMeaning
STRATAFS_XATTR_PREFIX"system.stratafs."Reserved namespace; never forwarded to a provider (§4.7)
STRATAFS_XATTR_ORIGIN"system.stratafs.origin"Synthetic, read-only, hidden from listings (§4.7)
STRATAFS_XATTR_STAGING"security.peios.stratafs_staging"Copy-up staging marker; also reserved (§4.5.2)

STRATAFS_XATTR_STAGING is an alias for STRATAFS_STAGING_XATTR, declared in the shared header. Note the name it resolves to lies outside the reserved system.stratafs. namespace, yet receives the same treatment (§4.7).

The canonical security-descriptor attribute is KACS's, not stratafs's; stratafs only detects it in order to exclude it from copy-up replication (§4.6.3).

4.A.4 Copy-up and staging #

ConstantValueMeaning
STRATAFS_STAGE_MARKER_MAGIC0x53544731Marker magic
STRATAFS_STAGE_MARKER_VERSION1Marker version
STRATAFS_COPY_BUFFER_SIZE65536Copy-up read/write chunk, in bytes (§4.5.2)
STRATAFS_STAGE_PREFIX".stratafs-stage-"Staged-name prefix
STRATAFS_RECOVERY_BATCH128Names scanned per staging-recovery pass

4.A.4.1 The staging marker #

struct stratafs_stage_marker is packed and 24 bytes, all fields little-endian. It is the value of the staging attribute above.

OffsetSizeFieldType
04magic__le32
42version__le16
62size__le16
88boot_cookie__le64
168mount_cookie__le64

4.A.5 Routing #

The value route_existing returns (§4.5.1), as the Rust decision core names it and as the C glue mirrors it. The discriminants match.

C enumeratorValueRust
STRATAFS_ROUTE_IN_PLACE0InPlace
STRATAFS_ROUTE_COPY_UP1CopyUp
STRATAFS_ROUTE_READ_ONLY2ReadOnly

4.A.6 The decision core #

stratafs-core holds the stack-wide flag rules, provider selection, and routing. Its flag bits match the C ones above exactly.

ConstantValue
MAX_STRATA16
FLAG_CREATE0x1
FLAG_READ_ONLY0x2
FLAG_ABSENT_MAY0x4
FLAG_MASK0x7

The crate distinguishes these configuration errors. The C boundary collapses all of them to EINVAL, so the distinction is not observable to a caller (§4.2.1).

ErrorDiscriminant
Empty1
TooMany2
UnknownFlag3
RepeatedCreate4
CreateReadOnly5

4.A.7 Build configuration #

stratafs is built by CONFIG_STRATAFS_FS, a boolean option, so what it builds is linked into vmlinux rather than loaded. It depends on CONFIG_SECURITY_PKM and selects FS_STACK. Its sources are staged into the kernel tree as fs/stratafs, separate from PKM's own security/pkm. CONFIG_STRATAFS_KUNIT_TEST builds the in-kernel unit tests.

The translation units are:

  • super.o
  • lookup.o
  • inode.o
  • file.o
  • dir.o
  • xattr.o
  • copy_up.o

5.1 Overview

Peios / Advanced Peios / PKM / LCS

LCS — the Layered Configuration Subsystem — is the kernel half of the Peios registry: a hierarchical, access-controlled configuration store modelled on the Windows registry. It owns the namespace, the security model, change observation, transactions, and the layer system that gives the registry its name. It owns no storage at all.

Storage belongs to sources: userspace processes that hold registry data and answer questions about it over the Registry Source Interface. A source stores what it is told and returns everything it holds; it never resolves layers, never filters by visibility, never sees the identity of a caller, and never interprets a path beyond the parent and child names it is given. Every decision about what a caller may see or do is made in the kernel. loregd is the first source, and the one that provides Machine\ and Users\ at boot, but nothing in LCS knows that: hive routing is built entirely from what registers.

Userspace never talks to a source. Processes reach the registry through three syscalls and eighteen ioctls, and the fd those syscalls return is a capability — an open key carries the access mask it was granted, and carries it wherever the fd goes.

5.1.1 Where it sits #

LCS is a subsystem of PKM, peer to KACS and KMES, staged into the kernel tree as security/pkm/lcs and built by CONFIG_SECURITY_PKM — a boolean option, so it is linked into vmlinux rather than loaded. Its three syscalls occupy 1100–1102 in the PKM range, added to the syscall table by a patch against arch/x86/entry/syscalls/syscall_64.tbl.

It depends on KACS and KACS does not depend on it. Every access decision LCS makes is a call into the KACS AccessCheck function against a Security Descriptor the source returned; LCS defines no access control mechanism of its own. Security Descriptor inheritance at key creation is likewise KACS's computation, not LCS's. Audit events go to KMES.

A substantial part of LCS is Rust. The crate lcs-core is staged alongside PKM's other cores as security/pkm/lcs/lcs_core and holds the parts where correctness is a matter of pure decision rather than kernel plumbing: layer resolution, the RSI wire codec, the backup stream serialiser, the transaction mutation log, watch dispatch, case folding, and configuration validation. The C half owns fds, the char device, memory, locking, and the syscall boundary.

5.1.2 The semantic core #

Six rules generate the rest of the model. Every behaviour described in this chapter is a consequence of one of them.

  1. Names are layered. A key's presence at a path is per-layer. Different layers can name different keys at the same path, and removing a layer removes its names.
  2. Values are layered. Every write is tagged with a layer; the effective value is the highest-precedence entry. Tombstones can actively mask lower layers.
  3. Key identity is not layered. A key's GUID, Security Descriptor, volatile flag, symlink flag and last write time belong to the key object, not to any layer, and are never automatically reverted.
  4. Security is key-bound, not layer-bound. Modifying a Security Descriptor is a permanent change to the key. Removing a layer does not revert it. Security policy is operational state, not configuration overlay.
  5. Handles are capabilities granted at open. An open key fd carries an access mask computed once, by AccessCheck, at open time. Later operations test the mask, not the descriptor. Changing a descriptor does not affect an fd that already exists.
  6. Sources persist, the kernel decides meaning. A source stores path entries, key records and value entries and returns all of them. Everything else is the kernel's.

The consequence that surprises people most often is the fourth. A layer is a configuration overlay and reverts cleanly; an access control change is not configuration and does not.

5.1.3 What is layered and what is not #

PropertyLayeredResolved bySurvives layer deletion
Path existenceYesHighest precedence, then highest sequenceNo
ValuesYesHighest precedence, then highest sequenceNo
Value tombstonesYesMasking lower precedenceNo
Blanket tombstonesYesMasking all lower-precedence values on the keyNo
Key hidingYesMasking lower precedenceNo
Key GUIDNoDirect on the key objectYes
Security DescriptorNoDirect on the key objectYes
Volatile flagNoDirect on the key objectYes
Symlink flagNoDirect on the key objectYes
Last write timeNoDirect on the key objectYes

A watch is bound to the key object rather than to either, and stays on that object whatever happens to the name (§5.6.3).

5.1.4 registry.pol #

One external format constrains the design. registry.pol is the binary format Active Directory Group Policy uses to deliver configuration to domain-joined machines, and the registry exists so that Peios can consume it without loss. Everything registry.pol can express, the registry can represent.

Three consequences run all the way through:

  • The full Windows value type set is supported, including the three hardware-resource types that carry no Peios semantics at all. They behave exactly as REG_BINARY; they exist so a value copied from a Windows hive round-trips with its type tag intact (§5.2.6).
  • Paths are backslash-separated, case-preserving and case-insensitive. This is not negotiable and it is the reason case folding appears in the kernel at all (§5.2.8).
  • Registry access rights occupy the Windows bit positions, so a Security Descriptor containing registry ACEs is binary-compatible (§5.4.2).

Tombstones and blanket tombstones exist for the same reason: the **Del.ValueName and **DelVals directives express absence, which a purely additive overlay cannot (§5.2.7).

LCS does not parse registry.pol. Parsing is a userspace concern; LCS provides the model that makes a faithful translation possible.

No other parity with Windows is claimed. The binary compatibility of a Security Descriptor is a KACS guarantee, not an LCS one.

5.1.5 Where the model diverges from Windows #

LCS is modelled on the Windows Configuration Manager, and departs from it in seven places. All seven are decisions rather than gaps.

WindowsLCS
Backing storeKernel-internal hive filesUserspace sources over the RSI, so a storage backend is not a kernel change
Hive routingA fixed set of predefined hivesAny source may register any name at runtime
LayersNone; registry.pol is applied by flattening valuesPrecedence-ordered layers with tombstones, resolved at query time, so removal reverts rather than tattoos
Change observationRegNotifyChangeKeyValue, single-shotPersistent watches, closing the re-registration race
Key identityHive cell offsetsGUIDs, stable across storage reorganisation
Forward slashNot acceptedAccepted on input, normalised to backslash
Case comparisonRtlCompareUnicodeStringUnicode Simple Case Folding, pinned to a version

5.1.6 Windows features that are absent #

Four have been evaluated and deliberately excluded.

Key classes. The class parameter of RegCreateKeyEx is documented by Microsoft as reserved, and no consumer of it is known.

RegOverridePredefKey. Per-process key redirection, and specific to COM. It would require unbounded per-process state in the kernel, and private hives and private layers cover the uses that are legitimate.

WoW64 redirection. Splitting keys by pointer width. Peios has no 32-bit compatibility concern to split for.

The HKEY_CLASSES_ROOT merged overlay. A merge of HKLM and HKCU Software\Classes, again COM-specific, with no Peios equivalent.

5.1.7 This chapter #

§5.2 covers the data model — hives, keys, path entries, values, tombstones, names, and what happens to a key that loses its last name. §5.3 covers layers: the model, the base layer, where layer metadata lives and the circularity that implies, who may write into a layer, and the resolution algorithm and the sequence counter that drives it. §5.4 covers security: the access flow, the rights, inheritance, and the audit events LCS emits. §5.5 covers the syscall and ioctl interface and the error model. §5.6 covers watches, §5.7 transactions, and §5.8 the source model — registration, dispatch, validation, and the intricate business of what a late response means. §5.9 covers backup and restore, §5.10 how LCS configures itself out of the registry it is serving, and §5.A the ABI.

Two contracts extracted from LCS are specified rather than described, because a third party implements the other side of each: the Registry Source Interface and the registry backup format. Both are chapters of PSPK.

5.2.1 Hives and Routing

Peios / Advanced Peios / PKM / LCS / The Data Model

A hive is a top-level namespace — the first component of every registry path. LCS keeps a routing table mapping hive names to registered sources, and that table is built entirely from what registers. There is no static configuration.

FieldDescription
NameThe hive name, e.g. Machine, Users. Case-preserving, compared case-insensitively.
Root GUIDThe GUID of the hive's root key, supplied by the source at registration.
SourceThe source slot backing this hive.
StatusActive or Unavailable, tracking source connectivity.

Status is a property of the source slot rather than of the individual hive, which amounts to the same thing: a slot's hives are exactly one source's, and they go Down together.

5.2.1.1 Route identity #

A hive route is identified by the pair (case-folded name, scope), where the scope is either the global namespace or one private scope GUID. That pair must be unique across every registered source; a source claiming one another source already holds is rejected.

The same name may appear in different scopes, which is precisely how a private hive shadows a global one without colliding with it.

A hive is backed by exactly one source. A source may back many.

5.2.1.2 Routing a path #

LCS takes the first component of a path — everything before the first separator — and looks it up. Registered and active routes to the backing source. Not registered is ENOENT. Registered but Down is EIO.

Before any source registers, the table is empty and every path yields ENOENT (§5.10.1).

5.2.1.3 CurrentUser is not a hive #

CurrentUser\ is a kernel-level alias. When a caller-supplied absolute path starts with it, LCS reads the user SID from the calling thread's effective token and rewrites the path to Users\<SID>\... before routing. The rewritten path is re-checked against the total-length limit, since a textual SID is longer than the alias it replaced.

Three constraints keep this safe.

It applies only to the first component of a path, and only to a caller-supplied absolute one. It does not apply to symlink targets: a target beginning with CurrentUser\ is followed literally, routes as a hive of that name, and finds nothing. Without that rule, a symlink containing CurrentUser\ would redirect a privileged service into the service's own user hive — a confused deputy.

And CurrentUser cannot be registered as a hive name. LCS rejects the registration with EINVAL, so the alias can never collide with a real hive.

Symlink targets are subject to ordinary hive routing, private hives included. A sandboxed process resolving a symlink sees the same registry through it that it sees directly, which is the point of sandboxing it.

5.2.1.4 Two names the kernel knows #

LCS is source-agnostic about routing but not entirely ignorant of names. Users is compiled in as the target of CurrentUser\ rewriting. Machine is matched, case-insensitively and only for a global hive, to decide when to run the bootstrap refresh that reads LCS's own configuration (§5.10.2).

Neither is a routing decision — an unregistered Machine routes nowhere like any other name — but the two names do exist in the kernel.

In practice loregd registers Machine and Users at boot. Other sources may register other hives at any time.

5.2.1.5 Names #

Hive names follow the same rules as key name components: no backslash, no forward slash, no null byte, valid UTF-8, non-empty, and no longer than MaxPathComponentLength. They are case-preserving and compared case-insensitively (§5.2.8).

5.2.2 Private Hives

Peios / Advanced Peios / PKM / LCS / The Data Model

A private hive is registered with the RSI_HIVE_PRIVATE flag and a scope GUID. It is invisible in the global namespace and reachable only by a thread whose token carries that scope GUID.

5.2.2.1 Routing #

Private hives are checked before global ones. For each scope GUID on the calling thread's token, in the order the token carries them, LCS looks for a private hive with the path's name and that scope. The first match wins. Only if none matches is the global table consulted.

A private hive can therefore shadow a global one, and no name is exempt — a thread with a private Machine\ sees the private one. That is what makes complete registry isolation possible for a container or a sandbox without giving it a differently-named hive to notice.

MaxScopeGUIDsPerToken, default 8, bounds the per-syscall iteration cost. Duplicate scope GUIDs on a token are rejected.

5.2.2.2 Scope GUIDs on a token #

Scope GUIDs reach a thread through the KACS token's LCS credential extension: a versioned block in the token specification carrying the thread's scope GUIDs and its private layer names (§5.3.5). It parses at a fixed offset, rejects nil and duplicate GUIDs, and caps the count at 256 — a hard KACS limit, above the configurable LCS one. The credentials propagate across fork, duplication and impersonation like the rest of the token.

Attaching them is gated by SeCreateTokenPrivilege, because scope GUIDs can only enter a token when the token is created. That is a blanket privilege rather than a per-scope authorisation: a caller that can create tokens at all can create one claiming any scope. Isolation between private hives therefore rests on who may create tokens, not on who owns a scope.

5.2.2.3 Scope lifecycle #

A scope GUID is an opaque 128-bit value with no lifecycle. LCS neither creates nor tracks one; it only compares. A scope exists as long as some private hive or some token references it, and when the last reference goes it is simply a number nobody uses.

Nothing in the kernel generates scope GUIDs. Key GUIDs and token GUIDs come from the kernel's UUIDv4 generator; scope GUIDs are supplied by userspace, and choosing them unpredictably is userspace's responsibility.

5.2.2.4 Registration rules #

Registration enforces more than the route-identity uniqueness of §5.2.1:

  • A hive's root GUID may not be nil, and the root GUIDs within one registration request must be distinct.
  • A hive without the RSI_HIVE_PRIVATE flag may not carry a non-nil scope GUID, and a hive with it may not carry a nil one. No token can carry a nil scope GUID, so such a hive would be registrable and then permanently unroutable — holding its name against other sources in that scope while every lookup returned ENOENT.
  • Unknown flag bits are rejected.
  • Opening /dev/pkm_registry at all requires an enabled SeTcbPrivilege, checked in the device's open() handler (§5.8.2).

5.2.3 Keys

Peios / Advanced Peios / PKM / LCS / The Data Model

A key is a node in the hierarchy: a container holding subkeys and values. The filesystem analogy is an inode. A key carries identity and properties; naming lives in path entries (§5.2.5).

FieldMutableLayeredDescription
GUIDNoNoIdentity, assigned by LCS at creation, persisted by the source.
NameNoNoThe key's own name component. Informational; the authoritative name is in the path entry.
Parent GUIDNoNoThe parent key. Nil for a hive root.
Security DescriptorYesNoComputed from the parent at creation; changed at runtime with WRITE_DAC / WRITE_OWNER.
Last write timeYesNoUpdated when a value is written or deleted or the descriptor changes.
VolatileNoNoThe source stores this key in non-persistent storage only.
SymlinkNoNoThis key is a symbolic link (§5.2.4).

No key property is layer-qualified. Properties belong to the object. Changing a key's structural type through a layer is not a matter of setting a flag; the layer hides the original key and creates a new one at the same path, which is the ordinary overlay pattern.

5.2.3.1 Identity #

A key's identity is its GUID, not its path. Two keys occupying the same path at different times are different objects with different GUIDs. A path is a name that maps to an identity; the identity outlives the name.

GUIDs are assigned by LCS — never by a source — and pushed to the source at creation, where they become the primary key of its storage. Once a path has been resolved to a GUID at open time, every subsequent RSI operation uses the GUID directly.

The generator is the kernel's UUIDv4: random bytes from the kernel CSPRNG with the RFC 4122 version and variant bits set. Freshness is the collision resistance of UUIDv4 plus a check against the keys LCS currently tracks, with a bounded retry. There is no persistent retired-GUID catalogue, and no code anywhere that would maintain one. A GUID dropped by orphan cleanup is not remembered.

If a source answers RSI_CREATE_KEY for a freshly generated GUID with RSI_ALREADY_EXISTS, that is not a race to retry — the kernel believes the GUID is unused and the source disagrees. LCS treats it as source inconsistency and fails closed with EIO. It is never retried as an open and EEXIST never reaches userspace from reg_create_key.

A GUID appears at exactly one canonical location. LCS validates that and rejects a source that reports otherwise.

5.2.3.2 Volatile keys #

Volatile is a flag LCS carries and forwards. Storing a volatile key in non-persistent storage is the source's obligation; nothing in the kernel enforces it.

What the kernel does enforce is the containment rule: a non-volatile key may not be created under a volatile parent (EINVAL). The converse is allowed — a volatile key under a persistent parent is ordinary.

5.2.3.3 Naming #

Three bytes are forbidden in a key name component: backslash and forward slash, both of which are separators, and the null byte. Every other valid UTF-8 sequence is permitted, spaces and arbitrary Unicode included.

Empty components are forbidden, which rules out a leading separator and consecutive separators (Machine\\System). A trailing separator (Machine\System\) is likewise invalid.

Length limits are byte counts: MaxPathComponentLength per component, MaxTotalPathLength for the whole path, MaxKeyDepth for nesting.

Forward slash normalisation is achieved by treating / as a separator wherever a separator is recognised, rather than by rewriting the string — so a materialised component never contains either separator, and the canonical form is per-component rather than a canonicalised string.

5.2.4 Symlinks

Peios / Advanced Peios / PKM / LCS / The Data Model

A symlink key uses two mechanisms, and both are load-bearing.

The symlink flag on the key record marks the key's structural type. It is set at creation by REG_OPTION_CREATE_LINK and is immutable afterwards — RSI_WRITE_KEY can update only the descriptor and the last write time, so there is no operation that could change it.

The default value, of type REG_LINK, supplies the target path. It is an ordinary layered value, which means a higher-precedence layer can redirect a symlink by writing a different REG_LINK default value, and removing that layer restores the original target.

The flag marks identity; the value provides the target.

5.2.4.1 Resolution #

LCS follows symlinks during path resolution and the fd it returns refers to the resolved target — its GUID, its position in the tree, its ancestor chain — not to the link.

The target is resolved by issuing a separate RSI_QUERY_VALUES for the key's default value (the empty name) and applying ordinary layer resolution to the result, so the target participates in the layer system exactly as any other value does.

If the effective default value is missing, or is not of type REG_LINK, resolution fails with EINVAL. LCS does not validate the type at write time: a layer that writes a REG_SZ default value over a symlink's target breaks resolution at the next open, and removing that layer fixes it. The offending value stays in the registry; a failed resolution writes nothing.

5.2.4.2 The target path #

The REG_LINK payload is a length-delimited UTF-8 registry path. No trailing null is required or permitted — the length delimits it, and a null byte inside that length is rejected like any other. Forward slashes are handled as separators as everywhere else.

The target is validated with exactly the same rules as a syscall path: UTF-8, no null bytes, per-component and total length, no empty components, no trailing separator, maximum depth. The only difference is that a syscall path arrives null-terminated and has its terminator stripped first.

A target is always interpreted as absolute — its first component is routed as a hive name. There is no check that rejects a relative-looking target as malformed. A target of Sub\Key is not an error; it is a request for a hive named Sub, and it yields ENOENT unless such a hive happens to be registered, in which case it resolves there.

CurrentUser\ rewriting is not applied (§5.2.1), so a target beginning with CurrentUser\ routes as a hive of that name and cannot be registered, and therefore always fails. Ordinary hive routing does apply, private hives included, for the resolving thread.

5.2.4.3 Depth #

Symlink resolution is bounded by SymlinkDepthLimit, default 16, configurable from 1 to 64. Exceeding it is ELOOP.

Two paths in the walk use the compiled-in default rather than the configured value, so a SymlinkDepthLimit other than 16 is not honoured everywhere.

5.2.4.4 Opening the link itself #

REG_OPEN_LINK on reg_open_key opens the symlink key rather than following it, which is how a symlink is managed at all — deleted, retargeted, inspected.

It applies to the final path component only. A symlink encountered part-way along a path is followed whether or not the flag is set. The access check follows the same rule: with REG_OPEN_LINK the check is against the link, otherwise against the target.

5.2.4.5 Creation #

Creating a symlink needs all of:

  • KEY_CREATE_SUB_KEY on the parent, as for any key;
  • KEY_CREATE_LINK on the parent;
  • either an enabled SeTcbPrivilege or membership of Administrators.

The last is a genuine disjunction — either satisfies it — and the privilege branch marks the privilege used. Failing it is EPERM.

5.2.5 Path Entries

Peios / Advanced Peios / PKM / LCS / The Data Model

Key existence and naming are layer-qualified; key identity is not. The source stores a path entry per layer, which separates the two. The overlay-filesystem analogy is exact: directory entries are per-layer, inodes are shared.

FieldDescription
Parent GUIDThe parent key.
Child nameThe key's name under that parent — one component, not a path.
LayerThe layer this entry belongs to.
TargetThe GUID of the key at this path in this layer, or HIDDEN.
SequenceA monotonic number assigned by LCS at creation, used for tiebreaking within a precedence tier.

On the wire a HIDDEN entry is a target type of 1 with an all-zero GUID (§5.A). A source returning a non-zero GUID on a HIDDEN entry is returning malformed data.

5.2.5.1 Creating a key #

Creating a key in a layer always assigns a fresh GUID and produces two records: a path entry (parent, name, layer) → GUID with a new sequence number, and a key record carrying that GUID.

LCS sends RSI_CREATE_ENTRY first and RSI_CREATE_KEY second. That order matters for the race: if another caller got there first, the entry creation returns RSI_ALREADY_EXISTS and LCS retries the whole thing as an open, reporting REG_OPENED_EXISTING. Failure on the second call, for a GUID LCS just minted, is not a race and fails closed (§5.2.3).

If a different layer already has a key at that path, the new layer gets its own distinct GUID. Each layer has its own key object, and resolution decides which is visible.

No operation creates a path entry pointing at an existing GUID. reg_create_key always mints a new one. The namespace is a tree, never a graph.

5.2.5.2 No GUID sharing across layers #

The path table's shape would technically permit two entries referencing one GUID, which would be a hard link. No API exposes it. Every key has exactly one canonical parent and name, and LCS validates that a source is not reporting otherwise.

Aliasing has an explicit, visible mechanism, and it is symlinks. Hard links would make parent-GUID semantics, descriptor inheritance, subtree enumeration and watch dispatch all ambiguous at once.

5.2.5.3 Hiding #

A layer can create a HIDDEN entry at a path, making the key invisible regardless of what lower-precedence layers say. It is the path-level equivalent of a value tombstone, and when the hiding layer is removed the lower key reappears.

A HIDDEN entry gets a sequence number like any other entry, so a conflict between a GUID entry in one layer and a HIDDEN entry in another at the same precedence resolves deterministically: the higher sequence number wins.

5.2.5.4 Hide and replace #

A single layer can hide an existing key and put a new one at the same path, and no special mechanism is needed for it. The layer creates its own path entry pointing at its own new GUID; lower-precedence entries for the same (parent, name) are masked because this layer's entry wins on precedence. Removing the layer removes both the new key and its masking effect, and the lower key comes back.

This falls out of per-layer path entries and precedence ordering without a line of code that knows about it.

5.2.5.5 Hive roots #

A hive root has no parent GUID and no child name, so there is no (parent, name, layer) tuple to remove or mask. Deleting or hiding a hive root fd is EINVAL, rejected before source dispatch, before transaction enlistment, before sequence allocation and before any watch event is generated.

5.2.6 Values

Peios / Advanced Peios / PKM / LCS / The Data Model

A value is a named, typed datum inside a key. Values hold the configuration data; keys hold values.

FieldDescription
Key GUIDThe key this value belongs to.
NameThe value's name; the empty string is the default value. Case-preserving, compared case-insensitively.
TypeA registry value type.
DataAn opaque byte array, up to MaxValueSize (default 1 MB).
LayerThe layer this entry belongs to. Every write is tagged.
SequenceAssigned by LCS at write time, for tiebreaking within a precedence tier.

A key holds many values with distinct names, and at most one unnamed one.

5.2.6.1 One name, many entries #

A single (key GUID, value name) pair can have several entries in the source — one per layer that has written to it. The source stores them all and returns them all; LCS resolves the effective value at read time (§5.3.6).

That is the mechanism that makes layer deletion revert configuration automatically. Delete the layer, its entries go, and the next-highest-precedence entry becomes effective. Nothing has to be recomputed or rewritten.

5.2.6.2 Types #

The full Windows type set is supported, for registry.pol fidelity: REG_NONE, REG_SZ, REG_EXPAND_SZ, REG_BINARY, REG_DWORD, REG_DWORD_BIG_ENDIAN, REG_LINK, REG_MULTI_SZ, REG_RESOURCE_LIST, REG_FULL_RESOURCE_DESCRIPTOR, REG_RESOURCE_REQUIREMENTS_LIST and REG_QWORD, numbered 0 to 11. Values are in §5.A.

LCS stores the type tag and returns it on read but does not interpret the data. The single exception is REG_LINK read as a symlink key's default value, which triggers target resolution (§5.2.4).

The three hardware-resource types, 8 to 10, describe device resource assignments in the Windows HKLM\HARDWARE hive, which the Windows kernel rebuilds at every boot. Peios has no equivalent: hardware enumeration belongs to the Linux device model, sysfs and /proc/iomem, not to the registry. LCS accepts these tags only so a value carrying one round-trips without loss. They have no semantics, LCS never produces one, and for every operation they behave as REG_BINARY. Rejecting a faithfully-copied value would break the fidelity guarantee that is the reason the registry exists.

Type tags are validated at every write boundary, before sequence allocation, transaction enlistment or source dispatch. An unknown code is EINVAL.

5.2.6.3 REG_TOMBSTONE #

One further type, REG_TOMBSTONE (0xFFFF), is internal. It is never returned to a caller reading a value — a caller whose effective entry is a tombstone gets ENOENT, which is exactly what a tombstone means.

There is no separate tombstone flag in the REG_IOC_SET_VALUE argument. Writing type REG_TOMBSTONE is the explicit tombstone operation, and it must carry zero-length data; non-empty tombstone data is EINVAL, again before sequence allocation, enlistment or dispatch.

5.2.6.4 Naming #

Value names use the same case rules as key names: Unicode Simple Case Folding, case-preserving, case-insensitive.

They differ in one respect. Backslash and forward slash are permitted in a value name. Value names are not hierarchical, so a separator has no meaning in one. Only the null byte is forbidden, and invalid UTF-8 is rejected. The empty string is reserved for the default value.

That difference is why watch event path components are length-prefixed rather than joined by a separator (§5.6.2) — a value name can contain the separator.

5.2.6.5 Size #

MaxValueSize defaults to 1 MB and is configurable from 4 KB to 64 MB (§5.10.3). Value data is opaque bytes and is not subject to the UTF-8 validation that applies to every string in the interface.

5.2.7 Tombstones

Peios / Advanced Peios / PKM / LCS / The Data Model

An overlay that can only add is not enough. registry.pol expresses absence — **Del.ValueName deletes a specific value, **DelVals deletes every value in a key before applying new ones — and a higher-precedence layer that can only override cannot say "this value must not be configured". Tombstones are how absence is expressed.

Both kinds are per-layer, and both vanish with their layer, restoring whatever they were masking.

5.2.7.1 Value tombstones #

A value tombstone is a layer entry that says the value does not exist in this layer and lower-precedence layers are masked. Resolution treats a winning tombstone as "not found" without falling through.

In the source's storage it is an entry of type REG_TOMBSTONE with no data. A caller who wins with one gets ENOENT.

Removing the tombstone's layer makes the lower-precedence value effective again, which is the whole point.

5.2.7.2 Blanket tombstones #

A blanket tombstone is a per-layer marker on a key that masks every value from lower-precedence layers, whatever its name. Where a value tombstone names one value, a blanket names none and covers all — including values whose names were not known when the blanket was written, which is exactly what **DelVals requires.

It is stored as a flag on the (key GUID, layer) relationship and occupies no per-name entry. It has its own sequence number.

5.2.7.2.1 How it competes #

A blanket does not short-circuit resolution. It enters the candidate pool as a tombstone candidate for every value name, at its own (precedence, sequence), and the ordinary rule picks the winner (§5.3.6).

So a per-value entry that beats the blanket on the tuple overrides it, and one that loses is masked. A layer can write a blanket and write specific values in the same layer: the specific values are visible because they were written afterwards and carry higher sequence numbers, and everything else from below is masked. That is **DelVals followed by new writes, expressed without a special case.

An exact tie — same precedence and same sequence — between a blanket and a per-value entry is not resolved in the blanket's favour or anyone's. It is malformed source data and the operation fails with EIO (§5.3.7).

5.2.7.2.2 Enumeration #

Enumerating a key with a blanket applies the same per-name rule to each name, so what a caller sees is the set of names whose winning candidate is not the blanket. It is not simply "the blanket's layer and above": a different layer at the same precedence with a higher sequence number surfaces, and one with a lower sequence number does not.

5.2.7.2.3 Removal #

Removing a blanket, or the layer holding it, unmasks everything it was hiding.

5.2.7.3 What a watcher sees #

Nothing about tombstones. Writing a blanket produces one VALUE_DELETED per name it newly masks; removing one produces one VALUE_SET per name that became visible. A watcher sees per-value effective state and never has to know the mechanism (§5.6.1).

5.2.8 Names and Case

Peios / Advanced Peios / PKM / LCS / The Data Model

Every string in the LCS interface — key names, value names, hive names, layer names, paths — is UTF-8. Invalid UTF-8 is rejected with EINVAL before parsing, routing, folding, layer resolution or source dispatch. Null bytes are rejected in all of them.

The one thing that is not a string is value data, which is opaque bytes.

5.2.8.1 Lengths are byte counts #

Every configured length limit is measured in UTF-8 bytes, not Unicode scalar values and not display characters. MaxPathComponentLength (default 255) bounds one component or one value or layer name; MaxTotalPathLength (default 16383) bounds a whole path; MaxKeyDepth (default 512) bounds nesting.

A syscall path arrives as a null-terminated C string, is copied under a hard bound, and has its terminator stripped before anything is measured — so the terminator is not part of the length. Ioctl and RSI strings are length-delimited and need no terminator; a terminator byte included in the length is a null byte and is therefore invalid.

5.2.8.2 Separators #

Backslash is canonical. Forward slash is accepted on input and treated as a separator wherever a separator is recognised, so a component can never contain either. There is no string-rewriting step: normalisation is a property of how paths are split rather than a transformation applied to them.

5.2.8.3 Case folding #

Comparison is case-insensitive and storage is case-preserving. The algorithm is Unicode Simple Case Folding — the C and S status entries of CaseFolding.txt, with the full (F) and Turkic (T) entries excluded. It is a fixed one-to-one codepoint mapping with no locale input, applied after decoding from UTF-8, never to raw bytes.

The Unicode version is pinned at 16.0. The table is generated by pkm/tools/lcs/generate_casefold_table.py and checked in with the digest of the source data, so adopting a newer Unicode version means regenerating the table deliberately. It is not something that happens by updating a dependency.

This gives practical compatibility with Windows' RtlCompareUnicodeString without claiming byte-identical behaviour in every edge case.

Unicode normalisation is not performed. NFC and NFD forms of the same visual character are different names, matching Windows.

Case folding is what "identity" means throughout: a layer's identity is its folded name, a hive route's identity includes its folded name, and a duplicate is a folded-equal duplicate. RoleA and rolea are one layer, not two.

Two comparisons in the kernel are ASCII-only rather than folded: the check for whether a layer name is base on two of its call sites, and KACS's duplicate check when parsing private layer names into a token. For the literal string base the two agree; for arbitrary names they do not.

5.2.9 Deletion and Orphans

Peios / Advanced Peios / PKM / LCS / The Data Model

Deletion operates on two levels, because naming and identity are separate things.

5.2.9.1 Deleting a key #

REG_IOC_DELETE_KEY removes one layer's path entry. The key's data — its GUID, descriptor and values — is untouched; only a name is removed. LCS derives the parent GUID from the fd's ancestor chain and the child name from the last component of its resolved path, and sends RSI_DELETE_ENTRY.

If path entries remain in other layers, the key is still visible through them. If none remain anywhere, the key is orphaned.

A key with visible children cannot be deleted: ENOTEMPTY. Visibility is evaluated globally, across all enabled layers, and deliberately ignores the caller's private layer set — so whether a deletion succeeds does not depend on who is asking. Recursive deletion is a client-side tree walk, not a kernel primitive.

Deleting a key does not delete its values. Values belong to the GUID, not to the path entry, and go when the GUID goes.

Hive roots cannot be deleted or hidden (§5.2.5).

5.2.9.2 Layer deletion #

Deleting a layer removes all of its path entries, value entries and blanket tombstones, across every source. LCS broadcasts RSI_DELETE_LAYER and each source returns the GUIDs that lost their last path entry as a result.

Effects on live state follow from the model with no special cases: keys named only by that layer become orphaned; where the layer held the winning value entry, the next layer's value becomes effective; blanket tombstones it held are removed and unmask what they were hiding; and Security Descriptors are unchanged, because they were never layered.

Watchers are notified by whatever recovery mechanism applies — per-key events for the orphaned keys, and a source-wide OVERFLOW for the rest (§5.6.3).

Before sending RSI_DELETE_LAYER, LCS aborts every bound transaction whose mutation log touched that layer. Otherwise a transaction could commit writes into a layer that no longer exists. Those transactions return EINVAL on their next operation or commit attempt.

Layer deletion is what role uninstallation and Group Policy removal are.

5.2.9.3 Orphaned keys #

An orphaned key is a GUID with no path entry in any layer. It follows the Linux unlink model: alive but unnamed.

Existing fds keep working. Operations that address the key by GUID proceed normally:

  • querying, setting and deleting values;
  • setting and removing blanket tombstones;
  • querying and setting the Security Descriptor;
  • querying key metadata;
  • flushing the key's hive;
  • closing the fd.

Namespace operations return ENOENT:

  • creating a child key under it;
  • opening or creating anything relative to it;
  • deleting its path entry;
  • hiding it;
  • backing it up.

The reason is that an orphaned key is no longer a reachable subtree root. Allowing new names beneath an unnamed key would build a subgraph nothing can reach.

5.2.9.4 Watches on an orphaned key #

A watch armed before the key was orphaned stays armed, and the transition delivers KEY_DELETED. After that the watch may still observe GUID-local changes made through the surviving fds, though the subtree is no longer expanded through the orphaned key.

Arming a new watch on an already-orphaned key is ENOENT. Re-arming one that is already armed is allowed.

5.2.9.5 Dropping the GUID #

When the last fd to an orphaned key closes and the source is Active, LCS sends RSI_DROP_KEY, which purges the key record, every value entry across every layer, and any remaining blanket tombstones. It is dispatched before the in-kernel key state is released.

The request is asynchronous: nothing waits for the answer, and a valid response is processed as an ordinary response rather than as a late one (§5.8.5). That distinction is load-bearing — RSI_DROP_KEY is a mutating operation, and without it the arrival of a perfectly normal answer to a caller-less request would look like an unaccounted mutation and tear the source down.

If the source is Down, LCS releases its in-kernel state and does not queue a deferred drop. There is no deferred-drop queue. Recovering the key record then falls to the source's own startup obligation to purge records with no path entries before it becomes Active (§5.8.2).

close() never reports orphan cleanup failure to userspace, and never can: it returns 0 unconditionally.

5.2.9.6 A new key at the same path #

A layer can create a new key where another layer's key already exists. Each layer has its own path entry pointing at its own GUID, and resolution decides which is visible.

Fds referencing the other GUID are completely isolated from it: different identity, different data, different value entries. Nothing observable connects two keys that merely share a name.

5.3.1 The Layer Model

Peios / Advanced Peios / PKM / LCS / Layers

A layer is a named collection of registry writes that can be managed as a unit. Layers have precedence, and the highest-precedence entry wins. They are how role installation, Group Policy and configuration revert all work, and they are the reason removing a role does not leave its settings behind.

FieldMutableDescription
NameNoThe layer's identity — not a GUID, not an integer. Case-preserving, compared with Unicode Simple Case Folding. Bounded by MaxPathComponentLength.
PrecedenceYesHigher wins. Default 0.
EnabledYesA disabled layer is invisible during resolution unless it is attached to the resolving thread's credentials (§5.3.5). Default true.
Owner—The SID of the principal that created the layer. Informational only.

The name being the identity is deliberate. Layer names are code-generated and meaningful by construction — role-jellyfin, gpo-security-baseline — so there is nothing an opaque identifier would add.

Owner is never used for an access check; authorisation is the descriptor on the layer's metadata key (§5.3.4). It is also not quite immutable as a field: a refresh re-reads the Owner value and re-selects it, so rewriting the value does change what is cached. Because nothing consults it, that has no effect on anything.

5.3.1.1 Precedence tiers #

The base layer and role layers all sit at precedence 0. Within one tier, the most recent write wins — the highest sequence number. Group Policy layers sit above 0 and override both.

Establishing or raising a layer's precedence above 0 requires SeTcbPrivilege (§5.3.4). That is what keeps the tier boundary meaningful.

5.3.1.2 Caps #

MaxTotalLayers, default 1024, bounds the in-memory layer table. Creating a layer when it is full returns ENOSPC.

The table itself is a fixed array sized at compile time for 1023 dynamic layers plus the base layer. MaxTotalLayers is configurable up to 65536, and a value above 1024 validates and publishes, but the table still runs out at 1023 dynamic entries. Values below 1024 bind correctly.

MaxLayersPerValue, default 128, bounds how many layers may write to the same (key GUID, value name) pair. It is a guard against amplification — every read of that value has to resolve every entry — not an access control boundary.

It is enforced at REG_IOC_SET_VALUE time, before the source is contacted, by querying the source for the current entry count. A write that replaces an existing entry in the same layer does not increase the count and is not checked. Exceeding the cap is ENOSPC.

The check is deliberately best-effort admission control, not a storage invariant. It queries and then dispatches without holding anything, so concurrent writers can both observe room and both proceed. Sources are not required to enforce it atomically. Once LCS observes a count at or above the cap, further new-layer writes are refused.

Blanket tombstones and value deletions are not subject to it.

5.3.1.3 Layers are global; entries are per-source #

There is one authoritative layer table, held by the kernel. Each source stores layer entries — path entries and value writes tagged with layer name strings — for its own hives. Layer metadata is global and lives in one place.

A source never needs the layer table. It stores what it is told, tagged with whatever name it is given, and returns everything on request. A source that has never seen a particular layer name simply stores entries carrying it. Resolution — precedence, enabled state, tombstone evaluation — happens entirely in the kernel, and the layer snapshot is passed into each operation rather than pushed to sources. There is no RSI operation that hands a source the layer list.

5.3.2 The Base Layer

Peios / Advanced Peios / PKM / LCS / Layers

The base layer, named base, is a kernel-reserved implicit layer. It exists unconditionally, before any source registers and whether or not any metadata has ever been persisted for it.

It is a static constant in the kernel: precedence 0, enabled. It is never stored in the dynamic layer table, is always emitted first in every layer snapshot, and is handed out even when the dynamic table is empty. A source that registers with a completely empty database is therefore immediately usable, because the one layer that writes need is not in the database.

Four things cannot happen to it:

  • it cannot be deleted;
  • it cannot be disabled;
  • its precedence cannot be changed;
  • a layer table row for it cannot be published at all.

Each of those is enforced in more than one place. Deletion is refused by the layer table, by the resolution core, by the RSI_DELETE_LAYER dispatch path, and by the transaction layer-abort path. Publication of a base row is rejected outright, and the refresh path short-circuits for base before it would read Precedence or Enabled, so persisted values for those are never even consulted.

5.3.2.1 Persisted metadata #

Machine\System\Registry\Layers\base\ may exist, and it usually does, but it decorates the base layer rather than defining it. What LCS takes from it is the metadata key's GUID and its cached Security Descriptor — which is to say, who may write into the base layer (§5.3.4). Its Precedence and Enabled values are ignored.

The internal self-watch also ignores a SUBKEY_DELETED for base: if a higher-precedence HIDDEN entry masks the base layer's metadata key, that is not a layer deletion and is not processed as one. The base layer's existence is hardcoded and layer mechanics cannot reach it.

5.3.2.2 The default target #

A write that names no layer targets the base layer. That is the default for manual administration and for system initialisation.

Before the base layer's metadata key exists — first boot, before seed restore — LCS uses a compiled-in default descriptor granting SYSTEM and Administrators KEY_ALL_ACCESS, so writes into the base layer are possible from the very beginning. The compiled-in default is replaced by the real descriptor as soon as seed restore creates the key.

5.3.2.3 base is matched two ways #

The check for whether a name is the base layer is implemented twice in the kernel: once using Unicode Simple Case Folding like every other name comparison, and once using ASCII case-insensitive comparison, on two of its call sites. For the literal string base the two agree.

5.3.3 Layer Metadata

Peios / Advanced Peios / PKM / LCS / Layers

Layer metadata lives in the registry, under Machine\System\Registry\Layers\<LayerName>\. Each layer's key holds three values:

ValueTypeDefault if missing
PrecedenceREG_DWORD0
EnabledREG_DWORD, 0 or 1true
OwnerREG_BINARY, a SIDthe creating token's SID for a new layer

A value of the wrong type, or a REG_DWORD that is not exactly four bytes, or an Enabled greater than 1, is malformed metadata and is rejected rather than coerced.

Owner selection has a fallback chain: the metadata value; failing that, the creator's SID for a newly created layer; failing that, the previous known-good owner; failing that, the owner SID from the metadata key's own descriptor. If none of those is available the layer cannot be published. Every one of these is informational and none grants access.

There is no "create layer" or "delete layer" syscall. Creating a key under Layers\ creates a layer; deleting that key deletes it. Creation should be done inside a transaction so that all three values are present when the refresh runs.

5.3.3.1 Circularity #

Layer metadata is stored in the registry, which is itself layered. That is circular, and it is safe, because resolution never re-enters itself.

LCS always resolves using its currently published layer table — including when resolving layer metadata values. When a write to the metadata subtree commits, the refresh reads the affected metadata using the current table and then publishes an updated one. The table is never re-resolved mid-operation; each operation takes one snapshot and uses it throughout.

So a high-precedence layer can override another layer's precedence, and that is useful and intended. It simply takes effect at the next publication rather than recursively.

5.3.3.2 Publication is atomic #

A layer is not merely a name and a precedence. The published unit is three things together: the layer table entry, the metadata key's GUID, and the cached Security Descriptor of that key. All three are written under one lock, and a snapshot reader that finds them incomplete returns EIO rather than a half-populated layer.

A layer that has no metadata key GUID and no authorisation descriptor is not visible in the table at all. There is no window in which a layer exists but nobody can be authorised against it.

Creating the metadata key is ordinary key creation, so LCS computes its descriptor from parent inheritance through KACS before the source persists it. On the normal path the key therefore has a descriptor before the layer can be published.

5.3.3.3 When the refresh runs #

Changes under Layers\ mark the affected layer names dirty. After the mutating operation commits, and before the syscall returns to userspace, LCS runs a bounded refresh for those names: it reads the committed metadata key, its values and its descriptor, and publishes the new entry atomically.

For a transaction the refresh runs once, after the source commit succeeds and before REG_IOC_COMMIT returns.

The internal self-watch is what notices the subtree changed, but the watch callback is not the atomicity boundary and must never publish a partial entry. LCS does not perform source round trips while holding the watch-map or layer-table publication locks.

5.3.3.4 When the metadata descriptor will not parse #

If the metadata key's descriptor cannot be read or parsed during a refresh, the source has returned malformed data. LCS emits an audit event, does not publish or update that layer's entry, and keeps the previous known-good one. If the refresh was required to complete the operation in hand — creating a layer, say, or exposing one — the syscall fails with EIO.

5.3.4 Writing Into a Layer

Peios / Advanced Peios / PKM / LCS / Layers

Every mutating operation that targets a layer — value writes, value deletions, tombstones, key hides, blanket tombstones, key creation — requires layer write authorization: KEY_SET_VALUE on the layer's metadata key at Machine\System\Registry\Layers\<LayerName>\.

This is a second AccessCheck, against a different object, and it is in addition to the fd's granted mask on the target key. Both must pass.

The descriptor on a layer's metadata key is therefore the answer to "who may write into this layer". The base layer's inherits from the Machine hive root — SYSTEM and Administrators with KEY_ALL_ACCESS. Group Policy layers get restrictive descriptors from the GP client at creation; role layers get theirs from the role installer.

That closes two escalation paths at once. An unprivileged process cannot write into a GP layer, and one role's service cannot write into another role's.

The layer metadata descriptors are cached alongside the layer table and invalidated by the same self-watch (§5.3.3). A layer that is not in the table is ENOENT for any operation naming it.

5.3.4.1 The base layer before it exists #

On first boot, before seed restore, Layers\base\ does not exist. LCS falls back to a compiled-in default descriptor granting KEY_ALL_ACCESS to SYSTEM and Administrators, so base-layer writes work from the start. It is replaced by the persisted descriptor the moment seed restore creates the key.

5.3.4.2 Layer lifecycle #

OperationRequirement
Create a layer at precedence 0KEY_CREATE_SUB_KEY on Layers\
Create a layer above precedence 0KEY_CREATE_SUB_KEY on Layers\ and SeTcbPrivilege
Write into a layerKEY_SET_VALUE on the layer's metadata key
Modify layer metadataKEY_SET_VALUE on the metadata key; raising precedence above 0 additionally requires SeTcbPrivilege
Delete a layerDELETE on the metadata key's fd

Everything except the precedence rule is controlled purely by the descriptor on the metadata key.

5.3.4.3 The precedence gate #

SeTcbPrivilege is required specifically to establish or raise a layer's precedence above 0. It is defence in depth: compromising the descriptor on Layers\ is not enough to create a Group Policy-tier layer.

The check is synchronous and inline at REG_IOC_SET_VALUE time, and it happens early — before sequence allocation, before transaction enlistment, before the source is contacted. It runs when three things hold: the target key GUID is in the set of known layer metadata keys, the value name folds equal to Precedence under the same Unicode folding used for every other value name, and the data is a positive REG_DWORD. Failing the privilege check is EPERM.

The gate tests for a four-byte REG_DWORD specifically. A Precedence written with some other type slips past it — and then fails at the refresh, which rejects a non-REG_DWORD Precedence as malformed metadata. The precedence never actually rises.

REG_IOC_RESTORE has its own equivalent gate, applied to the backup stream's layer manifest before anything is written (§5.9.3).

5.3.4.4 Deleting a layer #

Deleting the metadata key fires a SUBKEY_DELETED on the internal self-watch. LCS removes the layer from the table and broadcasts RSI_DELETE_LAYER to every registered source, each of which purges every entry tagged with that name and reports the GUIDs that lost their last path entry.

Before the broadcast, LCS aborts every bound transaction whose mutation log touched that layer (§5.2.9).

A SUBKEY_DELETED for base is ignored (§5.3.2).

5.3.5 Private Layers

Peios / Advanced Peios / PKM / LCS / Layers

A private layer is a disabled layer attached to a thread's credentials. It is invisible during ordinary resolution and treated as enabled when resolving on behalf of a thread whose token names it.

That covers three things a shared registry otherwise cannot do: giving one session experimental settings without affecting others; injecting test configuration without touching the shared tree; and giving a container a different view of the registry without a separate hive.

5.3.5.1 Resolution #

A private layer participates in normal precedence ordering. A disabled layer with precedence 5 attached to a thread resolves at precedence 5, competing with everything else at that level. It is not an overlay on top; it is a layer that only that thread can see.

The activity test is exactly: a layer is active for a thread if it is globally enabled, or its name appears in that thread's private layer set. Name matching uses Unicode Simple Case Folding, like every other layer name comparison.

5.3.5.2 Attachment #

Private layer names reach a thread through the KACS token's LCS credential extension — the same versioned block that carries scope GUIDs for private hives (§5.2.2). LCS reads the credentials from the effective token on each operation and passes them into resolution.

Private layers are therefore per-thread, not per-process: threads in one process can hold different private layer sets through different impersonation tokens.

5.3.5.3 Two things about the caps #

MaxPrivateLayersPerToken, default 16, is described as a limit on attachment. It is not enforced there. KACS applies its own hard cap of 256 names when it parses the token specification, and the configurable LCS limit is applied later, when LCS acquires a thread's private credentials for an operation.

The consequence is that a token carrying seventeen private layers is accepted by KACS and then fails every LCS operation, rather than being refused when it was built. Reading LCS's configured limits from KACS would invert the dependency between the two, so the cap stays where it can be read.

The failure is E2BIG. It was EACCES, which read as an access-control denial and sent anyone debugging it towards descriptors and privileges rather than towards a count that was fixed when the token was assembled, possibly in another process. MaxScopeGUIDsPerToken shares the check and the errno. A missing token is still EACCES, because that one is an access decision.

KACS also deduplicates private layer names using ASCII case-insensitive comparison, where LCS matches them with Unicode Simple Case Folding. Two names that LCS would treat as one layer can both sit on a token.

5.3.5.4 The privilege that is not checked #

Attaching a private layer whose precedence is above 0 ought to require SeTcbPrivilege — otherwise an unprivileged process can attach an existing high-precedence disabled layer to its own credentials and see, and potentially influence, Group Policy-tier configuration.

That check does not exist. KACS never consults the LCS layer table when parsing the credential extension, and there is no precedence lookup and no privilege test anywhere on the attachment path. What does gate attachment is SeCreateTokenPrivilege, because private layer names can only enter a token when the token is created — the same blanket gate that governs scope GUIDs.

5.3.6 Resolution

Peios / Advanced Peios / PKM / LCS / Layers

Layer resolution turns several per-layer entries into one effective answer. It is the mechanism that makes the registry layered, and it is the same algorithm for path entries and for values — one resolves existence claims, the other resolves data.

5.3.6.1 The rule #

Every candidate is a tuple (precedence, sequence, entry). The winner is the maximum, ordered by precedence first and sequence second.

Building the candidate list:

  1. For each entry the source returned, look up its layer in the current layer table. An entry naming a layer that is not in the table is skipped, not rejected.
  2. Discard entries from layers that are not active for this thread — a layer is active if it is globally enabled or its name is in the thread's private layer set (§5.3.5).
  3. For values, add every blanket tombstone on the key as a tombstone candidate for the requested name, at its own precedence and sequence (§5.2.7).
  4. If there are no candidates, the answer is not-found.
  5. Take the maximum. A winning HIDDEN path entry, a winning REG_TOMBSTONE value entry, and a winning blanket all mean not-found.

That is the whole algorithm. Everything the layer system does — override, revert, mask, hide, hide-and-replace — is a consequence of it.

5.3.6.2 Unknown layers are latent, not wrong #

An entry tagged with a well-formed layer name that is not currently in the table is a valid latent entry. It is ignored while the layer is absent, and if a layer with the same folded identity is later created, that entry becomes eligible for resolution under the new metadata.

This is what makes restore, import and boot ordering work: source storage can legitimately hold entries before their metadata has been loaded. It also follows the core rule — sources persist entries, LCS decides their meaning.

Normal operations do not create such entries. A layer-targeting ioctl naming a layer that is not in the table returns ENOENT. Latent entries come from existing storage, from a restore or import, from a previous boot, or from source behaviour. Since sources are trusted, a well-formed latent entry is not malformed merely because its layer is absent right now.

An entry whose layer name is malformed is a different matter, and is rejected as malformed source data.

5.3.6.3 Enumeration #

Enumerating values collects the unique names across all layers and resolves each one, returning only those whose answer is not not-found. Enumerating subkeys collects the unique child names and resolves each, returning those that map to a GUID rather than HIDDEN.

"Unique" means folded-unique. Two entries whose names differ only in case are one name, which is the only reading consistent with names being case-insensitive.

A caller sees effective state only. Tombstoned values, blanket-masked values and hidden keys are simply absent. Per-layer raw data is never visible through a normal operation.

5.3.6.4 Enumeration is index-based, and that has a cost #

REG_IOC_ENUM_VALUES and REG_IOC_ENUM_SUBKEYS return one entry at an index. Each call re-resolves the full set and returns the entry at that position, so walking 0..N-1 performs N full resolutions — O(n²) work overall.

For a key with a handful of values that does not matter. REG_IOC_QUERY_VALUES_BATCH exists for when it does: one call, the whole effective value set, one resolution.

Enumeration order is not defined, and the index-to-entry mapping can change between calls if the effective set changes. Indices must not be cached across mutations. The batch call is also the way to get a consistent snapshot.

5.3.6.5 Neither party can lie about ordering #

Two rules stop a source manipulating the outcome.

A source-returned entry whose sequence number is greater than or equal to the next sequence LCS would allocate is malformed data. Sources store the numbers LCS assigns them; they cannot legitimately hold a future one. Without this, a compromised source could fabricate a sequence number and win every tie in its own hive. Every entry in a response is validated this way, whatever layer it names.

Duplicate sequence numbers at the same precedence, where they would actually have to be compared to pick a winner, are also malformed. LCS rejects the response rather than making an arbitrary choice. Duplicates that never get compared are not an error.

Both produce EIO and an LCS_SOURCE_VALIDATION_FAILURE audit event (§5.4.4).

5.3.7 The Sequence Counter

Peios / Advanced Peios / PKM / LCS / Layers

LCS keeps one global monotonic counter. Every mutation that creates a layer-qualified entry — a path entry, a value write, a key hide, a blanket tombstone — takes the next number from it. The counter is never decremented and never reset.

It provides deterministic tiebreaking within a precedence tier (§5.3.6). Wall-clock time is tracked separately, as each key's last write time, for humans.

5.3.7.1 Allocation #

Allocating a number increments the counter. Allocated numbers are never reused, even if the operation later fails, times out, or is part of a transaction that aborts. Gaps in the sequence space are normal and carry no meaning.

A transactional mutation is assigned its number when the operation is accepted into the transaction, not at commit. That preserves the order in which the caller performed the operations, which is what layer tiebreaking and watch ordering need if it commits (§5.7.2).

5.3.7.2 Initialisation #

Each source reports the highest sequence number it has persisted in its registration handshake, and LCS raises the counter to one above the maximum reported. New writes therefore always outrank anything already in storage, even after a restart.

A source registering later advances the counter the same way, to max(current, source_max + 1). If that addition would overflow 64 bits the registration fails with EOVERFLOW and the source is not made Active — the failure happens before the slot becomes usable.

The counter itself refuses to hand out U64_MAX, so the value is never allocated.

5.3.7.3 What a sequence number is not #

A sequence number is not a hive generation number.

A sequence number orders layer-qualified entries for resolution and is persisted by sources.

A hive generation number is a volatile, per-hive, kernel-owned change epoch, exposed by REG_IOC_QUERY_KEY_INFO (§5.5.3) so that a watcher recovering from OVERFLOW can tell whether it actually missed anything. Sources never see it and never persist it. Its baseline is initialised from the source's reported maximum sequence at registration, purely so that observed generations are monotonic relative to persisted entries; after that the two are unrelated.

5.3.7.4 Restore #

A restore does not write the backup's sequence numbers. It reserves a fresh range and remaps into it, so restored entries are newer than everything that was there before while keeping their relative order from the backup (§5.9.3).

5.4.1 The Access Flow

Peios / Advanced Peios / PKM / LCS / Security

The registry's security model is KACS applied to keys. LCS defines no access control mechanism of its own: the same AccessCheck, the same Security Descriptors, the same tokens and SIDs that every other Peios subsystem uses. What this section describes is how those primitives attach to registry operations.

Every open follows the same five steps.

  1. Token capture. LCS takes the calling thread's effective token — the impersonation token if one is set, otherwise the process primary token. This is the same capture KACS performs for every syscall.

  2. Path resolution. LCS walks the path through the layer stack, following symlinks. No access check happens during the walk. Intermediate keys are not evaluated; only the final key matters.

  3. AccessCheck. LCS calls KACS AccessCheck with the captured token, the final key's Security Descriptor as returned by the source, and the desired access mask. Every requested right must be granted or the open fails with EACCES. There is no partial grant: the caller gets what it asked for or nothing.

    MAXIMUM_ALLOWED is the exception, and the only way to ask for whatever is available. AccessCheck computes the full allowed set and that becomes the granted mask.

  4. The granted mask is stored on the fd. It never changes.

  5. Per-ioctl checks are bitmask tests. Each ioctl has a required right; LCS tests the fd's granted mask against it and returns EACCES without contacting the source if it is absent. The Security Descriptor is not re-read and AccessCheck is not re-evaluated.

5.4.1.1 No traverse checking #

LCS checks nothing on the way down. A process can open Machine\System\Services\Jellyfin without holding any access to Machine\System\Services or Machine\System.

This matches the Windows registry and is a deliberate difference from filesystem path semantics, where every directory in a path is checked for traverse. It means a key's Security Descriptor is the whole story about who can reach it, and that an ancestor's descriptor confers no protection on its descendants.

5.4.1.2 Symlinks #

Opening a symlink follows it, and AccessCheck runs on the target key, not the link. REG_OPEN_LINK opens the link itself instead — but only for the final path component. A symlink encountered part-way through a path is followed regardless.

Creating a symlink is privileged: KEY_CREATE_SUB_KEY and KEY_CREATE_LINK on the parent, plus either SeTcbPrivilege or membership of Administrators (§5.2.4).

5.4.1.3 Fds are capabilities #

A key fd can be passed over a Unix socket with SCM_RIGHTS, and it carries its granted mask with it. The recipient gets the access the original opener was granted, whether or not its own token would have passed AccessCheck.

This is explicit delegation and it is consistent with how fds work everywhere else in Peios. Passing a KEY_WRITE fd hands over write access.

Opening relative to a parent fd skips path parsing and AccessCheck for the parent portion — the caller already proved its access when it obtained the parent fd. This is the ordinary way to traverse a subtree.

5.4.1.4 Changing a descriptor does not revoke a handle #

A Security Descriptor change takes effect for future opens. An fd that already exists keeps the mask it was granted at open, because that is what semantic rule 5 says (§5.1). The recourse for genuinely revoking access is to restart the process holding the fd.

Descriptor changes are also not layer-qualified. They are direct mutations on the key object, and deleting a layer does not undo one (§5.1, rule 4).

5.4.1.5 The second check nobody expects #

Every layer-qualified mutation runs a second AccessCheck, against a different object: the metadata key of the layer being written to. That check is for KEY_SET_VALUE and it is in addition to the fd's granted mask on the target key. Both must pass. §5.3.4 covers it.

5.4.2 Access Rights

Peios / Advanced Peios / PKM / LCS / Security

Registry rights occupy the Windows bit positions, so a Security Descriptor carrying registry ACEs is binary-compatible with one written by Windows tooling. That is a registry.pol and Samba requirement, not an aesthetic choice.

The values are in §5.A. In summary: six specific rights in bits 0–5 (KEY_QUERY_VALUE, KEY_SET_VALUE, KEY_CREATE_SUB_KEY, KEY_ENUMERATE_SUB_KEYS, KEY_NOTIFY, KEY_CREATE_LINK), the four standard rights DELETE, READ_CONTROL, WRITE_DAC and WRITE_OWNER, and ACCESS_SYSTEM_SECURITY for the SACL, which is itself gated by SeSecurityPrivilege.

There is no execute right on a key.

5.4.2.1 Convenience masks and generic mapping #

KEY_READ, KEY_WRITE and KEY_ALL_ACCESS are concrete masks, not generic bits — they are unions of the rights above and are usable directly.

Separately, LCS accepts the raw KACS generic bits GENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE and GENERIC_ALL in a caller's desired_access and in ACE masks in a Security Descriptor. Those are mapped through the registry generic mapping before AccessCheck sees them. GENERIC_READ maps to KEY_READ, GENERIC_WRITE to KEY_WRITE, GENERIC_ALL to KEY_ALL_ACCESS, and GENERIC_EXECUTE maps to zero, because there is nothing to execute.

5.4.2.2 Validating what a caller asks for #

desired_access is validated before path resolution or AccessCheck.

  • Zero is EINVAL. A caller must ask for something.
  • Any bit outside the valid caller mask is EINVAL.
  • MAXIMUM_ALLOWED may appear alone or combined with anything else.
  • SYNCHRONIZE (0x00100000) is not a registry right, so it is an unknown bit and fails EINVAL.

The valid caller mask is a single constant, REG_VALID_DESIRED_ACCESS_MASK (§5.A): the six specific rights, the four standard rights, ACCESS_SYSTEM_SECURITY, MAXIMUM_ALLOWED and the four generic bits.

5.4.2.3 Validating what a source returns #

Source-supplied Security Descriptors are validated too, because a source is trusted for its data but not for its arithmetic (§5.8.4).

An ACE mask may contain concrete registry rights and raw generic bits. It may not contain MAXIMUM_ALLOWED — that is a request, not a grant, and it is meaningless in an ACE. After generic mapping, an ACE mask must be a subset of the concrete registry rights plus ACCESS_SYSTEM_SECURITY.

A descriptor that breaks either rule is malformed source data: the operation fails closed with EIO and an LCS_SOURCE_VALIDATION_FAILURE audit event is emitted (§5.4.4).

Two further constants, REG_VALID_MAPPED_ACCESS_MASK and REG_VALID_ACE_ACCESS_MASK, express those two bounds.

5.4.2.4 Which right each operation needs #

OperationRequired
REG_IOC_QUERY_VALUE, QUERY_VALUES_BATCH, ENUM_VALUESKEY_QUERY_VALUE
REG_IOC_SET_VALUE, DELETE_VALUE, BLANKET_TOMBSTONE, FLUSHKEY_SET_VALUE
REG_IOC_ENUM_SUBKEYSKEY_ENUMERATE_SUB_KEYS
REG_IOC_QUERY_KEY_INFOREAD_CONTROL
REG_IOC_DELETE_KEY, HIDE_KEYDELETE
REG_IOC_NOTIFYKEY_NOTIFY
REG_IOC_GET_SECURITYREAD_CONTROL for owner, group and DACL; ACCESS_SYSTEM_SECURITY for the SACL
REG_IOC_SET_SECURITYWRITE_OWNER for owner or group; WRITE_DAC for the DACL; ACCESS_SYSTEM_SECURITY for the SACL
REG_IOC_BACKUPSeBackupPrivilege, no per-key check
REG_IOC_RESTORESeRestorePrivilege, no per-key check
creating a keyKEY_CREATE_SUB_KEY on the parent
creating a symlink keyKEY_CREATE_SUB_KEY and KEY_CREATE_LINK on the parent, plus SeTcbPrivilege or Administrators

Where a REG_IOC_SET_SECURITY or GET_SECURITY request names several components, every right those components imply must be present before the source is contacted.

REG_IOC_FLUSH requires KEY_SET_VALUE because a flush is only meaningful to a caller that wrote something and wants it durable. Requiring a write right stops an unprivileged reader using flush as a disk-I/O amplifier.

5.4.2.5 Enumeration exposes names, not contents #

REG_IOC_ENUM_SUBKEYS performs no per-child access check. Every visible child is returned, whatever the caller's access to it. The caller learns names, and must open each child separately — with a real AccessCheck — to read anything.

Subtree watches work the same way: a watcher is told a descendant was created without a check on that descendant. Structure visibility is deliberately weaker than content visibility, matching RegNotifyChangeKeyValue and RegEnumKeyEx.

5.4.3 Inheritance and Hive Roots

Peios / Advanced Peios / PKM / LCS / Security

5.4.3.1 Inheritance at creation #

When reg_create_key creates a key, its initial Security Descriptor is computed from the parent's by the KACS inheritance algorithm. LCS supplies the parent descriptor, the creating token, the registry generic mapping and the valid-mask bound, and hands the result to the source to persist. It implements no inheritance logic of its own.

Three things about registry inheritance are worth stating.

It is static. The computation happens once, at creation. A later change to the parent's descriptor does not propagate to children that already exist. Re-propagating is an explicit administrative action — a client-side tree walk — not a kernel operation, and there is no code anywhere in LCS that walks a tree to re-propagate.

Only CONTAINER_INHERIT_ACE matters. Every registry object is a container: keys hold subkeys and values. Values are not independent security objects and have no descriptors of their own; they inherit their key's access control. OBJECT_INHERIT_ACE is never used to select an ACE for inheritance. It is only cleared on the child copy when NO_PROPAGATE_INHERIT_ACE applies.

A parent with no inheritable ACEs falls back to the creating token's default DACL. That fallback covers the DACL only; there is no default SACL.

5.4.3.2 Hive roots #

A hive root has no parent, so it is the top of every inheritance chain below it and cannot inherit anything itself. Its descriptor is created by the source, on first boot, and LCS enforces whatever the source stored.

LCS holds no template. There are no hardcoded SIDs, no default hive root descriptors, and no code that would construct one — searching for them finds nothing. The defaults that follow are what loregd writes; they are conventions of the source, not properties of the kernel.

Machine\:

PrincipalRightsInheritance
SYSTEMKEY_ALL_ACCESSContainer-inherit
AdministratorsKEY_ALL_ACCESSContainer-inherit
Authenticated UsersKEY_READContainer-inherit

Users\<SID>\:

PrincipalRightsInheritance
the user's SIDKEY_ALL_ACCESSContainer-inherit
SYSTEMKEY_ALL_ACCESSContainer-inherit
AdministratorsKEY_ALL_ACCESSContainer-inherit

These mirror Windows HKLM and HKU. A subsystem needing something tighter — Machine\Security\, say — sets an explicit descriptor on its own subtree root at creation, overriding what it inherited.

Because there is no traverse checking (§5.4.1), a restrictive descriptor high in the tree protects only the key it is on. Protection of a subtree comes from the descriptors its keys inherited at creation, which is exactly why an administrator changing a parent's descriptor and expecting the subtree to follow will be disappointed.

5.4.3.3 Reading and writing descriptors #

REG_IOC_GET_SECURITY and REG_IOC_SET_SECURITY take a security_info bitmask naming which components to act on: owner, group, DACL, SACL. Zero is EINVAL, and so is any unknown flag; both are rejected before the source is contacted, before transaction enlistment and before any mutation.

The rights required are computed from every component named. Reading owner, group or the DACL needs READ_CONTROL; reading the SACL needs ACCESS_SYSTEM_SECURITY. Setting owner or group needs WRITE_OWNER; setting the DACL needs WRITE_DAC; setting the SACL needs ACCESS_SYSTEM_SECURITY. A request naming several components must hold all the corresponding rights.

A set is a merge, not a replacement. LCS reads only the components security_info names from the supplied self-relative descriptor and preserves the existing ones. The result must still have an owner; a merge that would leave the descriptor ownerless is EINVAL. A null group SID stays valid.

Enlisting a descriptor change in a transaction gives it atomicity with the rest of the transaction's operations. It does not make it layer-qualified: the change is still a direct mutation on the key, is still not reverted by deleting a layer, and is simply not applied at all if the transaction aborts.

5.4.4 Audit

Peios / Advanced Peios / PKM / LCS / Security

LCS emits audit events through KMES. Seven events exist.

EventEmitted when
LCS_KEY_OPEN_AUDITA key open matched a SACL audit ACE.
LCS_BACKUP_STARTBefore REG_IOC_BACKUP reads any subtree data.
LCS_BACKUP_COMPLETEAfter a backup completes or fails after starting.
LCS_RESTORE_STARTBefore REG_IOC_RESTORE modifies any source state.
LCS_RESTORE_COMPLETEAfter a restore completes or fails after starting.
LCS_SOURCE_VALIDATION_FAILURELCS rejected malformed source data.
LCS_SELF_CONFIG_INVALIDLCS rejected an invalid self-configuration value.

Backup and restore are audited unconditionally, whatever the SACL on the target key says. They are privilege-gated bulk operations that bypass per-key access checks entirely, so the audit trail is the only record that they happened.

Every payload is a single MessagePack map with string keys. GUIDs are 16-byte binary values; SIDs are binary KACS encodings.

5.4.4.1 The caller summary #

Six of the seven carry a caller submap describing the effective token used for the operation. It has nine fields and no more: effective_token_guid, true_token_guid, process_guid and user_sid, then authentication_id, token_id, token_type, impersonation_level and integrity_level.

The bound is deliberate. Group lists, privilege arrays, claims and default DACLs are unbounded and are never included. The summary carries enough to correlate an event with a caller, and nothing that could make one event arbitrarily large. A primary token reports an impersonation level of 0.

5.4.4.2 Key opens #

LCS_KEY_OPEN_AUDIT carries the caller summary, the key GUID, the requested and granted access masks, the decision (allowed or denied) and sacl_match_flags — bit 0 for a success-audit match, bit 1 for a failure-audit match, no other bits.

granted_access is forced to zero on a denial, and that is enforced rather than merely intended: a denied event carrying a non-zero granted mask is rejected as a malformed payload. requested_access is the mask after registry generic mapping, with MAXIMUM_ALLOWED re-added if the caller asked for it.

SACL evaluation follows the KACS AccessCheck algorithm — the SACL is evaluated alongside the DACL, not separately. Reading or modifying a SACL requires ACCESS_SYSTEM_SECURITY, which is itself gated by SeSecurityPrivilege.

A request of MAXIMUM_ALLOWED alone maps to a desired mask of zero, so AccessCheck's SACL walk matches each audit ACE against the granted mask instead. An audit ACE says "audit when someone gets this right", and with MAXIMUM_ALLOWED they did get it. Such an open therefore always audits as a success, which is correct: MAXIMUM_ALLOWED returns whatever is available and never fails, so a failure ACE has nothing to record. An ACE naming a right the caller did not receive still does not match.

5.4.4.3 Source validation failures #

LCS_SOURCE_VALIDATION_FAILURE carries the source slot identifier and then, where each is known, the hive name, the RSI request id, the operation code and the key GUID. The last field, validation_class, names what was wrong. There are twelve:

malformed_security_descriptor, malformed_layer_name, unknown_rsi_status_code, future_sequence_number, duplicate_winning_sequence_tie, malformed_layer_metadata_security_descriptor, malformed_key_name, malformed_value_name, malformed_response_payload, malformed_key_metadata, malformed_value_payload, malformed_delete_layer_orphan_list.

The three name classes are field-specific: layer-name fields, key component or child-name fields, and value-name fields respectively. The structural classes cover a response whose operation-specific payload has the wrong shape or trailing bytes; a lookup or enumeration whose metadata block is incomplete, duplicated, unreferenced or nil; a value payload with an invalid type, a tombstone/data mismatch or oversized data; and an invalid orphan GUID array from RSI_DELETE_LAYER.

5.4.4.4 Configuration #

LCS_SELF_CONFIG_INVALID carries the parent path and value name of the offending parameter, the expected type and numeric range, what was actually received — one of missing, wrong_type or dword_out_of_range, with the actual type or value where applicable — and the value LCS retained instead.

Because missing counts as invalid, a first boot before seed restore emits one of these per parameter on each refresh: nineteen events against an empty Registry\ key. That is correct and expected, but it is a noticeable share of the boot audit stream.

5.4.4.5 What happens when emission fails #

The policy differs per event, and the differences are the point.

  • LCS_KEY_OPEN_AUDIT. If LCS cannot construct a valid payload — corrupt internal state, allocation failure, anything on the LCS side — the open fails with EIO and no key fd is published. If the payload is valid but KMES cannot retain the event — unavailable, ring drops, capacity pressure, no consumer — the access decision and the fd publication are unaffected. Loss accounting is KMES's problem.
  • LCS_BACKUP_START, LCS_RESTORE_START. Emission failure returns EIO and the operation does not start. Nothing is read and nothing is written.
  • LCS_BACKUP_COMPLETE, LCS_RESTORE_COMPLETE. The operation has already finished. Emission is attempted; failure does not change the result.
  • LCS_SOURCE_VALIDATION_FAILURE. The triggering operation is already failing with EIO. Emission is attempted; failure does not change that.
  • LCS_SELF_CONFIG_INVALID. The invalid value has already been ignored and the previous known-good value retained. Emission is attempted; failure leaves the retained configuration in force.

The rule underneath all five: an audit failure blocks an operation only where the audit record is the point of the operation being permitted. A privileged bulk export whose start could not be recorded does not happen. A key open whose decision could not be recorded does not happen. Everything downstream of an already-determined outcome records what it can.

LCS constructs the payload and attempts to enqueue it before continuing past the audit point. It never waits for a userspace consumer to observe or retain the event.

5.5.1 The Fd Model

Peios / Advanced Peios / PKM / LCS / The Syscall Interface

LCS uses a hybrid syscall/ioctl interface, the same shape KACS uses within PKM.

Syscalls create file descriptors: opening a key, creating a key, beginning a transaction. There are three, numbered 1100 to 1102 in the PKM range. Ioctls operate on a descriptor that already exists — eighteen of them, all under type byte 'R'. close() releases both kinds of fd through the ordinary fd lifecycle.

5.5.1.1 Key fds #

A key fd is an anonymous inode created with O_CLOEXEC, holding:

FieldDescription
Source and key GUIDThe identity of the opened key.
Granted access maskComputed once by AccessCheck at open. Immutable.
Resolved pathAfter symlink resolution and CurrentUser\ rewriting.
Ancestor chainThe GUID at each path component from the hive root down. Captured during the open walk, used for subtree watch dispatch (§5.6.3).
Watch stateArmed or not, filter, subtree flag, pending event queue.

Key fds behave like any other fd: close(), close-on-exec, poll/epoll, and passing over Unix sockets with SCM_RIGHTS. That last one is what makes them capabilities (§5.4.1).

5.5.1.2 Open-time checking #

A caller names a desired access mask, and AccessCheck evaluates it against the key's Security Descriptor. All of it is granted or the open fails with EACCES. MAXIMUM_ALLOWED is the only way to ask for whatever is available.

The granted mask lives on the fd, and every subsequent ioctl is a bitmask test against it — not a fresh AccessCheck, and not a fresh read of the descriptor.

Opening relative to a parent fd skips both path parsing and AccessCheck for the parent portion. The caller already proved its access when it obtained the parent fd, and this is the ordinary way to walk a subtree.

5.5.1.3 Transaction fds #

A transaction fd is an anonymous inode holding a transaction id and, once bound, its source and hive. Transaction lifetime is fd lifetime: closing without committing aborts (§5.7.1).

5.5.1.4 Reserved fields #

Every syscall and ioctl argument structure uses natural C layout with fixed-width fields and explicit padding. Nothing is packed.

Fields named _pad, and anything else described as reserved, are ABI extension points. A caller must set them to zero, and a non-zero reserved or padding field fails the operation with EINVAL — before source dispatch, before transaction enlistment, before sequence allocation, and before any output is copied.

In the other direction, LCS zeroes every reserved and padding byte of an output structure or a watch event before copying it to userspace.

Flags fields carry only the bits defined for them. An unknown or reserved flag bit is EINVAL unless a specific field says otherwise.

The point of all this is that a future version can give a reserved field meaning without an older kernel having silently accepted a value it did not understand.

5.5.1.5 Strings #

Strings in ioctl structures are length-delimited, not null-terminated. Each is a (len, ptr) pair where len is a byte count and ptr is a u64 userspace address. LCS reads exactly len bytes. A terminator is neither required nor expected, and one included in the length is a null byte and therefore invalid.

Syscall paths are the exception: they arrive as null-terminated C strings and have the terminator stripped before validation (§5.2.8).

5.5.1.6 Variable-size output buffers #

Six ioctls return variable-size data — REG_IOC_QUERY_VALUE, QUERY_VALUES_BATCH, ENUM_VALUES, ENUM_SUBKEYS, QUERY_KEY_INFO and GET_SECURITY — and all six use one convention.

For each output buffer described by (length, pointer):

  • length 0 is a size probe, whether the pointer is null or not. The pointer is not dereferenced.
  • length greater than 0 requires a non-null pointer writable for that many bytes, or the ioctl returns EFAULT.

If any output buffer is too small the ioctl returns ERANGE and writes every required size it can determine, not just the first one that failed — so a caller with two undersized buffers learns both sizes from one call.

On ERANGE, output buffers are not partially filled; their contents are unspecified. Output scalar metadata is meaningful only on success, unless an ioctl explicitly documents a field as carrying a required size or count on ERANGE.

Input pointer faults return EFAULT, validated before source dispatch wherever that is possible.

5.5.2 Syscalls

Peios / Advanced Peios / PKM / LCS / The Syscall Interface

5.5.2.1 reg_open_key (1100) #

int reg_open_key(int parent_fd, const char __user *path,
                 u32 desired_access, u32 flags);

Opens an existing key. Fails if it does not exist after layer resolution.

ParameterDescription
parent_fdAn open key fd to resolve relative to, or -1 for an absolute path. No AccessCheck is performed on the parent.
pathA null-terminated registry path — absolute with a hive prefix when parent_fd is -1, relative to the parent key otherwise.
desired_accessRequested rights, raw generic bits, MAXIMUM_ALLOWED, or a combination (§5.4.2). Zero and unknown bits are EINVAL.
flagsREG_OPEN_LINK (0x01) opens a symlink key rather than following it. Every other bit is reserved and must be zero.

The open proceeds as follows.

  1. Parse and canonicalise the path: normalise separators, reject empty components and a trailing separator, check the total length and each component length.
  2. Rewrite a leading CurrentUser\ to Users\<caller SID>\ — only for an absolute path, and only the first component (§5.2.1).
  3. Route. For an absolute path, look the hive name up, private hives before global ones. For a relative one, use the parent key's source and resolve from the parent's GUID.
  4. Walk the path component by component through RSI_LOOKUP, resolving each through the layer stack and following symlinks — except a final component when REG_OPEN_LINK is set. Collect the ancestor chain as you go.
  5. Run AccessCheck against the final key's descriptor.
  6. Publish an fd holding the key GUID, the granted mask, the resolved path and the ancestor chain.

If the path traversed a symlink, the fd stores the resolved path and ancestor chain. It refers to the target object.

ErrnoCondition
ENOENTThe key does not exist after layer resolution.
EACCESAccessCheck did not grant everything requested.
EINVALMalformed path; zero or unknown desired_access bits; a symlink whose effective default value is not REG_LINK; maximum depth exceeded.
ELOOPSymlink depth limit exceeded.
ENAMETOOLONGA component or the total path is too long.
ETIMEDOUTThe source did not answer within RequestTimeoutMs.
EIOThe source failed or is unavailable.
ENOMEMKernel allocation failure.

5.5.2.2 reg_create_key (1101) #

int reg_create_key(const struct reg_create_key_args __user *args);

Opens the key if it exists after layer resolution, creates it with an inherited descriptor if not, and reports which happened.

FieldDescription
parent_fdAs reg_open_key.
path_ptrPointer to a null-terminated path.
desired_accessAs reg_open_key.
flagsREG_OPTION_VOLATILE (0x01), REG_OPTION_CREATE_LINK (0x02). Other bits reserved.
layer_ptrPointer to a null-terminated layer name for creation, or null for the base layer. Ignored if the key already exists.
txn_fdA transaction fd, or -1. A non-negative value makes creation a mutating operation that binds or reuses that transaction.
disposition_ptrReceives REG_CREATED_NEW (1) or REG_OPENED_EXISTING (2). May be null.
_pad0, _pad1Reserved; must be zero.

If the key exists, this behaves as reg_open_key, the layer parameter is ignored, and the disposition is REG_OPENED_EXISTING.

If it does not:

  1. Resolve the parent, which must exist. Check that the parent's depth plus one is within MaxKeyDepth.
  2. AccessCheck the parent for KEY_CREATE_SUB_KEY.
  3. Perform layer write authorization against the target layer's metadata key (§5.3.4).
  4. Mint a fresh UUIDv4 GUID.
  5. Compute the new key's descriptor from the parent's, through KACS.
  6. Create the path entry with a new sequence number, then the key record (§5.2.5).
  7. AccessCheck the new key's inherited descriptor against desired_access — an inherited descriptor may not grant everything the creator asked for.
  8. Publish the fd with the granted mask and disposition REG_CREATED_NEW.

Intermediate path components are not auto-created. Only the final one is.

Races. If two callers race, one creates and the other observes the key as existing. RSI_ALREADY_EXISTS on the path entry is retried as an open, reporting REG_OPENED_EXISTING, and EEXIST never reaches userspace from this syscall. RSI_ALREADY_EXISTS on the key record, for a GUID LCS has just minted, is not a race — the source and the kernel disagree about what exists — and fails closed with EIO (§5.2.3).

ErrnoCondition, in addition to reg_open_key's
ENOENTThe parent does not exist, or the named layer is not in the layer table.
EACCESThe parent denied KEY_CREATE_SUB_KEY, the inherited descriptor denied the requested access, or layer write authorization failed.
EPERMREG_OPTION_CREATE_LINK without KEY_CREATE_LINK on the parent, or without SeTcbPrivilege or Administrators.
ENOSPCThe per-value layer cap was exceeded.
EINVALA non-volatile key under a volatile parent; a non-zero reserved field.

5.5.2.3 reg_begin_transaction (1102) #

int reg_begin_transaction(void);

Allocates a transaction id, publishes a transaction fd in state REG_TXN_ACTIVE_UNBOUND, and starts the lifetime timer. It contacts no source and chooses none (§5.7.1). It can fail only with ENOMEM, EOVERFLOW on transaction id exhaustion, or EINVAL.

5.5.3 Key Ioctls

Peios / Advanced Peios / PKM / LCS / The Syscall Interface

Sixteen ioctls act on a key fd. Each checks the fd's granted mask first and returns EACCES without contacting the source if the required right is absent (§5.4.2). Numbers, directions and argument layouts are in §5.A.

Every mutating ioctl accepts an optional transaction fd. So do the four read ioctls — a read inside a bound transaction sees the transaction's own uncommitted writes. A transaction bound to a different hive is EXDEV; a committed, aborted or closed one is EINVAL; a timed-out one is ETIMEDOUT; one whose source went Down is EIO. An unbound transaction fd does not bind on a read, which is simply performed non-transactionally.

Every mutating ioctl that names a layer performs layer write authorization first (§5.3.4).

5.5.3.1 Common errors #

ErrnoCondition
EACCESThe granted mask lacks the required right, or layer write authorization failed.
EFAULTAn input pointer is invalid, or a non-zero-length output buffer pointer is null or unwritable.
EIOThe source is unavailable, failed, or the transaction's source went Down.
ETIMEDOUTThe source did not answer within RequestTimeoutMs, or the transaction had timed out.
ENOMEMKernel allocation failure.
EXDEVThe transaction is bound to a different hive.
ENOENTThe named layer is not in the layer table.
EINVALA non-zero reserved or padding field.
ENOTTYAn ioctl number this fd type does not implement.

5.5.3.2 Reading #

REG_IOC_QUERY_VALUE returns the effective value at a name: its type, data, the sequence number of the winning entry, and the canonical name of the winning layer — which is the layer table's spelling, not whatever string the source stored. A base-layer entry therefore reports base. A winning tombstone or blanket, or no entries at all, is ENOENT.

REG_IOC_QUERY_VALUES_BATCH returns every effective value on the key in one call: name, type and data for each, with tombstoned and blanket-masked values omitted. This is the call to use when you want them all (§5.3.6).

REG_IOC_ENUM_VALUES and REG_IOC_ENUM_SUBKEYS return the entry at an index, and ENOENT past the end. Both re-resolve the full set on every call, and enumeration order is undefined — see §5.3.6 for why the batch call usually wins.

ENUM_SUBKEYS performs no per-child access check. It returns every visible child with its last write time, subkey count and value count. The caller learns names and must open each child separately to read anything (§5.4.2).

REG_IOC_QUERY_KEY_INFO returns the key's name, last write time, subkey and value counts, the maximum subkey name length, maximum value name length and maximum value data size, the descriptor size, the volatile and symlink flags, and the hive generation number. It requires READ_CONTROL.

It is declared _IOWR, which is what it does: it reads the caller's output-buffer fields out of the argument structure before writing it back. It was declared _IOR, and because the direction bits are part of the encoded ioctl number and the kernel dispatches on the whole encoded value, correcting that was an ABI break rather than a relabelling. A binary built against the old constant gets ENOTTY from a kernel carrying the new one.

5.5.3.2.1 The hive generation number #

A monotonic per-hive change epoch owned by the kernel. It is not a sequence number and must not be read as one: sources neither report it nor persist it (§5.3.7).

It is incremented once per committed mutation, or once per committed transaction per affected hive, however many operations that transaction contained. Its baseline is initialised from the source's reported maximum sequence at registration so that observed values are monotonic relative to persisted entries; saturating at U64_MAX is EOVERFLOW.

It is exposed on every key because it is cheap and because a watcher that receives OVERFLOW can compare the generation it last saw with the current one and skip the recovery re-read entirely if nothing has committed (§5.6.4).

Layer operations produce a single generation increment covering the metadata key deletion, RSI_DELETE_LAYER, the recomputation and the resulting watch effects. There is no generation at which the metadata key is gone but the layer's entries are still resolving. An operation affecting several hives increments each independently.

5.5.3.3 Writing #

REG_IOC_SET_VALUE writes a value entry in a layer. LCS allocates a sequence number and tells the source to store (key GUID, value name, layer) → (type, data, sequence), then updates the key's last write time.

Writing type REG_TOMBSTONE is the explicit tombstone operation and requires zero-length data (§5.2.6).

A non-zero expected_sequence makes the write conditional. It is passed to the source, which atomically verifies that the layer's own current entry carries that sequence number before writing, and answers RSI_CAS_FAILED if not — which LCS returns as EAGAIN. There is no kernel-side query-then-write: the check is the source's, and it is atomic there or nowhere.

The condition is evaluated against the layer's own entry, not against the effective value. A higher-precedence layer overriding a value is not a lost update; it is the layer system working.

Additional errors: EINVAL for an unknown type or a tombstone with data; EAGAIN for a failed conditional write; ENOSPC for the layer cap or oversized data; ENAMETOOLONG for the value name; EPERM for a Precedence above 0 without SeTcbPrivilege (§5.3.4).

REG_IOC_DELETE_VALUE removes one layer's entry at a value name — whether that entry was a value or a tombstone. Removing a layer's opinion lets lower-precedence layers surface.

The operation is meant to be idempotent, and it is idempotent as far as a caller sees, provided the source answers RSI_OK for an entry that was not there. LCS does not mask a source's RSI_NOT_FOUND; that propagates as ENOENT. Idempotency is a source obligation, not a kernel behaviour.

REG_IOC_BLANKET_TOMBSTONE sets or removes a blanket tombstone for a layer on this key (§5.2.7). A new sequence number is assigned for dispatch ordering, and watch events are generated for every value whose effective state changed.

REG_IOC_DELETE_KEY removes this key's path entry from a layer. The parent GUID comes from the fd's ancestor chain and the child name from the last component of its resolved path. ENOTEMPTY if the key has visible children; EINVAL on a hive root (§5.2.9).

REG_IOC_HIDE_KEY creates a HIDDEN path entry at the same place instead, masking lower-precedence entries. The caller must hold an open fd to the key, which is how it proved access to it. EINVAL on a hive root.

5.5.3.4 Security #

REG_IOC_GET_SECURITY and REG_IOC_SET_SECURITY read and merge Security Descriptor components, selected by a security_info bitmask. §5.4.3 covers the rights, the merge, and the validation.

5.5.3.5 Watches, durability, bulk #

REG_IOC_NOTIFY arms, re-arms or disarms a watch (§5.6.1).

REG_IOC_FLUSH tells the source to persist pending writes for this key's hive, and returns when persistence is confirmed. The hive name comes from the first component of the fd's resolved path. It requires KEY_SET_VALUE (§5.4.2).

REG_IOC_BACKUP and REG_IOC_RESTORE export and replace a subtree. They are privilege-gated with no per-key access check, and they are covered in §5.9.

5.5.4 Transaction Ioctls

Peios / Advanced Peios / PKM / LCS / The Syscall Interface

Two ioctls act on a transaction fd. Everything else on that fd is ENOTTY — there are no savepoint or nesting operations to have.

The common key fd error table does not apply here.

5.5.4.1 REG_IOC_COMMIT #

Commits every operation in the transaction. §5.7.3 covers what happens in each case; in summary:

ErrnoCondition
—Success. The object becomes COMMITTED, poll waiters are woken, watch events are delivered, and further use of the fd returns EINVAL.
EINVALAlready committed, or never bound to a source.
EBUSYThe source could not take the write lock. The transaction stays ACTIVE_BOUND; retry.
EIOThe source failed to commit. The transaction stays ACTIVE_BOUND.
ETIMEDOUTThe transaction timed out before the commit completed.

EBUSY and EIO both leave the transaction usable: the mutation log is retained, no events are emitted, and poll waiters are not woken as though it had become terminal. The caller retries or closes the fd to abort.

5.5.4.2 REG_IOC_TXN_STATUS #

Reports the transaction's state and a terminal errno into a reg_txn_status_args. It reads nothing from the caller, consistent with its _IOR direction, and can fail only with EFAULT on an unwritable output pointer.

Stateterminal_errno
REG_TXN_ACTIVE_UNBOUND0
REG_TXN_ACTIVE_BOUND0
REG_TXN_COMMITTED0
REG_TXN_ABORTEDEINVAL
REG_TXN_TIMED_OUTETIMEDOUT
REG_TXN_SOURCE_DOWNEIO

For the three failed terminal states, terminal_errno is the errno a further operation on the fd would return. For COMMITTED it is not: the transaction reports 0, but using the fd again returns EINVAL.

This ioctl is what makes a poll wakeup useful. A terminal transition reports POLLERR | POLLHUP, which says only that something terminal happened; the status call says what.

5.5.5 The Error Model

Peios / Advanced Peios / PKM / LCS / The Syscall Interface

Syscalls and ioctls follow the ordinary Linux convention: -1 and errno. The errno is the whole interface — source-specific error detail is never surfaced to a caller.

ErrnoWhat it means here
ENOENTA key or value does not exist after layer resolution; an enumeration index is past the end; a named layer is not in the layer table; a namespace operation on an orphaned key.
EACCESAccessCheck denied a right, the fd's granted mask lacks it, or layer write authorization failed.
EINVALAn invalid path or argument; a non-zero reserved or padding field; a zero or unknown-bit desired_access; an operation on a committed, aborted or closed transaction; a symlink target that is not REG_LINK; maximum key depth exceeded; delete or hide on a hive root.
EFAULTAn invalid userspace pointer. A zero-length output probe ignores its pointer; a non-zero length with a null or unwritable pointer does not.
ENAMETOOLONGA key or value name exceeds MaxPathComponentLength, or a path exceeds MaxTotalPathLength.
ELOOPThe symlink resolution depth limit was exceeded.
ETIMEDOUTThe source did not answer within RequestTimeoutMs, or a timed-out transaction fd was used.
EIOThe source failed, is unavailable, returned malformed data, or a transaction's bound source went Down.
ENOMEMKernel allocation failure.
ENOSPCA layer cap was exceeded, or value data exceeds MaxValueSize.
ENOTEMPTYA key with visible children cannot be deleted.
EXDEVAn operation targeted a different hive from the one its transaction is bound to.
EPERMA privilege the caller does not hold: symlink creation, creating or raising a layer above precedence 0, backup, restore.
EAGAINA conditional write failed — the layer entry's sequence did not match. Re-read and retry.
EBUSYA commit could not take the source's write lock, or MaxBoundTransactionsPerSource or MaxReadOnlyTransactionsPerSource was exceeded.
ERANGEAn output buffer is too small. Every determinable required size is written; buffers are not partially filled. Retry with larger ones.
EEXISTA path entry or key already exists at the source.
ENOTSUPThe source does not support the transaction mode being requested.
EBADFAn fd argument is not valid or not open in the required mode — a backup output fd that is not writable, a restore input fd that is not readable.
EOVERFLOWA counter cannot advance: a source reported a persisted sequence number too large to allocate past, a hive generation saturated, restore sequence remapping would overflow, or transaction ids are exhausted.
ESTALEA source re-registration tried to resume a Down slot with a mismatched hive identity.

5.5.5.1 Two errnos worth reading closely #

ETIMEDOUT means "may or may not have happened." If the deadline expired before an in-flight RSI slot was reserved, no request was sent at all. If it expired after dispatch, the source may still apply the operation and answer later, and LCS will apply the kernel-side effects when it does (§5.8.5). A caller that needs certainty checks state before retrying. The same applies to a transaction commit.

EEXIST is rarer than it looks. It never comes out of reg_create_key, which retries a losing race as an open (§5.5.2). From REG_IOC_RESTORE it arrives only by propagation: the source answers RSI_ALREADY_EXISTS while the stream is being replayed. There is no kernel-side pre-check that a non-root GUID in a backup already exists outside the subtree being replaced, so a collision is detected during the restore rather than before it — inside the restore transaction, which then rolls back (§5.9.3). Its other source is source registration, where a hive identity collides with an Active slot; a collision with a Down slot yields EINVAL or ESTALE instead (§5.8.2).

5.6.1 The Watch Model

Peios / Advanced Peios / PKM / LCS / Watches

A watch is a persistent subscription to changes on an open key. It follows the inotify model rather than the Windows one: once armed, it stays armed until the fd closes, and events keep arriving without re-registration. RegNotifyChangeKeyValue is single-shot, and the window between receiving a notification and re-registering is a window in which changes are missed; a persistent watch has no such window.

A watch is armed by REG_IOC_NOTIFY on a key fd, which requires KEY_NOTIFY in the fd's granted mask. Arming takes a filter — a bitmask of event categories — and a subtree flag. After arming, the fd is pollable: EPOLLIN reports pending events, and read() returns structured records.

Each fd carries at most one watch. Arming an already-armed fd replaces the filter and the subtree setting and leaves queued events in place. Arming with a filter of zero disarms: the watch is removed and every pending event is discarded. To watch one key under two different filters, open it twice.

Arming a watch on a key that is already orphaned fails with ENOENT. A watch armed before the key was orphaned stays armed (§5.2.9).

5.6.1.1 What a watch observes #

Events describe changes to effective state, not to layer mechanics. A watcher sees that a value changed; it does not see which layer won, or that a layer was deleted. Removing a layer whose value was on top produces VALUE_SET for the value that surfaced underneath. Removing a hiding entry that was concealing a lower-precedence key produces SUBKEY_CREATED. The layer system is not visible through a watch at all.

The events are computed by diffing the effective state before the mutation against the effective state after it, which is what makes this true by construction rather than by careful case analysis. A change that replaces the key at a child name with a different key object — different GUID, same name — produces SUBKEY_DELETED followed by SUBKEY_CREATED, because that is what the diff says happened.

Only committed state is observable. Operations inside an uncommitted transaction produce nothing; the whole set fires at commit (§5.6.4).

5.6.1.2 Event types #

EventCodeName fieldMeaning
REG_WATCH_VALUE_SET1value nameThe effective value at this name changed or appeared.
REG_WATCH_VALUE_DELETED2value nameThe effective value at this name disappeared.
REG_WATCH_SUBKEY_CREATED3subkey nameA child key became visible.
REG_WATCH_SUBKEY_DELETED4subkey nameA child key became invisible.
REG_WATCH_SD_CHANGED5emptyThe watched key's Security Descriptor was modified.
REG_WATCH_KEY_DELETED6emptyThe watched key itself became invisible.
REG_WATCH_OVERFLOW7emptyEvents were dropped; re-read to recover.

VALUE_SET fires when a value is written, when a tombstone or blanket tombstone is removed and a lower-precedence value surfaces, and when a layer deletion makes a different value effective. VALUE_DELETED fires when the last entry for a name goes away, when a tombstone masks every entry, and when a blanket tombstone masks this name.

SUBKEY_CREATED and SUBKEY_DELETED cover both halves of the naming model: a path entry appearing or being removed, and a hiding entry being removed or created.

The three no-name events carry no name, and that is enforced: a record constructed with a name for SD_CHANGED, KEY_DELETED or OVERFLOW is rejected rather than emitted.

5.6.1.3 Filters #

The filter selects event categories, not individual event types.

Filter bitValueAdmits
REG_NOTIFY_VALUE0x01VALUE_SET, VALUE_DELETED
REG_NOTIFY_SUBKEY0x02SUBKEY_CREATED, SUBKEY_DELETED
REG_NOTIFY_SD0x04SD_CHANGED
REG_NOTIFY_ALL0x07all three of the above

KEY_DELETED and OVERFLOW are delivered unconditionally. They are not in any category and no filter suppresses them: the first tells a watcher its key is gone, and the second tells it that what it has been told is incomplete. Neither is something a watcher can usefully opt out of.

A filter containing an undefined bit is rejected, as is a subtree flag other than 0 or 1 or a non-zero padding byte in the argument structure.

5.6.1.4 Blanket tombstones #

A watcher never learns that a blanket tombstone exists. When one is written, LCS works out which values it newly masks and emits one VALUE_DELETED per name; when one is removed, one VALUE_SET per name that became visible. The per-value view is the only view.

5.6.2 Event Records

Peios / Advanced Peios / PKM / LCS / Watches

Events are read from the key fd with read(). A single call returns as many complete events as fit in the caller's buffer; an event is never split across two calls. If the buffer cannot hold even the first queued event, read() fails with EINVAL — the buffer is too small to make progress, and the caller has to try again with a larger one. On an armed fd with an empty queue, read() blocks, or returns EAGAIN under O_NONBLOCK.

Only events that were copied out in full are dequeued.

5.6.2.1 Layout #

Every record begins with the same four fields.

OffsetSizeField
04total_len
42event_type
62name_len
8name_lenname, UTF-8

All integers are little-endian. total_len is the whole record including this header; it is how a consumer advances to the next event, and it is the only safe way to do so.

A subtree watch's records carry additional fields after the name, locating the key the change happened on relative to the watched key:

SizeField
2path_depth
2 + n eachpath_components: len (u16) then that many UTF-8 bytes

path_depth is the number of components from the watched key down to the changed key; zero means the change was on the watched key itself. The components are length-prefixed rather than joined with a separator, because registry names can contain any Unicode character and value names can contain backslashes — a concatenated path string would be ambiguous.

The header offsets are ABI, named in uapi/pkm/lcs.h and listed in §5.A.

5.6.2.2 Not every record on a subtree watch has a path #

OVERFLOW records are emitted in the bare eight-byte form, on every watch, whether or not that watch is a subtree watch. KEY_DELETED is not: a subtree watcher receives it in the subtree form, with a path_depth of its own.

A consumer of a subtree watch therefore cannot assume the subtree fields are present on every record it reads. total_len is the cursor; path_depth is read only when the record is long enough to hold it.

5.6.2.3 Length limits #

name_len and each component length are 16-bit. If an event's name or one of its path components is too long to be represented, LCS does not emit a truncated or malformed record: it substitutes or preserves an OVERFLOW for that watcher instead, which is a statement the consumer already knows how to act on.

5.6.2.4 Forward compatibility #

Future versions may append fields after path_components. An existing consumer skips them, because it advances by total_len; a newer one compares total_len against what it has parsed to discover whether the optional fields are present. This is the whole extension mechanism, and it is why the length field comes first.

5.6.3 Dispatch

Peios / Advanced Peios / PKM / LCS / Watches

Dispatch answers one question: given a mutation on a key, which armed watches hear about it? The answer is computed from object identity and from ancestry captured at open time, never from a path string resolved at the moment of the event.

5.6.3.1 A watch is bound to an object #

A watch is registered against the GUID on the fd it was armed through. It fires for changes to that object. If a layer change causes a different key to become visible at the same path, the watch does not move: the new key is a different object with a different GUID, and nothing about the old watch refers to a name.

To follow the path rather than the object, a process detects the change — through KEY_DELETED on the old object, or a subtree watch on the parent — re-opens the path, and arms a new watch. This is the fd model applied consistently: an fd is a capability bound to one identity.

The converse also holds. If a watched key is hidden, KEY_DELETED is delivered, but the watch is not removed. Should the hiding layer later be deleted, the key reappears at its original path with the same GUID, and events resume. There is no re-emergence event; the watcher simply observes the key being active again.

5.6.3.2 The ancestor chain #

Subtree dispatch needs to know where a key sits in the tree, and it learns that once, at open.

Resolving a path walks it component by component, and the GUID at each level is retained on the resulting fd as the ancestor chain — root GUID through parent GUID to the key itself. It costs nothing extra: the walk had to resolve those components anyway. A relative open copies the parent fd's chain and extends it with the components the relative walk resolved. An open that followed a symlink records the chain of the resolved path, so the fd's ancestry reflects the target's real position in the tree, not the link's.

Dispatch uses that captured chain and never re-resolves it. If an ancestor in the chain is later hidden, deleted, orphaned, or replaced at the same path by a different key, watches on the original ancestor GUID still receive subtree events from descendants mutated through fds opened through that chain. Watches on a new key that later appears at the same path receive nothing from the old one.

5.6.3.3 The algorithm #

Two structures back dispatch. The watch map is a hash map from GUID to the watchers armed on it. The subtree watch set is a refcounted hash set of the GUIDs that have at least one subtree watch, maintained as watches are armed, re-armed, disarmed and closed.

For a mutation on key B, with B's ancestor chain in hand:

  1. Look up B's GUID in the watch map and queue the event, with path_depth 0, to every watcher whose filter admits it.
  2. Walk the ancestor chain outward from B's parent. At each ancestor, skip it unless it is in the subtree watch set — that test is what makes the walk cheap. For an ancestor that is, queue the event to each of its watchers that armed with the subtree flag and whose filter admits it, with the path from that ancestor down to B.

The cost is O(depth) hash lookups, with no RSI round trips, no trie and no string comparison. The path components handed to a subtree watcher are sliced out of the resolved path already stored on the mutating fd.

Dispatch runs under a single global registry lock, so it is serialised across the whole system rather than per hive or per source.

5.6.3.4 Depth #

MaxSubtreeWatchDepth bounds how far below a watched key a subtree watch is told about. The default is 0, meaning unlimited. A non-zero value suppresses events whose path is longer than it, limiting both noise and dispatch cost for a watch armed high in a tree.

The limit applies to watches held by userspace. LCS's own internal watches are collected before the depth test and are not subject to it (§5.10.4).

5.6.3.5 Transaction batches #

Nothing inside an uncommitted transaction dispatches. At commit, and only when the source reports success, the transaction's mutation log is walked in operation order and the whole set of events is queued as one batch, under a single hold of the registry lock, so no other operation interleaves with it. An aborted, failed or timed-out transaction dispatches nothing and its log is released.

A large transaction — a role installation writing thousands of values — could otherwise fill a watcher's queue atomically. So the batch is counted per watcher first: any watcher whose share exceeds MaxTransactionWatchEventBurst, default 4096, receives exactly one OVERFLOW and none of its individual events. That OVERFLOW is queued ahead of the batch, so it arrives before whatever else that watcher is still owed.

5.6.3.6 Recovery dispatch #

Some changes alter effective state for keys whose fds nobody holds and whose descendants the kernel is retaining no context for. Deleting a layer, changing its precedence, enabling or disabling it, and restoring a subtree from a stream are all of this kind. Computing an exact diff would mean walking arbitrary parts of the tree through the source.

LCS does not attempt it. It increments the affected hive's generation number and then queues a no-name OVERFLOW to every armed watch on the affected source, which is the notification for those changes. The watcher re-reads, and can compare the generation number it last saw against the current one (§5.5.3) to tell whether it missed anything.

Two properties of this delivery are worth stating. It is object- semantic like every other dispatch — it walks the watch map, resolves no paths — and it bypasses the filter, because OVERFLOW always does. It does not disarm anything.

The scope is the source, not the hive. A source backing several hives delivers OVERFLOW to watches on all of them, including hives the operation did not touch. The generation counters are maintained per hive; the watch delivery is not.

For a restore, recovery is published only after the source commit succeeds. A restore that fails or aborts before commit emits nothing.

5.6.3.7 Source restart #

When a source disconnects, watches stay armed and nothing is delivered; operations needing the source return EIO. OVERFLOW arrives on re-registration, not on disconnect, which is the only ordering that works: a watcher told to re-read needs the source to be there when it does. Existing fds resume without re-opening, and no watcher has to re-arm.

5.6.4 Queues and Overflow

Peios / Advanced Peios / PKM / LCS / Watches

Each armed fd has its own event queue, bounded by NotificationQueueSize — default 256 events, configurable between 16 and 65536 (§5.10.3).

Delivery is best-effort. A watcher that reads promptly sees every change; one that falls behind is told that it has, rather than being given a partial history it cannot distinguish from a complete one.

5.6.4.1 What happens when the queue is full #

When an event arrives for a full queue and no OVERFLOW is present:

  1. The oldest queued event is dropped.
  2. An OVERFLOW record is queued in its place.
  3. The event that triggered this is discarded, not queued.

Once an OVERFLOW is in the queue, subsequent events queue normally: the oldest non-OVERFLOW event is dropped to make room and the new event is added, so the single OVERFLOW is preserved and the queue continues to carry the most recent history behind it.

A queue therefore holds at most one OVERFLOW at a time, and that is an enforced invariant rather than a convention — an attempt to queue a second is rejected as a bug.

5.6.4.2 What a watcher does about it #

OVERFLOW means the record it has is incomplete. There is no way to learn what was dropped, and no attempt is made to describe it. The watcher re-reads the watched key, and its subtree if the watch is a subtree watch, and continues from the state it finds. Events after the OVERFLOW are complete again.

REG_IOC_QUERY_KEY_INFO reports a per-hive generation number (§5.5.3) that makes this cheaper than it sounds: a watcher that recorded the generation at its last full read can compare it with the current one and skip the re-read entirely if nothing committed in between.

5.6.4.3 Memory #

There is no registry-specific global cap on watch memory, and none is needed. A watch costs at most NotificationQueueSize queued events, a watch requires an fd, and a process holds at most RLIMIT_NOFILE fds. The product of the two is the bound, and it is enforced by machinery that already exists.

The same reasoning covers open key state: per-fd overhead — GUID, granted mask, ancestor chain, watch state — multiplied by the fd limit.

LCS's own internal watches are outside this. They are delivered synchronously through a kernel callback rather than queued, so the queue limit does not apply to them (§5.10.4).

5.7.1 Scope and Lifetime

Peios / Advanced Peios / PKM / LCS / Transactions

A transaction groups registry operations so that they commit together or not at all. Installing a role writes service definitions, defaults and registry entries; a transaction is what makes that one event rather than a sequence of partially-applied ones.

reg_begin_transaction takes no arguments and returns a transaction fd. It contacts no source: it allocates an id, creates an anonymous inode, starts the lifetime timer, and returns. A transaction begins in the state REG_TXN_ACTIVE_UNBOUND.

5.7.1.1 Binding #

A transaction binds to a source on its first mutating operation. There are seven: writing a value, deleting a value entry, setting or removing a blanket tombstone, creating a key, deleting a key's path entry, hiding a key, and changing a Security Descriptor.

Reads never bind. A read passed an unbound transaction fd is sent to the source with a transaction id of zero and behaves as an ordinary non-transactional read.

Once bound, every operation on that fd must target the same hive. One that does not fails with EXDEV, and it fails in the kernel, before anything reaches a source — a source never sees a cross-hive operation inside a transaction. Cross-source atomicity would require two-phase commit and is not supported.

Binding identity is the pair of source and hive root GUID carried on the transaction object, which identifies the hive exactly.

5.7.1.2 Sources that do not support transactions #

Because reg_begin_transaction chooses no source, it cannot fail for lack of transaction support. The failure surfaces on the operation that would have bound: LCS sends RSI_BEGIN_TRANSACTION, the source answers RSI_TXN_NOT_SUPPORTED, and the operation returns ENOTSUP. The transaction stays ACTIVE_UNBOUND, its source binding untouched, and the caller can use the fd against a different hive.

There is no advance check of whether a source supports transactions. The answer comes from the source, on the attempt.

If the source is Down before the first bind, the binding operation fails EIO under the ordinary rules and the transaction likewise remains unbound. If a source goes Down after binding, the transaction becomes REG_TXN_SOURCE_DOWN (§5.8.5).

5.7.1.3 States #

StateValueMeaning
REG_TXN_ACTIVE_UNBOUND0Active, no source chosen.
REG_TXN_ACTIVE_BOUND1Active, bound to a source.
REG_TXN_COMMITTED2Commit completed.
REG_TXN_ABORTED3Explicitly or implicitly aborted.
REG_TXN_TIMED_OUT4The lifetime timer fired.
REG_TXN_SOURCE_DOWN5The bound source went Down.

REG_IOC_TXN_STATUS reports the state and a terminal_errno: 0 for COMMITTED, EINVAL for ABORTED, ETIMEDOUT for TIMED_OUT, EIO for SOURCE_DOWN, and 0 while active.

For COMMITTED, terminal_errno is not the errno that a further operation would return. A committed transaction reports 0, but using its fd again returns EINVAL.

5.7.1.4 Reaching a terminal state #

  • Commit. REG_IOC_COMMIT on the transaction fd. The source applies everything atomically, watch events fire, and the object becomes COMMITTED. Later use of the fd returns EINVAL. The fd should be closed.
  • Explicit abort. close() without committing. The source is told to discard, and the object becomes ABORTED during release. No events.
  • Implicit abort. Process death closes the fd, which aborts it. There are no orphaned transactions.
  • Timeout. The lifetime timer fires. See below.

The object stays addressable after reaching a terminal state, until the fd is closed. That is what makes REG_IOC_TXN_STATUS useful.

Transaction fds are pollable. Any terminal transition wakes poll waiters with POLLERR | POLLHUP. A caller that needs a race-free reason for the wakeup queries the status.

5.7.1.5 Timeout #

The lifetime timer starts when the fd is created — not at the first operation — and runs for TransactionTimeoutMs, default 30 seconds.

When it fires, the object becomes TIMED_OUT, poll waiters are woken, and further use of the fd returns ETIMEDOUT. If the transaction was bound and no commit is already in flight, RSI_ABORT_TRANSACTION is sent to the source. The fd is not removed from the caller's table; close() still releases it normally.

The timeout is not a convenience. Sources serialise writers — loregd does so through SQLite's WAL — so a stalled transaction blocks every other write to that source. The timer is what bounds the starvation window, and MaxBoundTransactionsPerSource (default 16) is what stops colluding processes from extending it indefinitely by binding fresh transactions at each timeout boundary. An operation that would bind past that cap returns EBUSY.

The cap is tested before the source is contacted, but after the transaction's mutation-log entry has been allocated; that entry is freed on the way out.

5.7.1.6 Constraints #

  • No nesting. Transactions are flat: no savepoints, no sub-transactions. The transaction fd accepts only REG_IOC_COMMIT and REG_IOC_TXN_STATUS; every other ioctl is ENOTTY.
  • One transaction per fd. A process may hold many transaction fds at once, but each is exactly one transaction.
  • Reads are permitted. A transaction is not write-only, which is what makes verify-then-write possible.

5.7.2 Isolation and the Mutation Log

Peios / Advanced Peios / PKM / LCS / Transactions

5.7.2.1 Read-your-own-writes #

Within a bound transaction, reads see the transaction's own uncommitted writes. LCS does not implement this; the source does. A read tagged with the transaction id is executed by the source inside its open transaction, which naturally includes the pending writes. No uncommitted registry data is cached in the kernel for the purpose of resolving reads.

Externally, only committed state is visible. A transaction's writes are invisible to other threads and processes until commit.

5.7.2.2 The mutation log #

LCS does keep something: a per-transaction mutation log, holding what the kernel needs to know after a successful commit and cannot ask the source for afterwards. Each accepted mutating operation records the affected key, value or layer name, its assigned sequence number, the ancestor chain for watch dispatch, and enough context to compute effective-state changes.

The log is not the source of truth for reads and it is not a rollback journal — the source's own transaction state is authoritative for uncommitted data. The log exists so that when the source says "yes", LCS can produce the hive generation updates and the watch events that correspond to what was just committed.

It is bounded at 4096 entries. That bound is a compile-time constant rather than one of the self-configuration parameters. Exceeding it fails the operation with ENOMEM.

An operation whose log entry cannot be allocated fails before it is sent to the source. That ordering is deliberate: an operation the source applied but the kernel cannot account for is exactly the state the log exists to prevent.

The log is released, with no events emitted, on explicit abort, on a lifetime timeout that fires before a commit is dispatched, on source-down cancellation, on a late commit error, and on source teardown while a post-dispatch commit response is still retained. It is retained across a post-dispatch commit timeout, because the source may still answer.

5.7.2.3 Sequence numbers #

A transactional mutation is assigned its sequence number when it is accepted into the transaction, not at commit. This preserves the order in which the caller performed the operations, which is what layer tiebreaking and watch ordering need.

If the transaction aborts, those numbers are simply never used. Gaps in the sequence space are normal and mean nothing (§5.3.7).

5.7.2.4 Layer precedence coherency #

Layer metadata lives in the registry, so a transaction can write to it — and that raises the question of whether a precedence change takes effect inside the transaction that made it. It does not.

Resolution always uses the published layer cache, which is refreshed only at commit. Reading the Precedence value back inside the transaction shows the new number, because that is an ordinary read-your-own-writes read from the source. Resolving any other value in the same transaction still uses the old precedence order. The two are consistent with each other and with the rule that a transaction's effects become real at commit.

The refresh is deferred to commit, dropped on abort, and performed before REG_IOC_COMMIT returns on success.

5.7.2.5 Conflicts #

Transactions are atomic. They are not conflict-detecting.

If two transactions write the same value, both commits succeed and the one that committed second wins, because its write carries the higher sequence number. There is no read set, no version check, and no per-key validation anywhere in the commit path.

Serialising concurrent writers is the source's responsibility, not LCS's. The RSI requires only that commits are atomic, that writes within a transaction are ordered, and that concurrent commits are serialised.

Conditional writes are the mechanism for the cases where losing an update matters. REG_IOC_SET_VALUE takes an expected_sequence, the source verifies it atomically against the layer's own entry, and a mismatch returns EAGAIN (§5.5.3). That is a per-operation check, not a transaction-level one, and it is deliberately scoped to a single layer: a higher-precedence layer overriding a value is not a conflict, it is the layer system working.

5.7.3 Commit and Failure

Peios / Advanced Peios / PKM / LCS / Transactions

REG_IOC_COMMIT marks the transaction as having a commit in flight and sends RSI_COMMIT_TRANSACTION. What happens next depends on the answer.

5.7.3.1 Success #

The source's RSI_OK triggers, in order: the layer metadata cache refresh for any layer names the transaction touched, the hive generation increment, orphan tracking for keys that lost their last path entry, and the watch event batch derived from the mutation log. The object then becomes COMMITTED, the log is released, poll waiters are woken, and the ioctl returns 0.

The hive generation is incremented once per committed transaction per affected hive, however many operations the transaction contained.

5.7.3.2 Failure that leaves the transaction open #

A source that cannot take the write lock answers RSI_TXN_BUSY, which becomes EBUSY; a synchronous commit failure becomes EIO. In both cases the transaction stays ACTIVE_BOUND:

  • the mutation log is retained;
  • no watch events are emitted;
  • poll waiters are not woken as though the transaction had become terminal.

The in-flight marker is cleared, so the caller may simply retry REG_IOC_COMMIT, or close the fd to abort. Nothing has been lost.

5.7.3.3 Timeout after dispatch #

If the request timeout expires after the commit was dispatched, the caller receives ETIMEDOUT and the object becomes TIMED_OUT, but the mutation log is kept and the request record stays in the source's in-flight table. The source may still answer.

ETIMEDOUT on a commit means may or may not have committed. A caller that needs certainty checks state before retrying.

A late RSI_OK applies the full set of kernel-side effects from the retained log — the same generation updates and the same watch events an on-time commit would have produced. Watchers may therefore observe the effects of a transaction whose caller was told it timed out. A late error releases the log with no effects.

The transaction object does not move to COMMITTED when a late success arrives. It stays TIMED_OUT, so a caller that queries REG_IOC_TXN_STATUS afterwards is told TIMED_OUT with a terminal_errno of ETIMEDOUT, even though the writes are durable and the watch events have gone out. The state reflects what the caller was told, not what the source did.

5.7.3.4 When the watch events cannot be derived #

Two of the post-commit steps query the source. Working out which keys were orphaned by a key deletion needs a lookup that can only be made after the commit, and expanding a blanket tombstone into per-value events needs the value set.

If that derivation cannot complete exactly, LCS does not reinterpret a successful commit as a failed one and does not emit a partial set of events. It delivers OVERFLOW to the affected watchers instead, releases the retained replay state, and reports the commit as successful, which it was. A late response arriving afterwards does not resurrect the individual events once overflow recovery has been chosen.

The carve-out is narrower than it might appear. It covers the orphan lookup and the watch batch. The other two post-commit steps — publishing the layer metadata cache and recording the hive generation — are state updates rather than event derivation, and a failure in either returns EIO and marks the source Down.

5.7.3.5 Abort #

Aborting generates no events, ever, and releases the log. The source is told to roll back with RSI_ABORT_TRANSACTION if the transaction was bound.

Process death is the same path: closing the fd aborts.

5.8.1 The Source Model

Peios / Advanced Peios / PKM / LCS / Sources

A source is a userspace process that holds registry data and answers LCS's questions about it over the Registry Source Interface. LCS is source-agnostic: it does not know or care how a source stores anything.

The division is the sixth semantic rule (§5.1). A source stores path entries, key records, value entries and blanket tombstones, and returns all of them on request. It does not evaluate access, resolve layers, dispatch watches, interpret paths beyond the parent and child names it is given, or see the identity of any caller. LCS does all of that.

A source may back several hives; a hive is backed by exactly one source (§5.2.1).

5.8.1.1 The interface is specified elsewhere #

The RSI — its channel, framing, operations, error vocabulary, and the obligations binding on a conforming source — is a normative specification, because the userspace side is a role a third party can implement. It is a chapter of PSPK, and it is where the wire format lives.

This section covers the kernel's side: how LCS admits a source, how it dispatches requests and accounts for them, what it refuses to believe, and what it does when a source dies or answers late.

5.8.1.2 The trust boundary #

Sources are in the TCB. LCS trusts the data a source returns — descriptors, values, symlink targets, key metadata — because it has no independent copy of any of it.

A compromised source therefore has complete control over access decisions for its hives. It can return a permissive descriptor for any key and AccessCheck will grant access that should have been denied. LCS validates that responses are structurally correct, but it cannot detect data that is well-formed and wrong: a descriptor granting Everyone KEY_ALL_ACCESS on a sensitive key is a perfectly valid descriptor.

Three consequences are worth naming specifically.

Layer table poisoning. A compromised source backing Machine\ can fabricate the Precedence and Enabled values under Machine\System\Registry\Layers\, and so control which layer wins every resolution contest system-wide. The SeTcbPrivilege check at write time does nothing about a fabricated read.

Layer authorization bypass. The same source can return permissive descriptors for layer metadata keys, granting any process write access to any layer (§5.3.4).

SeRestorePrivilege implies descriptor control. Restore replaces a subtree including every descriptor in it, so granting SeRestorePrivilege effectively grants WRITE_DAC and WRITE_OWNER over everything in reach of a restore. Operators should understand it that way.

The mitigations are operational rather than architectural: sources run with tightly scoped privileges, protected by descriptors on their service definitions, and managed by peinit with Process Integrity Protection where available. LCS emits audit events for every source data validation failure. A harder guarantee — checksumming descriptors LCS computed during inheritance and verifying them on retrieval — is possible but not implemented.

5.8.1.3 loregd is not special #

loregd is the first source and the one that provides Machine\ and Users\ at boot, which puts it on the critical path to a running system. Nothing in LCS knows that. Its details are its own manual's business; what LCS requires of it is exactly what it requires of any source.

5.8.2 Registration and Slots

Peios / Advanced Peios / PKM / LCS / Sources

A source reaches LCS through /dev/pkm_registry. The device's open() handler checks the calling thread's effective token for an enabled SeTcbPrivilege and returns EPERM without it, so an unprivileged process cannot obtain an fd to the device at all.

Having opened it, the source issues REG_SRC_REGISTER, naming the hives it backs, the root GUID of each, the highest sequence number it has persisted, and per-hive flags and scope GUID. On success it enters the request loop: read() for requests, write() for responses.

5.8.2.1 What registration validates #

  • Every hive name is valid and is not the reserved CurrentUser (§5.2.1).
  • No route identity — the folded name paired with its scope — collides with one held by another Active source.
  • Private hive collisions are scoped: the same name in different scopes is fine (§5.2.2).
  • A hive's root GUID is not nil, and the root GUIDs within one request are distinct from each other.
  • A hive without RSI_HIVE_PRIVATE carries no scope GUID, and no unknown flag bits are set.
  • The hive count is non-zero and within MaxHivesPerSource (64), and the source count is within MaxRegisteredSources (32). Either is ENOSPC.
  • The reported maximum sequence can be advanced past without overflowing 64 bits, or registration fails EOVERFLOW and the source is never made Active (§5.3.7).

Root GUIDs are checked for uniqueness within one request, and the already-registered state is checked for consistency, but an incoming request's root GUIDs are not compared against those of existing slots.

5.8.2.2 Source slots #

Successful registration creates a source slot: the kernel object owning one connection and the hive set registered on it.

Each registered hive has a stable identity — its folded name, its visibility, its scope GUID for a private hive, and its root GUID.

Down slots keep their identities reserved. A crash or an fd close marks the slot Down; it does not unregister anything and does not retire any hive identity. Slots are never freed, and collision checks see Down slots as well as Active ones.

Status is a property of the slot, not of an individual hive. A source's hives go Down together.

5.8.2.3 Resuming a Down slot #

A new process may take over a Down slot if it holds SeTcbPrivilege and registers exactly the same hive set: the same folded name, visibility, scope GUID and root GUID for every hive, and the same number of them. Partial resume is rejected.

The distinction between the failures matters. A request whose only mismatch is stale identity data — a different root GUID for an otherwise-matching hive — fails ESTALE. Other partial or malformed resume attempts fail EINVAL. EEXIST is reserved for a collision with an Active slot; it never comes from a Down-slot collision. When both an Active collision and a stale Down slot apply, EEXIST wins.

New hives cannot be added by mutating a Down slot during a resume — the hive set has to match exactly, so a superset simply fails, and a new hive needs a new slot. There is no implicit retirement of a Down slot; retiring one would need an explicit administrative operation, and none exists.

A replacement source is authenticated by SeTcbPrivilege, not by process identity. Nothing records a pid, and nothing could usefully: process identity does not survive a crash and restart.

5.8.2.4 Coming back #

On a successful resume the slot becomes Active, its restart generation advances, and LCS replays any layer deletions that were pending, then delivers OVERFLOW to every armed watch on that source (§5.6.3).

Existing key fds resume working without being reopened. Each carries the restart generation it last saw; when it notices a change it re-reads its key and continues. If the key's GUID no longer exists in the restarted source — the database was restored from an older backup, say — that first operation returns ENOENT and the fd is marked orphaned.

5.8.3 Request Dispatch

Peios / Advanced Peios / PKM / LCS / Sources

The RSI is multiplexed. LCS sends concurrent requests tagged with request ids and matches responses back to the kernel threads waiting on them. A source may process requests in any order.

Request ids are allocated per connection, strictly increasing, and never reused while the connection lives — including after a timeout. The id is allocated inside the queue lock, after the in-flight limit has been checked, so a caller queued waiting for a slot does not hold one yet.

MaxConcurrentRSIRequests, default 256, bounds how many requests may be dispatched and awaiting a response at once. It is back-pressure for a slow source.

5.8.3.1 One deadline covers three waits #

RequestTimeoutMs, default 30 seconds, is measured from the point a kernel operation first attempts to reserve an in-flight slot, after local validation and access checks have already passed. One deadline is computed there and reused for all three legs: waiting for a slot, waiting for the source to read the queued request, and waiting for the response.

If the deadline expires before a slot is reserved, the caller gets ETIMEDOUT and no request is sent. If it expires after dispatch, the caller gets ETIMEDOUT and late-response handling applies (§5.8.5).

The deadline is checked before admission is attempted, not only after a contention round is lost. It used to be the latter, which meant a request finding a slot immediately free was dispatched with an already-expired deadline and timed out in the wait leg instead — so the rule above held only under contention, the one case where it is hardest to observe.

5.8.3.2 Timed-out requests keep their slot #

For every dispatched request LCS keeps a request record until a matching response is processed or the connection is torn down. The record holds the request id, the operation code, the transaction id, the key GUID it concerns, the runtime limits in force, and any retained effect the kernel will need if the source later reports success.

When the deadline expires after dispatch, LCS detaches the waiting caller from the record and returns ETIMEDOUT. The record stays in the in-flight table and keeps counting against MaxConcurrentRSIRequests. A timeout does not free a slot; only a response or a teardown does.

A source that accumulates timed-out requests can therefore exhaust its own in-flight slots until it answers or disconnects. That is the intended shape: a source that stops answering stops being usable.

5.8.3.3 Requests with no caller #

LCS dispatches some requests with nobody waiting: RSI_DROP_KEY after the last fd to an orphaned key closes (§5.2.9), and RSI_ABORT_TRANSACTION cleaning up source transaction state.

Such a record occupies an in-flight slot and is retained like any other, and its response is validated normally and released normally. But it is not a late response, and the retained-effect recovery rules do not apply to it merely because nobody is waiting.

The kernel tracks the difference explicitly, with two booleans: whether a waiter is attached now, and whether one was ever attached. A record that never had a caller is not a timed-out request; only one whose caller was detached after its deadline is.

This is load-bearing rather than pedantic. RSI_DROP_KEY is a mutating operation, so without the distinction a perfectly ordinary answer to a caller-less cleanup request would look like a mutation the kernel could not account for, and would tear the source down.

5.8.3.4 The channel #

/dev/pkm_registry is message-oriented. One read() returns exactly one complete request; a buffer too small for the next one returns EMSGSIZE without consuming it. An empty queue blocks, or returns EAGAIN under O_NONBLOCK, or returns 0 if the fd is closing. One write() submits exactly one complete response, and its length must equal the response's own total_len exactly.

poll reports the fd readable when a request is queued, writable while the slot is Active, and POLLHUP | POLLERR when the slot is Down or the fd is closing. An fd that is open but has not yet registered reports nothing at all.

Any rejected write() — short, over-long, an unknown or duplicate request id, an operation code that does not match the request, a response for another connection — returns EINVAL and tears the connection down. A source that cannot speak the protocol correctly is not one whose other answers are worth believing.

5.8.4 Validation

Peios / Advanced Peios / PKM / LCS / Sources

LCS validates every response before using it, and the failures split into two categories with very different consequences.

5.8.4.1 Malformed data #

The RSI message is structurally valid but its content is not: a descriptor that will not parse, a value type that does not exist, a sequence number that cannot be real, a metadata block that does not cover the GUIDs it should.

  • The request returns EIO to its caller.
  • An LCS_SOURCE_VALIDATION_FAILURE audit event is emitted, naming the source slot and — where known — the hive, the request id, the operation code, the key GUID, and which of the twelve validation classes applies (§5.4.4).
  • The source stays alive. Corruption may be localised, and one bad key is not a reason to take a hive offline.

What is checked, by category:

  • Security Descriptors, from lookups and from layer metadata refreshes, must parse and must satisfy the ACE mask rules of §5.4.2. A malformed layer metadata descriptor additionally leaves the previous known-good one cached (§5.3.3).
  • Names — layer names, key and child names, value names — must be valid under the ordinary rules for their kind.
  • Sequence numbers must be below the next number LCS would allocate, and must not duplicate at the same precedence in a way that would decide a winner (§5.3.6).
  • Payload shape — an otherwise-matched response whose operation-specific payload is the wrong shape, carries trailing bytes, or encodes a path target invalidly.
  • Metadata closure — a lookup or enumeration whose per-GUID metadata block has missing, duplicate, unreferenced or nil entries. A HIDDEN entry must carry an all-zero GUID and contributes no metadata.
  • Value payloads — invalid types, a tombstone carrying data, data above MaxValueSize.
  • Orphan lists — a nil or duplicated GUID in an RSI_DELETE_LAYER response.
  • Status codes outside the defined vocabulary.

5.8.4.2 Malformed protocol #

The message itself is structurally invalid: bad framing, a truncated response, an unknown request id, a duplicate response, an operation code that does not match the request.

This is treated as a source crash. The connection is torn down, the in-flight table is destroyed with every waiter completed EIO, the slot is marked Down, its hives become unavailable, and bound transactions enter SOURCE_DOWN.

There is one case where malformed data also takes the source down: when the caller had already timed out and the operation was a commit or a replayable mutation. At that point LCS cannot establish whether a mutation was applied, and it cannot account for one it cannot describe (§5.8.5).

5.8.4.3 Asymmetric extensibility #

Requests and responses do not extend the same way.

A request may carry trailing fields a source does not recognise; a source skips them using total_len. That is how a new optional field is added without an RSI version bump.

A response may not. LCS rejects any trailing bytes in a response payload as malformed data. Forward compatibility on the response side comes from new operations, not from extending existing payloads.

5.8.5 Failure and Late Responses

Peios / Advanced Peios / PKM / LCS / Sources

5.8.5.1 When a source dies #

The connection closes unexpectedly, and:

  1. The slot is marked Down and its hives become unavailable.
  2. Every pending request fails EIO, including ones queued but not yet delivered.
  3. Open key fds stay valid. An fd holds a GUID and a granted mask, and neither depends on the source. Operations needing a round trip return EIO until the source comes back.
  4. Bound transactions enter REG_TXN_SOURCE_DOWN, their poll waiters are woken with POLLERR | POLLHUP, their mutation logs are released, and further use of those fds returns EIO.
  5. Watches stay armed. Watch state is kernel-side and does not depend on the source at all. Nothing is delivered during the window, and OVERFLOW arrives on re-registration rather than on disconnect (§5.6.3).

Coming back is covered in §5.8.2.

5.8.5.2 The late response problem #

A caller that times out after its request was dispatched is gone, but the request is not. The source may still apply the operation and answer minutes later, and by then there is nobody to return a value to — while the kernel still has work to do, because a mutation that succeeded has to produce its generation increment and its watch events.

This is the most intricate part of LCS and the part with the most ways to be subtly wrong.

A late response is validated exactly like an on-time one. The rules that follow apply only to a request whose caller was detached after its deadline, never to one that never had a caller (§5.8.3).

5.8.5.2.1 A late error #

The record is released. No watch events, no generation change, nothing.

5.8.5.2.2 A late successful read #

Validated, then discarded. There is nobody to give it to and nothing about it changes kernel state.

5.8.5.2.3 A late successful mutation #

The kernel-side effects that correspond to the mutation are applied from the retained record: the hive generation increment, watch dispatch, the layer metadata cache refresh, and — for a commit — the transaction's batch effects and orphan tracking.

Which mutations can actually be replayed is narrower than the set of operations that count as mutating. A replayable effect is recorded for RSI_SET_VALUE and RSI_WRITE_KEY, and only for non-transactional calls. For the other mutating operations — creating, hiding or deleting a path entry, creating or dropping a key, deleting a value entry, setting a blanket tombstone, deleting a layer — no effect was retained, so a late success is a mutation the kernel cannot account for. LCS tears the source down and returns EIO rather than silently ignoring it.

That is the conservative direction, and deliberately so: the alternative is a committed change with no watch event and a stale generation number, which nothing downstream could detect.

5.8.5.2.4 A late successful transaction operation #

A late RSI_BEGIN_TRANSACTION has created transaction state in the source that nobody will ever use, so LCS enqueues an RSI_ABORT_TRANSACTION for that id.

A late RSI_ABORT_TRANSACTION or RSI_FLUSH releases the record with no effects.

A late RSI_COMMIT_TRANSACTION is a mutating response and applies the retained commit effects — the same generation update and the same watch events an on-time commit would have produced. Watchers may therefore observe the effects of a transaction whose caller was told it timed out (§5.7.3).

5.8.5.2.5 A malformed late response #

The ordinary malformed-data and malformed-protocol rules apply. But if LCS cannot safely process the kernel-side effects of a possibly-applied mutation because the request metadata it needs is missing or invalid, it tears the source down and marks it Down rather than ignoring the response. A malformed late commit response takes the source down for the same reason.

5.8.5.3 What a caller should conclude #

ETIMEDOUT means may or may not have completed. A caller that needs certainty reads state back before retrying. That applies to every operation and, especially, to a transaction commit.

5.8.5.4 Fd lifecycle #

Key fds and transaction fds are ordinary file descriptors, subject to RLIMIT_NOFILE. There is no registry-specific fd accounting and no registry-specific leak protection.

Process exit closes them through normal kernel cleanup: key fds released and their watches removed, transactions aborted.

5.8.5.5 Memory #

Registry kernel memory needs no global cap, because everything is bounded by limits that already exist.

  • Watch queues: NotificationQueueSize per queue, times RLIMIT_NOFILE queues per process.
  • Open key state: per-fd overhead — GUID, granted mask, ancestor chain, watch state — times the same fd limit.
  • Layer table: bounded by MaxTotalLayers, and per-value resolution cost by MaxLayersPerValue.
  • In-flight requests: bounded per source by MaxConcurrentRSIRequests, and separately by the number of threads blocked on registry syscalls.

5.9.1 The Stream

Peios / Advanced Peios / PKM / LCS / Backup and Restore

The registry backup format is a streamable binary representation of a key and everything beneath it, with full layer fidelity. It is used by REG_IOC_BACKUP and REG_IOC_RESTORE, by first-boot seeding, by disaster recovery and by offline migration.

It is an LCS-level format. Sources never see it: LCS serialises from source data on the way out and deserialises into RSI operations on the way in.

Because a third party writes and reads these streams directly — that is what migration and recovery mean — the format is a normative specification rather than a description. It is a chapter of PSPK, and every byte layout, record type, ordering rule and validation requirement lives there. This section covers what LCS does with it.

5.9.1.1 What the design buys #

Streamable. It is written to an arbitrary fd — a file, a pipe, a socket — with no seeking, in a single pass, and read back the same way.

Full layer fidelity. Every path entry, value, tombstone and blanket tombstone is stored with its layer tag, so restoring reconstructs the layered state rather than a flattened snapshot of it.

Depth-first pre-order. A parent always appears before its children, so a restore can create keys top-down without buffering a tree.

Descriptors inline. Each key record carries its own descriptor with no deduplication. Redundancy is external compression's problem; piping through zstd handles it.

Self-verifying. A trailer carries a record count and a SHA-256 over everything before it, so truncation and corruption are detected.

5.9.1.2 Versioning #

The header carries a format version and a minimum reader version. A writer that used only older features sets a lower minimum, letting older readers restore the stream; a reader that finds a minimum above its own supported version rejects the stream outright, before touching anything.

Both are 21 in the current implementation, and the reader supports 21.

Unknown record types are skipped when the minimum reader version allows it, and they still count toward the record count and the checksum. A writer that adds a record type a restore genuinely needs must raise the minimum reader version, so that an older reader refuses the stream rather than restoring an incomplete one.

Extension is by new record types only. Existing record payloads must be consumed exactly; trailing bytes inside a known record are an error. This is the opposite of the RSI's request convention (§5.8.4), and the difference is deliberate: a stream is replayed into mutations long after it was written, and a field silently ignored there is data silently lost.

5.9.2 Backup

Peios / Advanced Peios / PKM / LCS / Backup and Restore

REG_IOC_BACKUP exports the key on the fd and its entire subtree to another fd.

It requires SeBackupPrivilege and performs no per-key AccessCheck whatsoever. The privilege is the whole authorisation, which is why the operation is audited unconditionally (§5.4.4). The output fd must be writable, or EBADF.

5.9.2.1 The snapshot #

Before reading anything, LCS opens a read-only source transaction — RSI_BEGIN_TRANSACTION with mode RSI_TXN_READ_ONLY — so that the whole export is a point-in-time snapshot. Concurrent mutations do not appear part-way through the stream.

That transaction is released with RSI_ABORT_TRANSACTION and never committed. There is no commit call anywhere in the backup path; a read-only transaction has nothing to commit.

A source that does not support read-only snapshots answers RSI_TXN_NOT_SUPPORTED and the backup fails ENOTSUP. A source already holding MaxReadOnlyTransactionsPerSource snapshots (default 16) yields EBUSY — which bounds snapshot-holding without treating backups as write-lock holders, since they are not.

Backing up an orphaned key is ENOENT: it is no longer a reachable subtree root (§5.2.9).

5.9.2.2 Audit #

LCS_BACKUP_START is emitted before any subtree data is read, and if it cannot be emitted the backup returns EIO and does not start. LCS_BACKUP_COMPLETE is emitted afterwards, carrying the result, and a failure to emit it cannot change a result that has already happened.

5.9.2.3 What is written #

The stream is a header, the layer manifest, then each key in depth-first pre-order with its path entries, values and blanket tombstones, then the trailer. The exact shapes are in the PSPK chapter.

Two things about the exporter are worth stating here, because they constrain a reader more tightly than the format does.

The exporter writes no GUID-bearing path entries for the backup root. The root's section contains only its hidden entries. A reader tolerates and skips them if some other writer produces them, but this one does not emit them, because on restore the target key's existing name is authoritative and they would be discarded anyway.

Hidden entries belong to the parent's section, not to a section of their own — a hidden entry has no key to have a section for. A hidden entry masking a name where no key exists in any layer is still valid; it expresses "this layer hides this name" whether or not anything is there to hide.

The layer manifest is written from the live layer table, and it is a manifest: it records what the layers looked like at backup time so that a restore can validate the stream against them. It is not a backup of the layer definitions. A layer definition is backed up only when Machine\System\Registry\Layers\<Name>\ is itself inside the subtree being exported, in which case it is ordinary key and value data like anything else.

5.9.3 Restore

Peios / Advanced Peios / PKM / LCS / Backup and Restore

REG_IOC_RESTORE replaces the key on the fd and its entire subtree from a stream. It is not a merge: the target's contents and descendants are torn down before the stream's contents are written.

It requires SeRestorePrivilege and, like backup, performs no per-key AccessCheck and is audited unconditionally. The input fd must be readable, or EBADF, and restoring onto an orphaned key is ENOENT.

Because restore rewrites every descriptor in the subtree, SeRestorePrivilege effectively confers WRITE_DAC and WRITE_OWNER over everything within reach of one (§5.8.1).

5.9.3.1 One transaction #

The entire restore — teardown and rebuild together — is wrapped in a single read-write source transaction. A source that answers RSI_TXN_NOT_SUPPORTED for RSI_TXN_READ_WRITE cannot be a restore target: restore requires atomicity and there is no partial-restore mode. Every failure path aborts the transaction, so a failed restore rolls back the teardown as well.

5.9.3.2 The target key survives #

The stream's header names a root GUID, and that GUID is remapped to the already-open target key everywhere it appears — in parent references, in child references, in value key references — before anything is validated or dispatched.

The target key object is not replaced. Its GUID, parent, name, volatile flag and symlink flag remain what they were; they are never taken from the stream. What the stream's root record supplies is the mutable part: the Security Descriptor and the last write time, written to the target with RSI_WRITE_KEY inside the transaction.

The root record's immutable flags must match the target's. A backup of a volatile key restored onto a non-volatile one, or a symlink onto a non-symlink, is EINVAL.

Descendants keep their backup GUIDs. Those are written into the target verbatim.

5.9.3.3 Order of operations #

  1. Validate the whole stream, including the trailer's record count and checksum, and retain every replayable record.
  2. Read the layer manifest and apply the precedence gate (below).
  3. Verify the root record and its immutable flags against the live target.
  4. Tear down the target's contents and descendants — path entries, values, blanket tombstones, and descendant key records — inside the transaction.
  5. Write the root's mutable fields, then replay its section's values and blanket tombstones.
  6. For each non-root key in stream order: create it, write its last write time, then replay its path entries, values and blanket tombstones.
  7. Commit.

The stream is read from the fd exactly once, sequentially. Nothing seeks, so a pipe is a valid input.

Validating the whole stream first is stronger than the format requires: a checksum failure aborts before any source mutation rather than after some. The cost is memory rather than seeking — the replayable records are retained in kernel memory across the teardown.

5.9.3.4 The precedence gate #

Before any key record is written, LCS checks the layer manifest. If any declared layer has a precedence above 0, or any existing cached layer table entry with the same folded identity does, and the caller does not hold SeTcbPrivilege, the restore aborts with EPERM before a single byte reaches the source.

And if the stream contains ordinary key and value records for Machine\System\Registry\Layers\<Name>\, writes that create or raise persisted metadata above precedence 0 hit the ordinary inline SeTcbPrivilege check as well (§5.3.4).

Both exist so that SeRestorePrivilege cannot be used to smuggle a Group Policy-tier layer past the defence in depth that guards precedence.

5.9.3.5 Layers in a restored stream #

Manifest records create, update, delete, enable, disable and authorise nothing. If restored entries reference a layer that is not in the current table, and the stream does not also restore that layer's metadata subtree as ordinary registry data, those entries become latent unknown-layer entries and are ignored during resolution until real metadata exists (§5.3.6). If the metadata subtree is included, it is restored through the ordinary path, and those records — not the manifest — are what define the layer.

5.9.3.6 Sequence remapping #

Backup sequence numbers preserve the backup's internal ordering, but a restore is a new mutation and its entries must outrank everything already present. So the numbers are remapped rather than written through.

Before dispatching the first layer-qualified record, LCS takes the global sequence-allocation gate and records the current next sequence as the offset. Every restored record is then written with offset + backup_sequence, which preserves relative order while placing the whole set above pre-restore state.

The gate is held until the restore reaches a terminal state. Other sequence-allocating mutations wait; reads are not blocked. A restore with no layer-qualified records at all never takes it.

If a remapped value would overflow, the restore fails EOVERFLOW — and it fails at validation, before teardown, rather than part-way through. The valid remapped range stops just below U64_MAX, which is never handed out (§5.3.7).

When the restore reaches any terminal state — commit, abort, failure or cancellation — LCS advances the global counter past the highest number it dispatched, and does not roll that back. Sequence numbers a failed restore dispatched become unused gaps, exactly like those of any other failed write.

5.9.3.7 GUID collisions #

A GUID that appears twice in one stream, other than the root, is EINVAL, and so is a non-root GUID equal to the target root's.

A non-root GUID that already exists outside the subtree being replaced is EEXIST — but it is discovered by the source rejecting the create during replay, not by a check beforehand. LCS has no index of which GUIDs exist elsewhere, so the collision surfaces mid-restore, and the transaction rolls back.

The parent of every path entry, after remapping, must be either the restore root or a key record already processed earlier in the stream. That check is made up front, and it is what stops a crafted backup injecting path entries into arbitrary parts of the existing namespace outside the subtree being replaced.

5.9.3.8 Watches #

A restore is an arbitrary subtree replacement and LCS retains no exact before-and-after diff for it. On a successful commit it publishes the affected hive's generation increment and dispatches a no-name OVERFLOW to the armed watches on that source (§5.6.3). A restore that fails or aborts before commit emits nothing.

5.10.1 The Bootstrap Problem

Peios / Advanced Peios / PKM / LCS / Bootstrap and Self-Configuration

The registry is the configuration store for the whole system, and it has to configure itself. Three circular dependencies have to be broken before it can serve anyone.

peinit needs the registry to start services, but the registry source is a service. Service definitions live in the registry, and the process that would serve them has not started.

LCS needs operational parameters from the registry, but the registry needs LCS. Timeouts, caps and limits live under Machine\System\Registry\, in a hive LCS itself routes.

A fresh install has no data at all. The source's database is empty; there is nothing to read.

Three rules break all three.

5.10.1.1 Rule 1: compiled-in defaults #

Every operational parameter has a compiled-in default, and LCS runs on those defaults from the moment PKM initialises. There is no "waiting for configuration" state, no flag, and no wait queue: the limits structure is statically initialised at load, the source char device is registered without consulting configuration, and the first attempt to read configuration happens on a workqueue after a source has already registered.

Before any source registers, the routing table is empty, so every operation that names a hive returns ENOENT. That is the only sense in which LCS is not yet useful, and it is not a distinct state — it is just an empty table.

5.10.1.2 Rule 2: the base layer exists unconditionally #

The base layer is a static constant in the kernel: name base, precedence 0, enabled. It is handed out whenever the dynamic layer table is empty and is always written first into every layer snapshot.

A source that registers with an entirely empty database is fully functional, because the one layer that writes need is not in the database. Persisted metadata under Machine\System\Registry\Layers\base\ may exist and may decorate the base layer, but it is not required and cannot contradict it (§5.3.2).

5.10.1.3 Rule 3: hot-swap, not restart #

When configuration becomes available, LCS reads it, validates it, and swaps the values in place. It does not restart, re-initialise, or block. The whole transition from compiled-in defaults to registry-backed configuration is driven by the internal self-watch (§5.10.4).

5.10.1.4 Source dependencies are the source's problem #

LCS neither knows nor cares what a source depends on. A source that needs the root filesystem, a SYSTEM token, or a particular kernel feature arranges that for itself. LCS's only requirement is that the process can open /dev/pkm_registry and speak RSI.

5.10.2 The Boot Sequence

Peios / Advanced Peios / PKM / LCS / Bootstrap and Self-Configuration

5.10.2.1 Normal boot #

Kernel boots
  → PKM initialises; LCS runs on compiled-in defaults
  → /dev/pkm_registry registered
  → the base layer exists in memory

A source registers — loregd, started by peinit
  → opens /dev/pkm_registry (SeTcbPrivilege checked at open)
  → REG_SRC_REGISTER: hive names, root GUIDs, max persisted sequence
  → LCS initialises or advances next_sequence to max + 1

Bootstrap refresh is queued
  → resolve Machine\System\Registry     → read and hot-swap parameters
  → resolve Machine\System\Registry\Layers → populate the layer table
  → arm internal subtree watches

The bootstrap refresh runs on a workqueue after REG_SRC_REGISTER returns, so registration never blocks on configuration and a source that registers can start answering immediately.

The refresh is triggered by the arrival of a global hive named Machine. That name is matched case-insensitively in the kernel and is one of the two hive names LCS knows about; the other is Users, the target of CurrentUser\ rewriting (§5.2.1). Neither is a routing decision — routing is entirely dynamic — but the claim that the kernel holds no hive names at all would not be true.

5.10.2.2 First boot #

An empty source has no Machine\System\Registry to read.

Source detects an empty database on first startup
  → generates root GUIDs for the hives it backs
  → creates root key records with their default SDs
  → persists them, then registers with LCS

LCS resolves Machine\System\Registry → does not exist
  → compiled-in defaults retained
  → subtree watch armed on the Machine\ hive root instead

peinit notices the registry is empty and restores the seed backup.
That is peinit's decision, not LCS's.

Seed restore populates Machine\ through REG_IOC_RESTORE
  → the fallback subtree watch fires
  → LCS re-runs the bootstrap refresh: resolves the specific GUIDs,
    re-arms targeted watches, validates and hot-swaps the seed values,
    and re-reads layer metadata

Hive root Security Descriptors are set by the source, not by LCS. LCS holds no template for them and enforces whatever the source stores (§5.4.3).

5.10.2.3 Watch arming in practice #

The spec-level description above says the fallback watch is armed instead of the targeted ones. What the kernel actually arms is a mixed set: targeted watches on whichever of the two roots exist, plus the Machine\ root fallback. The fallback is a superset rather than a substitute, and it stays armed until a refresh finds both roots present.

A third internal watch is armed by the same sequence, on Machine\System\KMES, which is not LCS configuration at all — it is how KMES picks up its own parameters from the registry LCS serves.

5.10.2.4 The bootstrap contract #

Four properties hold throughout and are relied on elsewhere:

  1. LCS is always operational with compiled-in defaults. It accepts syscalls from the moment PKM initialises.
  2. The base layer requires no persisted state.
  3. Hot-swap is the only configuration transition. LCS never blocks, restarts, or re-initialises.
  4. Source dependencies are the source's concern.

5.10.3 Operational Parameters

Peios / Advanced Peios / PKM / LCS / Bootstrap and Self-Configuration

LCS reads nineteen parameters from Machine\System\Registry\. All are REG_DWORD. Each has a compiled-in default and a valid range, and LCS runs on the defaults until the registry says otherwise.

ValueDefaultRangeBounds
RequestTimeoutMs300001000–600000A source round trip (§5.8.3).
TransactionTimeoutMs300001000–600000The lifetime of an open transaction (§5.7.1).
NotificationQueueSize25616–65536Queued events per watcher before overflow (§5.6.4).
SymlinkDepthLimit161–64Symlink resolution depth (§5.2.4).
MaxValueSize10485764096–67108864One value's data, in bytes.
MaxKeyDepth51232–4096Key hierarchy nesting.
MaxPathComponentLength25564–1024One key, value or layer name, in UTF-8 bytes.
MaxTotalPathLength163831024–65535A whole path, in UTF-8 bytes.
MaxLayersPerValue1281–1024Layers writing to one (key, value name) (§5.3.1).
MaxBoundTransactionsPerSource161–256Concurrently bound transactions per source (§5.7.1).
MaxReadOnlyTransactionsPerSource161–256Concurrent backup snapshots per source (§5.9.2).
MaxTotalLayers102416–65536Distinct layers in the in-memory table (§5.3.1).
MaxRegisteredSources321–256Concurrently registered sources.
MaxHivesPerSource641–1024Hives one source may register.
MaxConcurrentRSIRequests2568–4096In-flight RSI requests per source (§5.8.3).
MaxScopeGUIDsPerToken81–256Private hive scope GUIDs on a token (§5.2.2).
MaxPrivateLayersPerToken161–256Private layer names on a token (§5.3.5).
MaxSubtreeWatchDepth00–4096Subtree watch depth; 0 is unlimited (§5.6.3).
MaxTransactionWatchEventBurst4096256–65536Watch events per watcher from one commit (§5.6.3).

Unknown values under this key are ignored. There are exactly nineteen parameters and no undocumented ones; a separate set under Machine\System\KMES\ belongs to KMES.

Two limits are not among them. The transaction mutation log is capped at 4096 entries by a compile-time constant (§5.7.2), and hard ceilings on total path length and key depth exist independently of configuration — set equal to the range maxima above, so they never conflict.

5.10.3.1 Where a configured value does not fully bind #

Three of the nineteen do not do everything their range suggests.

MaxTotalLayers may be configured up to 65536, but the in-memory layer table is a fixed array sized at compile time for 1023 dynamic layers plus the base layer. A value above 1024 validates and publishes, and then layer creation fails ENOSPC at 1023 regardless. Values below 1024 bind correctly.

MaxPrivateLayersPerToken is described as an attachment-time limit but is not enforced at attachment. KACS applies its own hard cap of 256 and LCS applies the configured value later, at use, with E2BIG (§5.3.5). MaxScopeGUIDsPerToken behaves the same way.

SymlinkDepthLimit is honoured on most of the walk but two call sites use the compiled-in default of 16 instead of the configured value.

5.10.3.2 Validation #

A value is checked against its range when it is read.

  • Valid — hot-swapped into the in-memory configuration and used by new operations.
  • Invalid — out of range, the wrong type, or missing — the value is ignored and the previously active one is kept: the compiled-in default or the last known-good. An LCS_SELF_CONFIG_INVALID audit event is emitted naming the parameter, what was wrong, and the value being retained (§5.4.4).

Values are never clamped or silently corrected. There is no min/max on any configuration path. A write to the registry succeeds, because the source does not enforce kernel semantics, and LCS simply refuses to use it. The registry shows what was written; the audit log shows what LCS is running on.

Because "missing" is invalid, a first boot before seed restore emits nineteen of these events per refresh.

5.10.3.3 Hot-swap and in-flight operations #

Configuration is published as a whole structure under a seqlock, and a reader takes a complete copy of it. A syscall entry point snapshots it once and threads that snapshot through the operation, so in-flight work uses the values that were current when it started and new work uses the updated ones.

That is the rule, and mostly the practice. Some deeper paths take a second snapshot part-way through, and a few call sites read a single live value rather than a snapshot — reg_begin_transaction's timeout, the bound-transaction cap, and the in-flight request cap among them. For those, a hot-swap can be observed mid-operation.

5.10.3.4 Security #

Machine\System\Registry\ inherits the Machine hive root descriptor — SYSTEM and Administrators with KEY_ALL_ACCESS, Authenticated Users with KEY_READ — so an unprivileged process cannot change any of this.

Domain policy at a higher-precedence layer defends against a compromised local administrator, which is the reason SeTcbPrivilege guards precedence above 0 (§5.3.4).

5.10.4 The Self-Watch

Peios / Advanced Peios / PKM / LCS / Bootstrap and Self-Configuration

LCS watches its own configuration and layer metadata subtrees, and it does so through the same machinery userspace uses — but not through the same interface.

An internal watch is an entry in the same watch map, taking a reference on the same subtree watch set, distinguished only by a kind marker. It has no fd, no granted access mask and no filter. Events reach it through a kernel callback rather than being queued for a read(), and it is therefore not subject to NotificationQueueSize. It is also not subject to MaxSubtreeWatchDepth, nor to the transaction burst suppressor: internal collection happens before either test.

Because there is no filter, deliverability is decided per target rather than by a bitmask. Each internal target admits only the event types it cares about: value events on the watched key itself for the configuration subtrees, and subkey events at depth 0 or value and descriptor events at depth 1 for the layer metadata subtree.

5.10.4.1 What it drives #

Self-configuration. A change under Machine\System\Registry\ triggers a re-read and validation of the parameters (§5.10.3).

The layer table. A change under Machine\System\Registry\Layers\ marks the affected layer names dirty and drives a bounded refresh of their precedence, enabled state, owner and cached descriptor.

Layer lifecycle. SUBKEY_CREATED and SUBKEY_DELETED under Layers\ add and remove layers, except for base, which is ignored (§5.3.2).

KMES configuration. A third internal watch, on Machine\System\KMES\, exists for KMES's own parameters. It is not LCS configuration, but the registry is where it lives and this is the mechanism that notices it change.

5.10.4.2 The callback is not the atomicity boundary #

For layer metadata, internal delivery identifies which layer names are dirty. It does not itself publish anything, and it must not: publishing a layer means publishing its table entry, metadata key GUID and cached descriptor together (§5.3.3), and a callback that published a partial entry would create a window in which a layer exists and nobody can be authorised against it.

LCS also does not perform source round trips while holding the watch-map or layer-table publication locks. The refresh runs outside them, after the mutating operation commits and before the syscall returns.

5.10.4.3 Arming #

At bootstrap, LCS resolves the GUIDs for Machine\System\Registry\ and Machine\System\Registry\Layers\ through RSI_LOOKUP and arms targeted subtree watches. If either does not exist — first boot, empty database — it arms a subtree watch on the Machine\ hive root instead, so that seed restore creating the subtree is noticed. That fallback event re-enters the whole bootstrap refresh, which resolves the specific GUIDs and arms the targeted watches.

In practice the kernel arms both: targeted watches for whichever roots exist, plus the Machine\ root fallback, until a refresh finds everything present. It is a superset of what is needed rather than a substitute for it.

5.10.4.4 Bootstrap interaction #

  1. A source registers. LCS reads Machine\System\Registry\*; the keys do not exist; compiled-in defaults are retained.
  2. Seed restore populates them. The subtree watch fires, LCS validates and hot-swaps to the seed values.
  3. Subsequent administrative changes fire the watch again, and LCS validates and hot-swaps, or rejects with an audit event.

At no point is there a state in which LCS is waiting for configuration.

Appendix 5.A LCS ABI Reference

Peios / Advanced Peios / PKM / LCS

Every name, value, offset and size in this appendix is generated from pkm/uapi/pkm/lcs.h by pkm/tools/gen-lcs-abi.py, with ioctl encodings and struct layouts measured by compiling a probe against the real header. Regenerate it whenever the ABI changes; do not edit it by hand. The names here are the ones a program actually compiles against.

What a compiler cannot measure -- which properties belong with their operations rather than here, and the kernel configuration -- is in the notes appendix, §5.B, which this generator does not touch.

5.A.1 Syscall numbers #

Signatures are read from the SYSCALL_DEFINE sites in pkm/lcs/.

NumberConstantSignature
1100SYS_REG_OPEN_KEYreg_open_key(int parent_fd, const char __user *path, u32 desired_access, u32 flags)
1101SYS_REG_CREATE_KEYreg_create_key(const struct reg_create_key_args __user *args)
1102SYS_REG_BEGIN_TRANSACTIONreg_begin_transaction(void)

5.A.2 Ioctls #

The type byte is 'R'. Ioctl number namespaces are per fd type, so REG_SRC_REGISTER (number 0 on the source device) and REG_IOC_QUERY_VALUE (number 0 on a key fd) do not collide: the kernel dispatches on the fd's file_operations, not globally. The encoded value is what _IOC produces from the direction, type byte, number and argument size.

Source device fd.

IoctlNumberDirectionArgumentArg sizeEncoded
REG_SRC_REGISTER0_IOWstruct reg_src_register_args240x40185200

Key fd.

IoctlNumberDirectionArgumentArg sizeEncoded
REG_IOC_QUERY_VALUE0_IOWRstruct reg_query_value_args640xC0405200
REG_IOC_SET_VALUE1_IOWstruct reg_set_value_args640x40405201
REG_IOC_DELETE_VALUE2_IOWstruct reg_delete_value_args400x40285202
REG_IOC_BLANKET_TOMBSTONE3_IOWstruct reg_blanket_tombstone_args240x40185203
REG_IOC_QUERY_VALUES_BATCH4_IOWRstruct reg_query_values_batch_args240xC0185204
REG_IOC_ENUM_VALUES5_IOWRstruct reg_enum_value_args400xC0285205
REG_IOC_ENUM_SUBKEYS6_IOWRstruct reg_enum_subkey_args400xC0285206
REG_IOC_QUERY_KEY_INFO7_IOWRstruct reg_query_key_info_args640xC0405207
REG_IOC_DELETE_KEY8_IOWstruct reg_delete_key_args240x40185208
REG_IOC_HIDE_KEY9_IOWstruct reg_hide_key_args240x40185209
REG_IOC_GET_SECURITY10_IOWRstruct reg_get_security_args160xC010520A
REG_IOC_SET_SECURITY11_IOWstruct reg_set_security_args240x4018520B
REG_IOC_NOTIFY12_IOWstruct reg_notify_args80x4008520C
REG_IOC_FLUSH13_IOnone00x0000520D
REG_IOC_BACKUP14_IOWstruct reg_backup_args40x4004520E
REG_IOC_RESTORE15_IOWstruct reg_restore_args40x4004520F

Transaction fd.

IoctlNumberDirectionArgumentArg sizeEncoded
REG_IOC_COMMIT16_IOnone00x00005210
REG_IOC_TXN_STATUS17_IORstruct reg_txn_status_args80x80085211

5.A.3 Structure layouts #

Offsets and sizes are measured, not declared. The header also defines a _SIZE constant for each of these structures; the two agree by construction, and a mismatch fails the build in uapi/smoke_test.c.

5.A.3.1 struct reg_create_key_args #

Total size 48 bytes.

OffsetSizeTypeField
04__s32parent_fd
44__u32_pad0
88__u64path_ptr
164__u32desired_access
204__u32flags
248__u64layer_ptr
324__s32txn_fd
364__u32_pad1
408__u64disposition_ptr

5.A.3.2 struct reg_query_value_args #

Total size 64 bytes.

OffsetSizeTypeField
04__u32name_len
44__u32_pad0
88__u64name_ptr
164__u32type
204__u32data_len
244__s32txn_fd
284__u32layer_buf_len
328__u64data_ptr
408__u64sequence
484__u32layer_len
524__u32_pad1
568__u64layer_ptr

5.A.3.3 struct reg_set_value_args #

Total size 64 bytes.

OffsetSizeTypeField
04__u32name_len
44__u32_pad0
88__u64name_ptr
164__u32type
204__u32data_len
248__u64data_ptr
324__u32layer_len
364__u32_pad1
408__u64layer_ptr
484__s32txn_fd
524__u32_pad2
568__u64expected_seq

5.A.3.4 struct reg_delete_value_args #

Total size 40 bytes.

OffsetSizeTypeField
04__u32name_len
44__u32_pad0
88__u64name_ptr
164__u32layer_len
204__u32_pad1
248__u64layer_ptr
324__s32txn_fd
364__u32_pad2

5.A.3.5 struct reg_blanket_tombstone_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32layer_len
44__u32_pad0
88__u64layer_ptr
161__u8set
173__u8``[3]_pad1
204__s32txn_fd

5.A.3.6 struct reg_query_values_batch_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32buf_len
44__u32count
88__u64buf_ptr
164__s32txn_fd
204__u32_pad

5.A.3.7 struct reg_enum_value_args #

Total size 40 bytes.

OffsetSizeTypeField
04__u32index
44__u32name_len
88__u64name_ptr
164__u32type
204__u32data_len
248__u64data_ptr
324__s32txn_fd
364__u32_pad

5.A.3.8 struct reg_enum_subkey_args #

Total size 40 bytes.

OffsetSizeTypeField
04__u32index
44__u32name_len
88__u64name_ptr
168__u64last_write_time
244__u32subkey_count
284__u32value_count
324__s32txn_fd
364__u32_pad

5.A.3.9 struct reg_query_key_info_args #

Total size 64 bytes.

OffsetSizeTypeField
04__u32name_len
44__u32_pad0
88__u64name_ptr
168__u64last_write_time
244__u32subkey_count
284__u32value_count
324__u32max_subkey_name_len
364__u32max_value_name_len
404__u32max_value_data_size
444__u32sd_size
481__u8volatile_key
491__u8symlink
506__u8``[6]_pad1
568__u64hive_generation

5.A.3.10 struct reg_delete_key_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32layer_len
44__u32_pad0
88__u64layer_ptr
164__s32txn_fd
204__u32_pad1

5.A.3.11 struct reg_hide_key_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32layer_len
44__u32_pad0
88__u64layer_ptr
164__s32txn_fd
204__u32_pad1

5.A.3.12 struct reg_get_security_args #

Total size 16 bytes.

OffsetSizeTypeField
04__u32security_info
44__u32sd_len
88__u64sd_ptr

5.A.3.13 struct reg_set_security_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32security_info
44__u32sd_len
88__u64sd_ptr
164__s32txn_fd
204__u32_pad

5.A.3.14 struct reg_notify_args #

Total size 8 bytes.

OffsetSizeTypeField
04__u32filter
41__u8subtree
53__u8``[3]_pad

5.A.3.15 struct reg_backup_args #

Total size 4 bytes.

OffsetSizeTypeField
04__s32output_fd

5.A.3.16 struct reg_restore_args #

Total size 4 bytes.

OffsetSizeTypeField
04__s32input_fd

5.A.3.17 struct reg_txn_status_args #

Total size 8 bytes.

OffsetSizeTypeField
04__u32state
44__s32terminal_errno

5.A.3.18 struct reg_src_register_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32hive_count
44__u32_pad
88__u64max_sequence
168__u64hives_ptr

5.A.3.19 struct reg_src_hive_entry #

Total size 56 bytes.

OffsetSizeTypeField
04__u32name_len
44__u32_pad0
88__u64name_ptr
1616__u8``[16]root_guid
324__u32flags
364__u32_pad1
4016__u8``[16]scope_guid

5.A.4 Constants #

Grouped as the header groups them.

Syscall and ioctl argument sizes.

ConstantValue
REG_CREATE_KEY_ARGS_SIZE48
REG_QUERY_VALUE_ARGS_SIZE64
REG_SET_VALUE_ARGS_SIZE64
REG_DELETE_VALUE_ARGS_SIZE40
REG_BLANKET_TOMBSTONE_ARGS_SIZE24
REG_QUERY_VALUES_BATCH_ARGS_SIZE24
REG_ENUM_VALUE_ARGS_SIZE40
REG_ENUM_SUBKEY_ARGS_SIZE40
REG_QUERY_KEY_INFO_ARGS_SIZE64
REG_DELETE_KEY_ARGS_SIZE24
REG_HIDE_KEY_ARGS_SIZE24
REG_GET_SECURITY_ARGS_SIZE16
REG_SET_SECURITY_ARGS_SIZE24
REG_NOTIFY_ARGS_SIZE8
REG_BACKUP_ARGS_SIZE4
REG_RESTORE_ARGS_SIZE4
REG_TXN_STATUS_ARGS_SIZE8
REG_SRC_REGISTER_ARGS_SIZE24
REG_SRC_HIVE_ENTRY_SIZE56

_IOWR, not _IOR: the kernel reads the caller's name_len and name_ptr out of the argument struct before it writes the result back, so the argument crosses in both directions. It was declared _IOR, which put the wrong direction bits in the encoded number -- and since the kernel dispatches on the whole encoded value, correcting it is a wire break, not a relabelling.

ConstantValue
REG_IOC_QUERY_KEY_INFO0xC0405207
REG_IOC_DELETE_KEY0x40185208
REG_IOC_HIDE_KEY0x40185209
REG_IOC_GET_SECURITY0xC010520A
REG_IOC_SET_SECURITY0x4018520B
REG_IOC_NOTIFY0x4008520C
REG_IOC_FLUSH0x0000520D
REG_IOC_BACKUP0x4004520E
REG_IOC_RESTORE0x4004520F

Transaction state codes.

ConstantValue
REG_TXN_ACTIVE_UNBOUND0
REG_TXN_ACTIVE_BOUND1
REG_TXN_COMMITTED2
REG_TXN_ABORTED3
REG_TXN_TIMED_OUT4
REG_TXN_SOURCE_DOWN5

Syscall flags and dispositions.

ConstantValue
REG_OPEN_LINK0x01
REG_OPTION_VOLATILE0x01
REG_OPTION_CREATE_LINK0x02
REG_CREATED_NEW1
REG_OPENED_EXISTING2

Registry key access rights.

ConstantValue
KEY_QUERY_VALUE0x00000001
KEY_SET_VALUE0x00000002
KEY_CREATE_SUB_KEY0x00000004
KEY_ENUMERATE_SUB_KEYS0x00000008
KEY_NOTIFY0x00000010
KEY_CREATE_LINK0x00000020
DELETE0x00010000
READ_CONTROL0x00020000
WRITE_DAC0x00040000
WRITE_OWNER0x00080000
ACCESS_SYSTEM_SECURITY0x01000000
MAXIMUM_ALLOWED0x02000000
GENERIC_ALL0x10000000
GENERIC_EXECUTE0x20000000
GENERIC_WRITE0x40000000
GENERIC_READ0x80000000
KEY_READ0x00020019
KEY_WRITE0x00020006
KEY_ALL_ACCESS0x000F003F
REG_VALID_DESIRED_ACCESS_MASK0xF30F003F
REG_VALID_MAPPED_ACCESS_MASK0x010F003F
REG_VALID_ACE_ACCESS_MASK0xF10F003F

Security information flags for REG_IOC_GET_SECURITY / SET_SECURITY.

ConstantValue
OWNER_SECURITY_INFORMATION0x00000001
GROUP_SECURITY_INFORMATION0x00000002
DACL_SECURITY_INFORMATION0x00000004
SACL_SECURITY_INFORMATION0x00000008
REG_VALID_SECURITY_INFORMATION0x0000000F

Registry value types.

ConstantValue
REG_NONE0
REG_SZ1
REG_EXPAND_SZ2
REG_BINARY3
REG_DWORD4
REG_DWORD_BIG_ENDIAN5
REG_LINK6
REG_MULTI_SZ7
REG_RESOURCE_LIST8
REG_FULL_RESOURCE_DESCRIPTOR9
REG_RESOURCE_REQUIREMENTS_LIST10
REG_QWORD11
REG_TOMBSTONE0xFFFF

Watch event types and filters.

ConstantValue
REG_WATCH_VALUE_SET1
REG_WATCH_VALUE_DELETED2
REG_WATCH_SUBKEY_CREATED3
REG_WATCH_SUBKEY_DELETED4
REG_WATCH_SD_CHANGED5
REG_WATCH_KEY_DELETED6
REG_WATCH_OVERFLOW7

Watch event raw byte layout.

ConstantValue
REG_WATCH_EVENT_TOTAL_LEN_OFFSET0
REG_WATCH_EVENT_TYPE_OFFSET4
REG_WATCH_EVENT_NAME_LEN_OFFSET6
REG_WATCH_EVENT_NAME_OFFSET8
REG_WATCH_EVENT_MIN_SIZE8
REG_WATCH_SUBTREE_PATH_DEPTH_REL_OFFSET0
REG_WATCH_SUBTREE_PATH_DEPTH_SIZE2
REG_WATCH_SUBTREE_PATH_COMPONENTS_REL_OFFSET2
REG_WATCH_PATH_COMPONENT_LEN_SIZE2
REG_NOTIFY_VALUE0x01
REG_NOTIFY_SUBKEY0x02
REG_NOTIFY_SD0x04
REG_NOTIFY_ALL0x07

RSI common wire layout.

ConstantValue
RSI_REQUEST_TOTAL_LEN_OFFSET0
RSI_REQUEST_ID_OFFSET4
RSI_REQUEST_OP_CODE_OFFSET12
RSI_REQUEST_TXN_ID_OFFSET14
RSI_REQUEST_HEADER_SIZE22
RSI_RESPONSE_TOTAL_LEN_OFFSET0
RSI_RESPONSE_ID_OFFSET4
RSI_RESPONSE_OP_CODE_OFFSET12
RSI_RESPONSE_HEADER_SIZE14
RSI_RESPONSE_STATUS_OFFSET14
RSI_STATUS_SIZE4
RSI_MIN_RESPONSE_SIZE18
RSI_LENGTH_PREFIX_SIZE4
RSI_GUID_SIZE16
RSI_RESPONSE_BIT0x8000

RSI op codes and response op codes.

ConstantValue
RSI_LOOKUP0x0001
RSI_CREATE_ENTRY0x0002
RSI_HIDE_ENTRY0x0003
RSI_DELETE_ENTRY0x0004
RSI_ENUM_CHILDREN0x0005
RSI_CREATE_KEY0x0010
RSI_READ_KEY0x0011
RSI_WRITE_KEY0x0012
RSI_DROP_KEY0x0013
RSI_QUERY_VALUES0x0020
RSI_SET_VALUE0x0021
RSI_DELETE_VALUE_ENTRY0x0022
RSI_SET_BLANKET_TOMBSTONE0x0023
RSI_BEGIN_TRANSACTION0x0030
RSI_COMMIT_TRANSACTION0x0031
RSI_ABORT_TRANSACTION0x0032
RSI_FLUSH0x0040
RSI_DELETE_LAYER0x0050
RSI_LOOKUP_RESPONSE0x8001
RSI_CREATE_ENTRY_RESPONSE0x8002
RSI_HIDE_ENTRY_RESPONSE0x8003
RSI_DELETE_ENTRY_RESPONSE0x8004
RSI_ENUM_CHILDREN_RESPONSE0x8005
RSI_CREATE_KEY_RESPONSE0x8010
RSI_READ_KEY_RESPONSE0x8011
RSI_WRITE_KEY_RESPONSE0x8012
RSI_DROP_KEY_RESPONSE0x8013
RSI_QUERY_VALUES_RESPONSE0x8020
RSI_SET_VALUE_RESPONSE0x8021
RSI_DELETE_VALUE_ENTRY_RESPONSE0x8022
RSI_SET_BLANKET_TOMBSTONE_RESPONSE0x8023
RSI_BEGIN_TRANSACTION_RESPONSE0x8030
RSI_COMMIT_TRANSACTION_RESPONSE0x8031
RSI_ABORT_TRANSACTION_RESPONSE0x8032
RSI_FLUSH_RESPONSE0x8040
RSI_DELETE_LAYER_RESPONSE0x8050

RSI status codes.

ConstantValue
RSI_OK0
RSI_NOT_FOUND1
RSI_ALREADY_EXISTS2
RSI_STORAGE_ERROR3
RSI_NOT_EMPTY4
RSI_TOO_LARGE5
RSI_TXN_BUSY6
RSI_INVALID7
RSI_CAS_FAILED8
RSI_TXN_NOT_SUPPORTED9

RSI path target types.

ConstantValue
RSI_PATH_TARGET_GUID0
RSI_PATH_TARGET_HIDDEN1

RSI_WRITE_KEY field mask bits.

ConstantValue
RSI_WRITE_KEY_FIELD_SD0x01
RSI_WRITE_KEY_FIELD_LAST_WRITE_TIME0x02
RSI_WRITE_KEY_FIELD_KNOWN_MASK0x00000003

RSI transaction modes and source-registration flags.

ConstantValue
RSI_TXN_READ_WRITE0
RSI_TXN_READ_ONLY1
RSI_HIVE_PRIVATE0x01

Backup record types and magic.

ConstantValue
REG_BACKUP_HEADER0x01
REG_BACKUP_LAYER0x02
REG_BACKUP_KEY0x03
REG_BACKUP_PATH_ENTRY0x04
REG_BACKUP_VALUE0x05
REG_BACKUP_BLANKET_TOMBSTONE0x06
REG_BACKUP_TRAILER0xFF
REG_BACKUP_MAGIC"PEIOSREG"

5.A.5 Tracepoint diagnostic codes #

From uapi/pkm/trace.h. These are a diagnostic contract for ftrace, perf and eBPF consumers, letting a tool decode an lcs: event's reason, op or state field without recompiling against a specific kernel. No LCS syscall accepts or returns them, and values are append-only.

lcs_rsi_request op — which of the 18 RSI dispatch verbs a source-side request admission record describes. Emitted by lcs:lcs_rsi_request on successful queue admission and on the admission error rungs; the rung is read from ret (0 == enqueued, -EAGAIN == in-flight at limit / backpressure, -EIO == source gone / fd closing, -EOVERFLOW == request-id space exhausted, other == build reject). The same op enum tags the round-trip begin marker (lcs_rsi_roundtrip). Never records a pathname, key name, GUID, or frame bytes — only this op code, ids, counts and ret.

ConstantValueNotes
LCS_OP_LOOKUP0RSI_LOOKUP
LCS_OP_READ_KEY1RSI_READ_KEY
LCS_OP_ENUM_CHILDREN2RSI_ENUM_CHILDREN
LCS_OP_QUERY_VALUES3RSI_QUERY_VALUES
LCS_OP_SET_VALUE4RSI_SET_VALUE
LCS_OP_DELETE_VALUE5RSI_DELETE_VALUE_ENTRY
LCS_OP_BLANKET_TOMBSTONE6RSI_SET_BLANKET_TOMBSTONE
LCS_OP_DROP_KEY7RSI_DROP_KEY
LCS_OP_CREATE_ENTRY8RSI_CREATE_ENTRY
LCS_OP_HIDE_ENTRY9RSI_HIDE_ENTRY
LCS_OP_DELETE_ENTRY10RSI_DELETE_ENTRY
LCS_OP_CREATE_KEY11RSI_CREATE_KEY
LCS_OP_WRITE_KEY12RSI_WRITE_KEY
LCS_OP_TXN_BEGIN13RSI_BEGIN_TRANSACTION
LCS_OP_TXN_COMMIT14RSI_COMMIT_TRANSACTION
LCS_OP_TXN_ABORT15RSI_ABORT_TRANSACTION
LCS_OP_FLUSH16RSI_FLUSH
LCS_OP_DELETE_LAYER17RSI_DELETE_LAYER

lcs_rsi_response reason — the outcome of accepting/validating a source's RSI response frame, and the late-response effects that silently mark a source DOWN. ACCEPTED is the clean path; DESYNC / OP_MISMATCH / UNKNOWN_STATUS are the accept-time rejects that all surface as -EINVAL/-EIO; MALFORMED_PAYLOAD is a per-op body validation reject; the LATE_* codes mark a response whose deferred effect (commit/mutation/begin bookkeeping) failed and took the source DOWN. Verdict/outcome is also in ret. Never records name/GUID/frame bytes.

ConstantValueNotes
LCS_RESP_ACCEPTED0response matched an in-flight request
LCS_RESP_DESYNC1no matching delivered/unaccepted record
LCS_RESP_OP_MISMATCH2response op != request op | RESPONSE_BIT
LCS_RESP_UNKNOWN_STATUS3rsi_status not a known status code
LCS_RESP_MALFORMED_PAYLOAD4per-op response body failed validation
LCS_RESP_LATE_COMMIT_FAIL5commit late-effect failed; source DOWN
LCS_RESP_LATE_MUTATION_FAIL6mutation late-effect failed; source DOWN
LCS_RESP_LATE_BEGIN_FAIL7begin-txn late-effect failed; source DOWN

lcs_source_fd reason — which source-fd lifecycle transition a record marks.

OPEN is a fresh /dev/pkm_registry fd; the remaining codes are the entry points that drive a source to the DOWN/closing state. source_down_id is the source id that transitioned DOWN (0 if the call was a no-op). The semantic cause of a late-effect-driven DOWN is carried by lcs_rsi_response (LCS_RESP_LATE_*); here EXPLICIT/MARK_BY_ID are the mechanical transitions. No pathname/SD bytes.

ConstantValueNotes
LCS_SRC_OPEN0new source fd issued (post-TCB check)
LCS_SRC_RELEASE1fd .release() teardown
LCS_SRC_MALFORMED2malformed protocol frame -> mark down
LCS_SRC_EXPLICIT3explicit mark-down of this fd
LCS_SRC_MARK_BY_ID4mark-down requested by source id

lcs_in_flight reason — an in-flight RSI request table transition (kept lean; insert on admission, delivered when handed to the source's read(), release on response completion or teardown). in_flight_count is the post-transition depth. Emitted by lcs:lcs_in_flight.

ConstantValueNotes
LCS_IF_INSERT0request inserted into in-flight table
LCS_IF_DELIVERED1request delivered to source read()
LCS_IF_RELEASE2request released from in-flight table

lcs_route op — which resolution the lcs:lcs_route event describes.

ConstantValueNotes
LCS_ROUTE_HIVE_NAME0hive-name -> source/root resolution
LCS_ROUTE_ABSOLUTE_PATH1absolute-path -> source/root resolution
LCS_ROUTE_SYMLINK_TARGET2symlink-target -> source/root resolution

lcs_registration decision — the source registration path.

NEW/RESUME_DOWN are publish verdicts; COPY is the input-copy stage; REPLAY_FAIL/OVERFLOW_FAIL are resume post-publish -EIO paths that mark the resumed source down. Emitted by lcs:lcs_source_register / _registration_publish / _registration_copy.

ConstantValueNotes
LCS_REG_NEW0new source slot admitted
LCS_REG_RESUME_DOWN1down source slot resumed
LCS_REG_COPY2registration input copied from user
LCS_REG_REPLAY_FAIL3resume pending-delete replay failed (EIO)
LCS_REG_OVERFLOW_FAIL4resume overflow dispatch failed (EIO)

lcs_bootstrap stage — the phase of a bootstrap / self-config refresh.

Emitted by lcs:lcs_bootstrap_refresh / _self_config_refresh / _self_config_publish.

ConstantValueNotes
LCS_BOOT_REGISTRY0registry root discover phase
LCS_BOOT_KMES1kmes config root discover phase
LCS_BOOT_LAYERS2layer metadata root discover phase
LCS_BOOT_SELF_WATCH3self-watch arm phase
LCS_BOOT_COMPLETE4bootstrap refresh completed
LCS_BOOT_SELF_CONFIG_REFRESH5self-config refresh-from-key outcome
LCS_BOOT_SELF_CONFIG_PARAM_INVALID6self-config publish rejected a parameter

lcs_runtime_limits field_id — which runtime-limit field a validate reject names, or LCS_LIM_ALL for a successful whole-struct publish. Emitted by lcs:lcs_limits_validate (-EINVAL, value offending) and lcs:lcs_limits_publish.

ConstantValueNotes
LCS_LIM_REQUEST_TIMEOUT_MS0
LCS_LIM_TRANSACTION_TIMEOUT_MS1
LCS_LIM_NOTIFICATION_QUEUE_SIZE2
LCS_LIM_SYMLINK_DEPTH_LIMIT3
LCS_LIM_MAX_VALUE_SIZE4
LCS_LIM_MAX_KEY_DEPTH5
LCS_LIM_MAX_PATH_COMPONENT_LENGTH6
LCS_LIM_MAX_TOTAL_PATH_LENGTH7
LCS_LIM_MAX_LAYERS_PER_VALUE8
LCS_LIM_MAX_BOUND_TRANSACTIONS_PER_SOURCE9
LCS_LIM_MAX_READ_ONLY_TRANSACTIONS_PER_SOURCE10
LCS_LIM_MAX_TOTAL_LAYERS11
LCS_LIM_MAX_REGISTERED_SOURCES12
LCS_LIM_MAX_HIVES_PER_SOURCE13
LCS_LIM_MAX_CONCURRENT_RSI_REQUESTS14
LCS_LIM_MAX_SCOPE_GUIDS_PER_TOKEN15
LCS_LIM_MAX_PRIVATE_LAYERS_PER_TOKEN16
LCS_LIM_MAX_SUBTREE_WATCH_DEPTH17
LCS_LIM_MAX_TRANSACTION_WATCH_EVENT_BURST18
LCS_LIM_ALL19whole-struct publish (success)

lcs_audit event_type_id — which LCS audit event a record describes.

Emitted by lcs:lcs_audit_emit and lcs:lcs_audit_emit_failed. result_errno carries the op-specific numeric. No SD or raw GUID bytes; key GUID is a u64 hash.

ConstantValueNotes
LCS_AUDIT_KEY_OPEN0key-open SACL audit
LCS_AUDIT_BACKUP_START1
LCS_AUDIT_BACKUP_COMPLETE2
LCS_AUDIT_RESTORE_START3
LCS_AUDIT_RESTORE_COMPLETE4
LCS_AUDIT_VALIDATION_FAILURE5source validation-failure audit
LCS_AUDIT_SELF_CONFIG_INVALID6self-config-invalid audit

lcs_txn state — the transaction-fd state machine state carried in old_state new_state.

Emitted by lcs:lcs_txn_begin / _first_bind / _bind_mutation _commit / _abort / _timeout / _source_down.

ConstantValueNotes
LCS_TXN_ST_ACTIVE_UNBOUND0allocated, not yet source-bound
LCS_TXN_ST_ACTIVE_BOUND1bound to a source + root guid
LCS_TXN_ST_COMMITTED2commit round-trip succeeded
LCS_TXN_ST_ABORTED3aborted (close / layer writer abort)
LCS_TXN_ST_TIMED_OUT4deadline timer or commit timeout
LCS_TXN_ST_SOURCE_DOWN5bound source marked down

lcs_key_fd cmd — the key-fd ioctl verb (also stamped on lcs_key_mutation).

LCS_KCMD_NONE is used by publish/release/read. Never records key/name/SD bytes. Emitted by lcs:lcs_key_ioctl / _mutation.

ConstantValueNotes
LCS_KCMD_NONE0no ioctl verb (publish/release/read)
LCS_KCMD_SET_VALUE1
LCS_KCMD_DELETE_VALUE2
LCS_KCMD_BLANKET_TOMBSTONE3
LCS_KCMD_DELETE_KEY4
LCS_KCMD_HIDE_KEY5
LCS_KCMD_QUERY_VALUE6
LCS_KCMD_QUERY_VALUES_BATCH7
LCS_KCMD_ENUM_VALUES8
LCS_KCMD_ENUM_SUBKEYS9
LCS_KCMD_QUERY_KEY_INFO10
LCS_KCMD_GET_SECURITY11
LCS_KCMD_SET_SECURITY12
LCS_KCMD_FLUSH13
LCS_KCMD_BACKUP14
LCS_KCMD_RESTORE15
LCS_KCMD_NOTIFY16

Appendix 5.B LCS ABI Notes

Peios / Advanced Peios / PKM / LCS

§5.A is generated from pkm/uapi/pkm/lcs.h and holds only what a compiler can measure. This appendix holds the rest.

The split is structural rather than editorial. gen-lcs-abi.py overwrites §5.A wholesale on every run, so anything written there is lost the next time the ABI changes.

5.B.1 What is not here #

The header carries names, numbers and layouts. Everything else about the interface is a property of the implementation rather than of the ABI, and is documented with the operation it belongs to: the required access right for each ioctl and the two-pass output buffer convention in §5.6, the error vocabulary in §5.6.4, the RSI payload shapes in the Registry Source Interface specification, and the backup stream's record payloads in the Registry Backup Format specification.

REG_BACKUP_MAGIC in §5.A is the eight-byte header magic; the record type codes are the framing, not the payloads.

5.B.2 Build configuration #

LCS is built by CONFIG_SECURITY_PKM, a boolean option, so it is linked into vmlinux rather than loaded. CONFIG_RUST=y is required: the resolution core, the RSI codec, the backup serialiser and the transaction log are Rust, staged into the kernel tree as security/pkm/lcs/lcs_core. CONFIG_SECURITY_PKM_KUNIT compiles in the in-kernel test harness.

The three syscall numbers are added to the syscall table by kernel/patches/arch/syscall-table-pkm.patch, which patches both arch/x86/entry/syscalls/syscall_64.tbl and the copy of it that ships under tools/perf/. They are registered common, so they are reachable from the x32 ABI as well as from x86-64.

Peios Learn — documentation for the Peios project.

Built with Trail.