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, viaktime_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 viaoverride_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 withCLONE_THREADshare 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
0xc1type 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 withevent_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
MaxNestingDepthrange; 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
u16type_lenfield and is nonzero. Syscall emitters cannot express an overlong type — the ABI length field is alreadyu16— but the kernel emission API takes asize_tand 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 withENOSPCwhen 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:
- One wall clock timestamp is captured; every event in the batch shares it.
- The three identity GUIDs are captured once and shared.
- 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. - 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.
- 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.
- The privilege gate and rate reservation, above.
event_type_lenis nonzero —EINVALotherwise.- 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 isEINVAL. - The declared size is within
MaxEventSize—ENOSPCotherwise. - The declared size is within 50% of the ring capacity —
ENOSPCotherwise. 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 surfaceENOSPCafter all other validation has passed. - The event type and payload are copied into a kernel staging buffer
(
EFAULTif a pointer is inaccessible,ENOMEMif 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. - The event type is validated as UTF-8 —
EINVALotherwise. - The payload is validated as msgpack within
MaxNestingDepth(§2.2) —EINVALotherwise.
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:
- The SeAuditPrivilege gate, as for
kmes_emit. countis within 1–256 —EINVALotherwise.counttokens are reserved from the rate bucket in one critical section, so concurrent threads cannot both pass the check —EAGAINif unavailable, and nothing is emitted. SeTcbPrivilege exempts as before.- Zero is stored to
*emitted_outbefore any per-entry work —EFAULTif unwritable, with nothing emitted. - The descriptor array is copied from userspace (
EFAULT/ENOMEM). - 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. - 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.
- Unused tokens (
countminus 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 #
- PKM loads. KMES initialises with compiled-in defaults and creates per-CPU rings at the default capacity. They are live immediately.
- The first Machine-hive source registers, making LCS usable. KMES
enumerates every value under
Machine\System\KMES\. - Valid values are applied. A
BufferCapacitydiffering from the current one drives a swap; a matching or absent one changes nothing. - 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\KMESdo not trigger a re-read. - 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.
- 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/.
| Number | Constant | Signature |
|---|---|---|
| 1090 | SYS_KMES_EMIT | kmes_emit(const char __user *event_type, u16 event_type_len, const void __user *payload, u32 payload_len) |
| 1091 | SYS_KMES_ATTACH | kmes_attach(unsigned int cpu_id, u64 __user *capacity) |
| 1092 | SYS_KMES_EMIT_BATCH | kmes_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.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | event_type |
| 8 | 2 | __u16 | event_type_len |
| 10 | 6 | __u8[6] | _pad0 |
| 16 | 8 | __u64 | payload |
| 24 | 4 | __u32 | payload_len |
| 28 | 4 | __u8[4] | _pad1 |
2.A.3 Constants #
Grouped as the header groups them.
Event origin class — kmes_event_header.origin_class.
| Constant | Value |
|---|---|
KMES_ORIGIN_USERSPACE | 0 |
KMES_ORIGIN_KMES | 1 |
KMES_ORIGIN_KACS | 2 |
KMES_ORIGIN_LCS | 3 |
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.
| Constant | Value |
|---|---|
KMES_ATTACH_QUERY_SLOTS | 0xFFFFFFFF |
Largest entry count a single SYS_KMES_EMIT_BATCH call accepts.
| Constant | Value |
|---|---|
KMES_BATCH_MAX_ENTRIES | 256 |
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.
| Constant | Value |
|---|---|
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_DWORD | 4 |
KMES_CONFIG_TYPE_REG_QWORD | 11 |
KMES_CONFIG_BUFFER_CAPACITY_TYPE | 11 |
KMES_CONFIG_BUFFER_CAPACITY_DEFAULT | 4194304 |
KMES_CONFIG_BUFFER_CAPACITY_MIN | 65536 |
KMES_CONFIG_BUFFER_CAPACITY_MAX | 268435456 |
KMES_CONFIG_MAX_EVENT_SIZE_TYPE | 4 |
KMES_CONFIG_MAX_EVENT_SIZE_DEFAULT | 65536 |
KMES_CONFIG_MAX_EVENT_SIZE_MIN | 1024 |
KMES_CONFIG_MAX_EVENT_SIZE_MAX | 4194304 |
KMES_CONFIG_MAX_NESTING_DEPTH_TYPE | 4 |
KMES_CONFIG_MAX_NESTING_DEPTH_DEFAULT | 32 |
KMES_CONFIG_MAX_NESTING_DEPTH_MIN | 4 |
KMES_CONFIG_MAX_NESTING_DEPTH_MAX | 256 |
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_TYPE | 4 |
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_DEFAULT | 10000 |
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_MIN | 100 |
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_MAX | 1000000 |
Privilege requirements.
Values mirror the corresponding KACS privilege bits while keeping this header standalone.
| Constant | Value |
|---|---|
KMES_EMIT_REQUIRED_PRIVILEGE | 0x0000000000200000 (1ULL << 21) |
KMES_ATTACH_REQUIRED_PRIVILEGE | 0x0000000000000100 (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.
| Constant | Value |
|---|---|
KMES_EVENT_SIZE_OFFSET | 0 |
KMES_EVENT_HEADER_SIZE_OFFSET | 4 |
KMES_EVENT_TIMESTAMP_NS_OFFSET | 8 |
KMES_EVENT_SEQUENCE_OFFSET | 16 |
KMES_EVENT_CPU_ID_OFFSET | 24 |
KMES_EVENT_ORIGIN_CLASS_OFFSET | 26 |
KMES_EVENT_EFFECTIVE_TOKEN_GUID_OFFSET | 27 |
KMES_EVENT_TRUE_TOKEN_GUID_OFFSET | 43 |
KMES_EVENT_PROCESS_GUID_OFFSET | 59 |
KMES_EVENT_TYPE_LEN_OFFSET | 75 |
Byte width of each identity GUID in the event header.
| Constant | Value |
|---|---|
KMES_EVENT_GUID_SIZE | 16 |
Byte size of the fixed event header — the offset at which the type string begins.
| Constant | Value |
|---|---|
KMES_EVENT_HEADER_BASE_SIZE | 77 |
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.
| Constant | Value |
|---|---|
KMES_RING_MAGIC | "KMESRING" |
KMES_RING_VERSION | 1 |
KMES_METADATA_PAGE_SIZE | 4096 |
KMES_METADATA_TOTAL_SIZE | 8192 |
KMES_MAPPING_PRODUCER_OFFSET | 0 |
KMES_MAPPING_CONSUMER_OFFSET | 4096 |
KMES_MAPPING_DATA_OFFSET | 8192 |
Field offsets within the producer metadata page.
| Constant | Value |
|---|---|
KMES_PRODUCER_MAGIC_OFFSET | 0 |
KMES_PRODUCER_VERSION_OFFSET | 8 |
KMES_PRODUCER_CPU_ID_OFFSET | 12 |
KMES_PRODUCER_CAPACITY_OFFSET | 16 |
KMES_PRODUCER_DATA_OFFSET_OFFSET | 24 |
KMES_PRODUCER_GENERATION_OFFSET | 32 |
KMES_PRODUCER_WRITE_POS_OFFSET | 64 |
KMES_PRODUCER_TAIL_POS_OFFSET | 72 |
KMES_PRODUCER_FUTEX_COUNTER_OFFSET | 128 |
Field offset within the consumer metadata page.
| Constant | Value |
|---|---|
KMES_CONSUMER_NEED_WAKE_OFFSET | 0 |
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.
| Constant | Value | Notes |
|---|---|---|
KMES_DROP_RING_FULL | 0 | capacity overwrite; oldest event dropped |
KMES_DROP_TAIL_RESYNC | 1 | corrupt ring header; pending events silently discarded |
KMES_DROP_VALIDATE | 2 | single kernel-emit size/type reject |
KMES_DROP_BATCH_STRUCT_INVALID | 3 | kernel-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.
| Constant | Value | Notes |
|---|---|---|
KMES_SWAP_BEGIN | 0 | capacity change accepted, rings allocating |
KMES_SWAP_COMPLETE | 1 | swap committed across all CPUs |
KMES_SWAP_MIGRATE_SKIP | 2 | shrink: old event too large, skipped (ret=bytes) |
KMES_SWAP_FAILED | 3 | swap 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.
| Constant | Value | Notes |
|---|---|---|
KMES_RATE_THROTTLE | 0 | emit denied -EAGAIN; tokens < requested |
KMES_RATE_RECONFIGURE | 1 | max 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.
| Constant | Value | Notes |
|---|---|---|
KMES_WAKE_NOTE | 0 | wake armed; futex counter incremented |
KMES_WAKE_FUTEX | 1 | blocked consumers woken |
kmes_ring_lifecycle reason — a generation-stable ring object transition.
ret is the outcome. Emitted by kmes:kmes_ring_lifecycle.
| Constant | Value | Notes |
|---|---|---|
KMES_RING_ALLOC | 0 | ring backing allocated (or -ENOMEM) |
KMES_RING_FREE | 1 | ring backing released |
KMES_RING_PRODUCER_PAGE | 2 | producer shmem/meta page attached |
KMES_RING_CONSUMER_FD | 3 | consumer 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.
| Constant | Value | Notes |
|---|---|---|
KMES_INGRESS_OVER_MAX | 0 | event_size exceeds configured max_event_size |
KMES_INGRESS_OVER_CAP_HALF | 1 | event_size exceeds ring_capacity/2 |
KMES_INGRESS_SIZE_OVERFLOW | 2 | declared header/event size overflow |
KMES_INGRESS_EMIT_OVERSIZE | 3 | staged event exceeds live capacity/2 at emit |
KMES_INGRESS_BATCH_PARTIAL | 4 | batch 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.
| Constant | Value | Notes |
|---|---|---|
KMES_VAL_EINVAL | 0 | Rust 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 #
| Syscall | Parameter | Type | Meaning |
|---|---|---|---|
kmes_emit | event_type | const char * | Event type string. |
event_type_len | u16 | Its length in bytes. | |
payload | const void * | MessagePack payload. | |
payload_len | u32 | Its length in bytes. | |
kmes_emit_batch | entries | struct kmes_emit_entry * | Array of event descriptors. |
count | u32 | Number of entries, 1 to KMES_BATCH_MAX_ENTRIES. | |
emitted_out | u32 * | Receives the number of events emitted. | |
kmes_attach | cpu_id | unsigned int | Ring slot index, or KMES_ATTACH_QUERY_SLOTS to query the slot count. |
capacity | u64 * | 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.
| Operation | Required privilege | Bit |
|---|---|---|
kmes_emit, kmes_emit_batch | SeAuditPrivilege | 21 |
| Rate-limit exemption on both | SeTcbPrivilege | 7 |
kmes_attach | SeSecurityPrivilege | 8 |
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.
| Quantity | Value | Where |
|---|---|---|
| Maximum event size, structural | 50% of ring capacity | kmes/kmes.c |
| MessagePack validator nesting stack | 256 | kmes/kmes_validate.rs |
| Self-configuration payload buffer | 768 bytes | kmes/kmes.c |
| Self-configuration audit intents per read | 4 | kmes/kmes.h |
| Self-configuration parameter name | 64 bytes | kmes/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.
| Key | Effect |
|---|---|
BufferCapacity | Per-CPU ring size in bytes. Must be a power of two. Changing it swaps every ring; see §2.6. |
MaxEventSize | Largest event a syscall emitter may produce. |
MaxNestingDepth | Deepest MessagePack container nesting the validator will accept. |
MaxEmitRatePerProcess | Token-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 #
| Errno | Condition |
|---|---|
EPERM | SeAuditPrivilege not held or not enabled, or recording its used state failed. |
EAGAIN | Per-process rate limit exceeded. |
EINVAL | Zero event type length, event type not valid UTF-8, declared size arithmetic overflowed, payload not valid msgpack, or nesting depth over MaxNestingDepth. |
EFAULT | Event type or payload pointer inaccessible. |
ENOSPC | Event exceeds MaxEventSize or 50% of ring capacity. |
ENOMEM | Staging buffer allocation failed, or KMES not initialised. |
2.B.5.2 kmes_emit_batch #
| Errno | Condition |
|---|---|
EPERM | As kmes_emit. |
EAGAIN | Fewer than count tokens available. |
EINVAL | count is 0 or over KMES_BATCH_MAX_ENTRIES, or the failing entry hit one of kmes_emit's EINVAL conditions. |
EFAULT | emitted_out, the entry array, or the failing entry's type or payload pointer inaccessible. |
ENOSPC | The failing entry exceeds MaxEventSize or 50% of ring capacity. |
ENOMEM | Kernel allocation failed, or KMES not initialised. |
2.B.5.3 kmes_attach #
| Errno | Condition |
|---|---|
EPERM | SeSecurityPrivilege not held or not enabled, or recording its used state failed. |
EINVAL | cpu_id at or beyond the ring array size, or its slot holds no live ring. |
EFAULT | capacity pointer inaccessible. |
ENOMEM | Kernel 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) #
| Field | Type | Description |
|---|---|---|
user_sid | SID | The token's primary identity. |
user_deny_only | bool | When true, the user SID matches only deny ACEs, never allow ACEs. Set at creation by CreateToken or FilterToken. True whenever write_restricted is true. |
groups | SID_AND_ATTRIBUTES[] | Group memberships. The set of SIDs is fixed at creation; the per-group attribute flags are adjustable. |
restricted_sids | SID_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_restricted | bool | When 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) #
| Field | Type | Description |
|---|---|---|
token_type | enum | Primary or Impersonation. |
impersonation_level | enum | Anonymous, Identification, Impersonation, or Delegation. Primary tokens always carry Anonymous. |
3.2.2.3 Integrity (fixed) #
| Field | Type | Description |
|---|---|---|
integrity_level | uint | Numeric 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_policy | flags | NO_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) #
| Field | Type | Description |
|---|---|---|
elevation_type | enum | Default (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) #
| Field | Description |
|---|---|
owner_sid_index | Index 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_index | Index into [user_sid, groups...] selecting the default primary group. The referenced SID is the user SID or any group SID on the token. |
default_dacl | The 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) #
| Field | Type | Description |
|---|---|---|
token_id | LUID | Unique identifier for this token instance. |
token_guid | UUID | 128-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_id | LUID | The LogonSession LUID, linking the token to the authentication event that produced it. |
source | TOKEN_SOURCE | Who minted the token: an 8-character name plus a LUID. |
created_at | timestamp | When the original token was minted by CreateToken. Copied unchanged by DuplicateToken, FilterToken, and NEW_PROCESS_MIN, so it tracks original minting rather than duplication. |
expiration | timestamp | When the token becomes invalid; zero means no expiry. Not enforced by AccessCheck. |
origin | LUID | The originating LogonSession for derived tokens, such as S4U or network logon. |
3.2.2.8 Mutation tracking (adjustable) #
| Field | Description |
|---|---|
modified_id | Counter 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) #
| Field | Description |
|---|---|
interactivity_scope | 0 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) #
| Field | Type | Description |
|---|---|---|
user_claims | CLAIM_ATTRIBUTES[] | Name-value pairs from the user's directory object, fed into conditional ACE evaluation. |
device_claims | CLAIM_ATTRIBUTES[] | Name-value pairs from the machine's directory object. |
3.2.2.11 LCS registry credentials (fixed) #
| Field | Type | Description |
|---|---|---|
lcs_scope_guids | GUID[] | 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_layers | string[] | 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) #
| Field | Type | Description |
|---|---|---|
device_groups | SID_AND_ATTRIBUTES[]? | The machine's group memberships, for compound identity. |
restricted_device_groups | SID_AND_ATTRIBUTES[]? | Filtered device groups for restricted tokens. |
3.2.2.13 Confinement (fixed) #
| Field | Type | Description |
|---|---|---|
confinement_sid | SID? | 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_capabilities | SID_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_boundary | bool | Adds 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_exempt | bool | Escape 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) #
| Field | Type | Description |
|---|---|---|
audit_policy | u32 | Per-token audit overrides as a bitmask, fixed at creation — no adjustment operation exists. |
| Flag | Value | Description |
|---|---|---|
OBJECT_ACCESS_SUCCESS | 0x0001 | Audit successful object access. |
OBJECT_ACCESS_FAILURE | 0x0002 | Audit failed object access. |
PRIVILEGE_USE_SUCCESS | 0x0004 | Audit successful privilege exercises: the privilege contributed requested bits that survive into the final granted result. |
PRIVILEGE_USE_FAILURE | 0x0008 | Audit 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) #
| Field | Description |
|---|---|
projected_uid | Linux UID for the user SID, precomputed by authd when the token is minted (PSPU §2); 65534 for the anonymous identity. |
projected_gid | Linux primary GID, precomputed the same way; 65534 for the anonymous identity. |
projected_supplementary_gids | Linux 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) #
| Field | Description |
|---|---|
security_descriptor | The token's own SD, controlling who may query, adjust, duplicate, or impersonate it. |
3.2.2.17 Internal #
| Field | Description |
|---|---|
refcount | Reference 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.
- 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.
- If the file's level is lower than the token's, a new token is
created following DuplicateToken semantics — new
token_id, newtoken_guid,modified_idinitialised to the newtoken_id,elevation_typereset to Default — withintegrity_levelset to the file's label. Every other field is copied from the source, and the original token is dropped. - 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.
| LUID | Value | Description |
|---|---|---|
SYSTEM_LUID | 999 (0x3E7) | The SYSTEM LogonSession, created at kernel init. |
ANONYMOUS_LOGON_LUID | 998 (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:
- authd decides a session has to end — an admin request, a security incident, an account deletion, or a user logging off.
- authd enumerates processes whose tokens carry the target
auth_idorinteractivity_scopeby walking/proc/*/token, opening each node's query-only inspection handle, and readingTokenStatistics, which includesauth_id. No dedicated enumeration syscall exists or is needed. - authd requests termination — through peinit for supervised services, through signals for user processes.
- The processes terminate, dropping their token references.
- 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 #
| Right | Value | Grants |
|---|---|---|
TOKEN_ASSIGN_PRIMARY | 0x0001 | Install as a process's primary token. Also requires SeAssignPrimaryTokenPrivilege on the caller's token. |
TOKEN_DUPLICATE | 0x0002 | Duplicate the token, or create a restricted copy with FilterToken. |
TOKEN_IMPERSONATE | 0x0004 | Install as a thread's impersonation token. |
TOKEN_QUERY | 0x0008 | Read token information: SIDs, groups, privileges, integrity, claims, source, statistics, elevation type. |
TOKEN_ADJUST_PRIVILEGES | 0x0020 | Enable, disable, or permanently remove privileges. |
TOKEN_ADJUST_GROUPS | 0x0040 | Enable or disable groups. |
TOKEN_ADJUST_DEFAULT | 0x0080 | Change the default DACL, owner SID, and primary group SID. |
TOKEN_ADJUST_INTERACTIVITY_SCOPE | 0x0100 | Change 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 right | Maps to |
|---|---|
GENERIC_READ | TOKEN_QUERY | READ_CONTROL |
GENERIC_WRITE | TOKEN_ADJUST_PRIVILEGES | TOKEN_ADJUST_GROUPS | TOKEN_ADJUST_DEFAULT | WRITE_DAC |
GENERIC_EXECUTE | TOKEN_IMPERSONATE |
GENERIC_ALL | TOKEN_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) #
| Field | Type | Description |
|---|---|---|
process_guid | UUID | 128-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) #
| Field | Type | Description |
|---|---|---|
pip_type | u32 | Process Integrity Protection type. Determined by the binary's cryptographic signature at exec. |
pip_trust | uint | Trust 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) #
| Field | Description |
|---|---|
lsv | Library 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. |
wxp | Write-XOR-Execute Protection. No page is simultaneously writable and executable; W+X mappings and transitions between writable and executable are rejected. |
tlp | Trusted Library Paths. Shared libraries load only from approved directory prefixes. Weaker than LSV, since it trusts the path rather than the binary. |
cfif | Forward-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. |
cfib | Backward-Edge Control Flow Integrity. The hardware shadow stack, Intel CET, is locked on and cannot be disabled by the process. |
pie | Position-Independent Executable Requirement. Non-PIE binaries are rejected at exec, so that ASLR is actually effective. |
sml | Speculation 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) #
| Field | Description |
|---|---|
ui_access | Permits 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) #
| Field | Description |
|---|---|
no_child_process | Once 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) #
| Field | Description |
|---|---|
virtualization | Per-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 #
| Right | Value | Meaning |
|---|---|---|
PROCESS_TERMINATE | 0x0001 | Send signals whose default action is termination. |
PROCESS_SIGNAL | 0x0002 | Send informational signals whose default action is to ignore: SIGCHLD, SIGURG, SIGWINCH. |
PROCESS_VM_READ | 0x0010 | Read process memory — ptrace peek, /proc/<pid>/mem, process_vm_readv. |
PROCESS_VM_WRITE | 0x0020 | Write process memory — ptrace poke, /proc/<pid>/mem, process_vm_writev. Includes debugger attach. |
PROCESS_DUP_HANDLE | 0x0040 | Extract file descriptors from the process through pidfd_getfd. |
PROCESS_SET_INFORMATION | 0x0200 | Change 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_INFORMATION | 0x0400 | Inspect 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_RESUME | 0x0800 | Send signals whose default action is to stop or continue. |
PROCESS_QUERY_LIMITED | 0x1000 | Read 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_CONTROL | 0x20000 | Read the process's own descriptor. |
WRITE_DAC | 0x40000 | Modify the process's DACL. |
WRITE_OWNER | 0x80000 | Change 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 | # | Default | Notes |
|---|---|---|---|
SIGHUP | 1 | Terminate | Session hangup |
SIGINT | 2 | Terminate | Ctrl-C |
SIGQUIT | 3 | Terminate + core | Quit request |
SIGILL | 4 | Terminate + core | Illegal instruction |
SIGTRAP | 5 | Terminate + core | Debug trap |
SIGABRT | 6 | Terminate + core | Abort |
SIGBUS | 7 | Terminate + core | Bus error |
SIGFPE | 8 | Terminate + core | Floating point exception |
SIGKILL | 9 | Terminate | Forced kill, cannot be caught |
SIGUSR1 | 10 | Terminate | User-defined |
SIGSEGV | 11 | Terminate + core | Segfault |
SIGUSR2 | 12 | Terminate | User-defined |
SIGPIPE | 13 | Terminate | Broken pipe |
SIGALRM | 14 | Terminate | Alarm timer |
SIGTERM | 15 | Terminate | Graceful termination request |
SIGSTKFLT | 16 | Terminate | Stack fault |
SIGXCPU | 24 | Terminate + core | CPU time exceeded |
SIGXFSZ | 25 | Terminate + core | File size exceeded |
SIGVTALRM | 26 | Terminate | Virtual timer |
SIGPROF | 27 | Terminate | Profiling timer |
SIGIO | 29 | Terminate | I/O possible |
SIGPWR | 30 | Terminate | Power failure |
SIGSYS | 31 | Terminate + core | Bad syscall |
PROCESS_SUSPEND_RESUME — default action stop or continue:
| Signal | # | Default | Notes |
|---|---|---|---|
SIGSTOP | 19 | Stop | Forced stop, cannot be caught |
SIGTSTP | 20 | Stop | Terminal stop, Ctrl-Z |
SIGTTIN | 21 | Stop | Background read from terminal |
SIGTTOU | 22 | Stop | Background write to terminal |
SIGCONT | 18 | Continue | Resume a stopped process |
PROCESS_SIGNAL — default action ignore:
| Signal | # | Default | Notes |
|---|---|---|---|
SIGCHLD | 17 | Ignore | Child status change |
SIGURG | 23 | Ignore | Urgent socket data |
SIGWINCH | 28 | Ignore | Window 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 right | Maps to |
|---|---|
GENERIC_READ | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | READ_CONTROL |
GENERIC_WRITE | PROCESS_SET_INFORMATION | PROCESS_VM_WRITE | WRITE_DAC |
GENERIC_EXECUTE | PROCESS_TERMINATE | PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED |
GENERIC_ALL | every 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 #
| Privilege | Bit | Mask | Enforcement |
|---|---|---|---|
SeCreateTokenPrivilege | 2 | 0x4 | Kernel standalone |
SeAssignPrimaryTokenPrivilege | 3 | 0x8 | Kernel standalone |
SeImpersonatePrivilege | 29 | 0x20000000 | Kernel 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 #
| Privilege | Bit | Mask | Enforcement |
|---|---|---|---|
SeSecurityPrivilege | 8 | 0x100 | AccessCheck + standalone |
SeTakeOwnershipPrivilege | 9 | 0x200 | AccessCheck |
SeBackupPrivilege | 17 | 0x20000 | AccessCheck + standalone |
SeRestorePrivilege | 18 | 0x40000 | AccessCheck + standalone |
SeRelabelPrivilege | 32 | 0x1_0000_0000 | AccessCheck + standalone |
SeChangeNotifyPrivilege | 23 | 0x800000 | Kernel standalone |
SeCreateSymbolicLinkPrivilege | 35 | 0x8_0000_0000 | Kernel 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 #
| Privilege | Bit | Mask | Enforcement |
|---|---|---|---|
SeTcbPrivilege | 7 | 0x80 | Kernel standalone |
SeLockMemoryPrivilege | 4 | 0x10 | Kernel standalone |
SeIncreaseQuotaPrivilege | 5 | 0x20 | Kernel standalone |
SeLoadDriverPrivilege | 10 | 0x400 | Kernel standalone |
SeSystemProfilePrivilege | 11 | 0x800 | Kernel standalone |
SeSystemtimePrivilege | 12 | 0x1000 | Kernel standalone |
SeProfileSingleProcessPrivilege | 13 | 0x2000 | Kernel standalone |
SeIncreaseBasePriorityPrivilege | 14 | 0x4000 | Kernel standalone |
SeManageVolumePrivilege | 28 | 0x10000000 | Kernel standalone |
SeShutdownPrivilege | 19 | 0x80000 | Kernel standalone |
SeDebugPrivilege | 20 | 0x100000 | Kernel standalone |
SeAuditPrivilege | 21 | 0x200000 | Kernel standalone |
SeRemoteShutdownPrivilege | 24 | 0x1000000 | Kernel 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 #
| Privilege | Bit | Mask | Enforcement |
|---|---|---|---|
SeBindPrivilegedPortPrivilege | 63 | 0x8000_0000_0000_0000 | Kernel 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 #
| Privilege | Bit | Enforcement |
|---|---|---|
SeSyncAgentPrivilege | 26 | Application-level |
SeEnableDelegationPrivilege | 27 | Application-level |
SeMachineAccountPrivilege | 6 | Application-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.
| Privilege | Bit | Reservation rationale |
|---|---|---|
SeCreatePagefilePrivilege | 15 | Absorbed into SeTcbPrivilege. |
SeCreatePermanentPrivilege | 16 | No Linux equivalent. |
SeSystemEnvironmentPrivilege | 22 | Gated by descriptors on efivar files under FACS. |
SeUndockPrivilege | 25 | Server operating system. |
SeCreateGlobalPrivilege | 30 | Peios has no per-LogonSession object namespaces. |
SeTrustedCredManAccessPrivilege | 31 | Reserved for future secrets infrastructure. |
SeIncreaseWorkingSetPrivilege | 33 | Linux does not gate memory residency hints. |
SeTimeZonePrivilege | 34 | Linux 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_sigreturnsENOENTon 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.
| SID | Level | Name |
|---|---|---|
S-1-16-0 | 0 | Untrusted |
S-1-16-4096 | 4096 | Low |
S-1-16-8192 | 8192 | Medium |
S-1-16-12288 | 12288 | High |
S-1-16-16384 | 16384 | System |
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 #
| Bit | Value | Meaning |
|---|---|---|
SYSTEM_MANDATORY_LABEL_NO_READ_UP | 0x00000001 | Non-dominant callers receive no read-mapped rights from the DACL. |
SYSTEM_MANDATORY_LABEL_NO_WRITE_UP | 0x00000002 | Non-dominant callers receive no write-mapped rights from the DACL. |
SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP | 0x00000004 | Non-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:
- Impersonation level gate. An impersonation token at Identification level is denied immediately. Anonymous tokens proceed through the full pipeline.
- Input validation. Reject a descriptor with no owner. A null group SID is valid and has no direct effect on the decision.
- Generic mapping. Map generic bits in the desired mask to
object-specific bits; strip
MAXIMUM_ALLOWED. - Effective privileges. Clear the backup and restore bits when the corresponding intent flag is absent.
- Privilege grants. Resolve
ACCESS_SYSTEM_SECURITY, backup and restore. Seeddecided,grantedandprivilege_granted. - 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.
- Virtual group resolution.
S-1-3-4andS-1-5-10become matchable where the caller is the owner or the object's principal. - Tree initialisation. Seed each node from the scalar state.
- Normal DACL evaluation. Owner implicit rights, then the walk.
- Post-DACL WRITE_OWNER override.
SeTakeOwnershipPrivilegegrantsWRITE_OWNERif the DACL did not and no mandatory mechanism blocked it. - Restricted token pass, with intersection and privilege restoration.
- Confinement pass, with absolute intersection.
- CAAP. Evaluate each applicable rule's DACL through the full per-descriptor pipeline and intersect; collect SACLs.
- Privilege-use auditing.
- Audit emission, over the object's SACL and any CAAP SACLs.
- 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 #
| Variable | Set at | Meaning |
|---|---|---|
security_granted | Step 4 | SeSecurityPrivilege granted ACCESS_SYSTEM_SECURITY. |
backup_granted | Step 4 | SeBackupPrivilege granted read bits. |
restore_granted | Step 4 | SeRestorePrivilege granted write and metadata bits. |
take_ownership_granted | Step 9 | SeTakeOwnershipPrivilege granted WRITE_OWNER. |
relabel_granted | Step 5 | SeRelabelPrivilege 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 #
| Value | Name | If it exists | If it does not |
|---|---|---|---|
| 0 | FILE_SUPERSEDE | Delete and recreate | Create |
| 1 | FILE_OPEN | Open | Fail |
| 2 | FILE_CREATE | Fail | Create |
| 3 | FILE_OPEN_IF | Open | Create |
| 4 | FILE_OVERWRITE | Truncate to zero | Fail |
| 5 | FILE_OVERWRITE_IF | Truncate to zero | Create |
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 #
| Value | Name | Description |
|---|---|---|
| 0x0001 | KACS_CREATE_OPT_DIRECTORY | The 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. |
| 0x0002 | KACS_CREATE_OPT_DELETE_ON_CLOSE | Delete 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:
| Flags | Core rights |
|---|---|
O_RDONLY | FILE_READ_DATA | FILE_READ_ATTRIBUTES |
O_WRONLY | FILE_WRITE_DATA | FILE_READ_ATTRIBUTES |
O_RDWR | FILE_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 #
| Operation | Required right |
|---|---|
| Read | FILE_READ_DATA |
| Sequential write, no append intent | FILE_WRITE_DATA |
Append-intent write (O_APPEND descriptor or RWF_APPEND) | FILE_APPEND_DATA or FILE_WRITE_DATA |
| Positioned write, or a no-append override | FILE_WRITE_DATA — denied on append-only descriptors |
| Directory listing | FILE_LIST_DIRECTORY |
ftruncate | FILE_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_READ | FILE_READ_DATA |
mmap PROT_WRITE | MAP_SHARED | FILE_WRITE_DATA — FILE_APPEND_DATA alone is insufficient |
mmap PROT_WRITE | MAP_PRIVATE | FILE_READ_DATA — copy-on-write, no write to the file |
mmap PROT_EXEC | FILE_EXECUTE |
mprotect | As mmap, for the new protection flags |
flock LOCK_SH / F_RDLCK | FILE_READ_DATA |
flock LOCK_EX / F_WRLCK | FILE_WRITE_DATA or FILE_APPEND_DATA |
fsync / fdatasync | SYNCHRONIZE |
A fallocate mode outside the supported set fails closed, and
PUNCH_HOLE additionally requires KEEP_SIZE.
3.9.4.2 Metadata operations #
| Operation | Required right |
|---|---|
stat / lstat / path statx | FILE_READ_ATTRIBUTES |
fstat / descriptor statx | FILE_READ_ATTRIBUTES |
fstatfs | FILE_READ_ATTRIBUTES |
path and descriptor file_getattr | FILE_READ_ATTRIBUTES |
path and descriptor file_setattr | FILE_WRITE_ATTRIBUTES |
truncate by pathname | FILE_WRITE_DATA |
chmod / fchmodat / fchmod | WRITE_DAC |
chown / lchown / fchownat / fchown | WRITE_OWNER |
utimensat / utimes / futimens | FILE_WRITE_ATTRIBUTES |
getxattr / lgetxattr / fgetxattr | FILE_READ_EA |
setxattr / lsetxattr / removexattr / fsetxattr / fremovexattr | FILE_WRITE_EA |
listxattr / llistxattr / flistxattr | none |
access / faccessat F_OK | FILE_READ_ATTRIBUTES |
access / faccessat R_OK | FILE_READ_DATA |
access / faccessat W_OK | FILE_WRITE_DATA |
access / faccessat X_OK | FILE_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:
| Command | Required right |
|---|---|
F_GETLK / F_GETLK64 / F_OFD_GETLK | Any data right |
F_GETLEASE / F_GETDELEG | FILE_READ_ATTRIBUTES |
F_GETPIPE_SZ | FILE_READ_ATTRIBUTES |
F_SETPIPE_SZ | FILE_WRITE_ATTRIBUTES |
F_GET_SEALS | FILE_READ_ATTRIBUTES |
F_ADD_SEALS | FILE_WRITE_ATTRIBUTES |
F_GET_RW_HINT / F_GET_FILE_RW_HINT | FILE_READ_ATTRIBUTES |
F_SET_RW_HINT / F_SET_FILE_RW_HINT | FILE_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:
| ioctl | Required right |
|---|---|
FIBMAP | FILE_READ_DATA |
FIGETBSZ | FILE_READ_ATTRIBUTES |
FIFREEZE / FITHAW | FILE_WRITE_ATTRIBUTES |
FITRIM | FILE_WRITE_ATTRIBUTES |
FS_IOC_GETFSUUID | FILE_READ_ATTRIBUTES |
FS_IOC_GETFSSYSFSPATH | FILE_READ_ATTRIBUTES |
FS_IOC_GETLBMD_CAP | FILE_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:
| ioctl | Required right |
|---|---|
FS_IOC_FIEMAP | FILE_READ_DATA |
FIONREAD | FILE_READ_DATA on a regular file; any data right otherwise |
FS_IOC_GETFLAGS | FILE_READ_ATTRIBUTES |
FS_IOC_SETFLAGS | FILE_WRITE_ATTRIBUTES |
FS_IOC_GETVERSION | FILE_READ_ATTRIBUTES |
FS_IOC_SETVERSION | FILE_WRITE_ATTRIBUTES |
FS_IOC_RESVSP / FS_IOC_RESVSP64 | FILE_APPEND_DATA or FILE_WRITE_DATA |
FS_IOC_UNRESVSP / FS_IOC_UNRESVSP64 | FILE_WRITE_DATA |
FS_IOC_ZERO_RANGE | FILE_WRITE_DATA |
FICLONE / FICLONERANGE | FILE_WRITE_DATA |
FIDEDUPERANGE | FILE_WRITE_DATA |
FIOQSIZE | FILE_READ_ATTRIBUTES |
FS_IOC_FSGETXATTR | FILE_READ_ATTRIBUTES |
FS_IOC_FSSETXATTR | FILE_WRITE_ATTRIBUTES |
FS_IOC_GETFSLABEL | FILE_READ_ATTRIBUTES |
FS_IOC_SETFSLABEL | FILE_WRITE_ATTRIBUTES |
FS_IOC_GET_ENCRYPTION_PWSALT | FILE_READ_ATTRIBUTES |
FS_IOC_GET_ENCRYPTION_POLICY | FILE_READ_ATTRIBUTES |
FS_IOC_GET_ENCRYPTION_POLICY_EX | FILE_READ_ATTRIBUTES |
FS_IOC_SET_ENCRYPTION_POLICY | FILE_WRITE_ATTRIBUTES |
FS_IOC_ADD_ENCRYPTION_KEY | FILE_WRITE_ATTRIBUTES |
FS_IOC_REMOVE_ENCRYPTION_KEY | FILE_WRITE_ATTRIBUTES |
FS_IOC_REMOVE_ENCRYPTION_KEY_ALL_USERS | FILE_WRITE_ATTRIBUTES |
FS_IOC_GET_ENCRYPTION_KEY_STATUS | FILE_READ_ATTRIBUTES |
BLKGETSIZE64 | FILE_READ_ATTRIBUTES |
BLKFLSBUF | FILE_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.
| Flag | Component | Required right |
|---|---|---|
OWNER_SECURITY_INFORMATION | Owner SID | WRITE_OWNER |
GROUP_SECURITY_INFORMATION | Group SID | WRITE_OWNER |
DACL_SECURITY_INFORMATION | Discretionary ACL | WRITE_DAC |
SACL_SECURITY_INFORMATION | System ACL | ACCESS_SYSTEM_SECURITY |
LABEL_SECURITY_INFORMATION | Mandatory integrity label | WRITE_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:
| Phase | Objects admitted |
|---|---|
| Source read | The pinned provider only. |
| Create | One 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. |
| Populate | The pinned provider and the one staged object bound to the context. |
| Publish by link | The bound staged object, one destination parent, one absent destination dentry. |
| Publish by rename | The bound staged object and its parent, one destination parent, one absent destination dentry. |
| Cleanup | One bound staging or materialisation dentry and its exact parent. |
| Orphan cleanup | One positive provider dentry carrying an authenticated stale staging marker, and its exact parent. |
| Orphan-marker cleanup | The 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 action | Enforcement points |
|---|---|
| Open and read provider data or a symlink target | security_inode_permission, security_file_open, security_file_permission, security_inode_readlink |
| Read provider attributes and extended attributes | the inode and file getattr, setattr-preflight, getxattr and listxattr paths as applicable |
| Create a staged file, directory or symlink, and materialise parents | security_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 xattrs | security_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. |
| Publish | security_inode_permission on the exact staged object or destination parent, then security_inode_link or security_inode_rename |
| Remove staging or roll back materialised entries | security_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:
| CAP | Name | Rationale |
|---|---|---|
| 0 | CAP_CHOWN | KACS file hooks enforce. |
| 1 | CAP_DAC_OVERRIDE | KACS file hooks enforce. |
| 2 | CAP_DAC_READ_SEARCH | KACS file hooks enforce. |
| 3 | CAP_FOWNER | KACS file hooks enforce. |
| 4 | CAP_FSETID | KACS file hooks enforce. |
| 5 | CAP_KILL | The task_kill hook enforces. |
| 6 | CAP_SETGID | Cosmetic under KACS. |
| 7 | CAP_SETUID | Cosmetic under KACS. |
| 11 | CAP_NET_BROADCAST | Unused in modern kernels. |
| 15 | CAP_IPC_OWNER | KACS IPC hooks enforce. |
| 28 | CAP_LEASE | KACS file hooks enforce. |
PRIVILEGE capabilities gate operations no other KACS hook covers, and map to a KACS privilege:
| CAP | Name | Privilege |
|---|---|---|
| 9 | CAP_LINUX_IMMUTABLE | SeTcbPrivilege |
| 10 | CAP_NET_BIND_SERVICE | SeBindPrivilegedPortPrivilege |
| 12 | CAP_NET_ADMIN | SeTcbPrivilege |
| 13 | CAP_NET_RAW | SeTcbPrivilege |
| 14 | CAP_IPC_LOCK | SeLockMemoryPrivilege |
| 16 | CAP_SYS_MODULE | SeLoadDriverPrivilege |
| 17 | CAP_SYS_RAWIO | SeTcbPrivilege |
| 18 | CAP_SYS_CHROOT | SeTcbPrivilege |
| 19 | CAP_SYS_PTRACE | SeDebugPrivilege |
| 20 | CAP_SYS_PACCT | SeTcbPrivilege |
| 21 | CAP_SYS_ADMIN | SeTcbPrivilege (but see the mount note below) |
| 22 | CAP_SYS_BOOT | SeShutdownPrivilege |
| 23 | CAP_SYS_NICE | SeIncreaseBasePriorityPrivilege |
| 24 | CAP_SYS_RESOURCE | SeIncreaseQuotaPrivilege |
| 25 | CAP_SYS_TIME | SeSystemtimePrivilege |
| 26 | CAP_SYS_TTY_CONFIG | SeTcbPrivilege |
| 27 | CAP_MKNOD | SeTcbPrivilege |
| 29 | CAP_AUDIT_WRITE | SeAuditPrivilege |
| 30 | CAP_AUDIT_CONTROL | SeSecurityPrivilege |
| 33 | CAP_MAC_ADMIN | SeSecurityPrivilege |
| 34 | CAP_SYSLOG | SeTcbPrivilege |
| 35 | CAP_WAKE_ALARM | SeTcbPrivilege |
| 36 | CAP_BLOCK_SUSPEND | SeTcbPrivilege |
| 37 | CAP_AUDIT_READ | SeSecurityPrivilege |
| 38 | CAP_PERFMON | SeSystemProfilePrivilege or SeProfileSingleProcessPrivilege or SeLoadDriverPrivilege |
| 39 | CAP_BPF | SeTcbPrivilege |
| 40 | CAP_CHECKPOINT_RESTORE | SeTcbPrivilege |
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.
| Mechanism | With privilege | Without privilege |
|---|---|---|
setuid() syscall | Designed: all UIDs and token change. Actual: fails with EOPNOTSUPP. | Silent no-op |
| Setuid bit on exec | Designed: 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/.
| Number | Constant | Signature |
|---|---|---|
| 1000 | SYS_KACS_OPEN_SELF_TOKEN | kacs_open_self_token(unsigned int flags, u32 access_mask) |
| 1001 | SYS_KACS_OPEN_PROCESS_TOKEN | kacs_open_process_token(int pidfd, u32 access_mask) |
| 1002 | SYS_KACS_OPEN_THREAD_TOKEN | kacs_open_thread_token(int pidfd, int tid, u32 access_mask) |
| 1003 | SYS_KACS_CREATE_TOKEN | kacs_create_token(const void __user *spec, size_t spec_len) |
| 1004 | SYS_KACS_CREATE_LOGON_SESSION | kacs_create_logon_session(const void __user *spec, size_t spec_len) |
| 1005 | SYS_KACS_SET_PSB | kacs_set_psb(int pidfd, u32 mitigations) |
| 1006 | SYS_KACS_DESTROY_EMPTY_LOGON_SESSION | kacs_destroy_empty_logon_session(u64 auth_id) |
| 1010 | SYS_KACS_OPEN_PEER_TOKEN | kacs_open_peer_token(int sock_fd) |
| 1011 | SYS_KACS_IMPERSONATE_PEER | kacs_impersonate_peer(int sock_fd) |
| 1012 | SYS_KACS_REVERT | kacs_revert(void) |
| 1013 | SYS_KACS_SET_IMPERSONATION_LEVEL | kacs_set_impersonation_level(int sock_fd, u32 level) |
| 1020 | SYS_KACS_OPEN | kacs_open(int dirfd, const char __user *path, struct kacs_open_how __user *uhow, size_t howsize, u32 __user *status_out) |
| 1021 | SYS_KACS_GET_SD | kacs_get_sd(int dirfd, const char __user *path, u32 security_info, void __user *buf, u32 buf_len, u32 flags) |
| 1022 | SYS_KACS_SET_SD | kacs_set_sd(int dirfd, const char __user *path, u32 security_info, const void __user *sd_buf, u32 sd_len, u32 flags) |
| 1023 | SYS_KACS_ACCESS_CHECK | kacs_access_check(const void __user *uargs) |
| 1024 | SYS_KACS_ACCESS_CHECK_LIST | kacs_access_check_list(const void __user *uargs, struct kacs_node_result __user *results, u32 results_count) |
| 1025 | SYS_KACS_SET_CAAP | kacs_set_caap(const void __user *policy_sid, u32 policy_sid_len, const void __user *spec, u32 spec_len) |
| 1026 | SYS_KACS_GET_MOUNT_POLICY | kacs_get_mount_policy(int fd, struct kacs_mount_policy_args __user *uargs, size_t argsize) |
| 1027 | SYS_KACS_SET_MOUNT_POLICY | kacs_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.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | token_class |
| 4 | 4 | __u32 | buf_len |
| 8 | 8 | __u64 | buf_ptr |
3.A.2.2 struct kacs_adjust_privs_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | count |
| 4 | 4 | __u32 | _pad |
| 8 | 8 | __u64 | data_ptr |
| 16 | 8 | __u64 | previous_enabled |
3.A.2.3 struct kacs_priv_entry #
Total size 8 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | luid |
| 4 | 4 | __u32 | attributes |
3.A.2.4 struct kacs_adjust_groups_args #
Total size 144 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | count |
| 4 | 4 | __u32 | _pad |
| 8 | 8 | __u64 | data_ptr |
| 16 | 128 | __u64``[16] | previous_state |
3.A.2.5 struct kacs_duplicate_args #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | access_mask |
| 4 | 4 | __u32 | token_type |
| 8 | 4 | __u32 | impersonation_level |
| 12 | 4 | __s32 | result_fd |
3.A.2.6 struct kacs_group_entry #
Total size 8 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | index |
| 4 | 4 | __u32 | enable |
3.A.2.7 struct kacs_adjust_default_args #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | dacl_ptr |
| 8 | 4 | __u32 | dacl_len |
| 12 | 2 | __u16 | owner_index |
| 14 | 2 | __u16 | group_index |
3.A.2.8 struct kacs_restrict_args #
Total size 40 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | privs_to_delete |
| 8 | 4 | __u32 | num_deny_indices |
| 12 | 4 | __u32 | num_restrict_sids |
| 16 | 4 | __u32 | data_len |
| 20 | 4 | __u32 | flags |
| 24 | 8 | __u64 | data_ptr |
| 32 | 4 | __s32 | result_fd |
| 36 | 4 | __u32 | _pad |
3.A.2.9 struct kacs_link_tokens_args #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __s32 | elevated_fd |
| 4 | 4 | __s32 | filtered_fd |
| 8 | 8 | __u64 | logon_session_id |
3.A.2.10 struct kacs_get_linked_token_args #
Total size 4 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __s32 | result_fd |
3.A.2.11 struct kacs_access_check_args #
Total size 136 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | caller_size |
| 4 | 4 | __s32 | token_fd |
| 8 | 8 | __u64 | sd_ptr |
| 16 | 4 | __u32 | sd_len |
| 20 | 4 | __u32 | desired_access |
| 24 | 4 | __u32 | mapping_read |
| 28 | 4 | __u32 | mapping_write |
| 32 | 4 | __u32 | mapping_execute |
| 36 | 4 | __u32 | mapping_all |
| 40 | 8 | __u64 | self_sid_ptr |
| 48 | 4 | __u32 | self_sid_len |
| 52 | 4 | __u32 | privilege_intent |
| 56 | 8 | __u64 | object_tree_ptr |
| 64 | 4 | __u32 | object_tree_count |
| 68 | 4 | __u32 | _pad0 |
| 72 | 8 | __u64 | local_claims_ptr |
| 80 | 4 | __u32 | local_claims_len |
| 84 | 4 | __u32 | _pad1 |
| 88 | 8 | __u64 | granted_out_ptr |
| 96 | 4 | __u32 | pip_type |
| 100 | 4 | __u32 | pip_trust |
| 104 | 8 | __u64 | audit_context_ptr |
| 112 | 4 | __u32 | audit_context_len |
| 116 | 4 | __u32 | _pad2 |
| 120 | 8 | __u64 | continuous_audit_out_ptr |
| 128 | 8 | __u64 | staging_mismatch_out_ptr |
3.A.2.12 struct kacs_object_type_entry #
Total size 20 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 2 | __u16 | level |
| 2 | 2 | __u16 | _reserved |
| 4 | 16 | __u8``[16] | guid |
3.A.2.13 struct kacs_node_result #
Total size 8 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | granted |
| 4 | 4 | __s32 | status |
3.A.2.14 struct kacs_open_how #
Total size 32 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | desired_access |
| 4 | 4 | __u32 | create_disposition |
| 8 | 4 | __u32 | create_options |
| 12 | 4 | __u32 | flags |
| 16 | 8 | __u64 | sd_ptr |
| 24 | 4 | __u32 | sd_len |
| 28 | 4 | __u32 | __pad |
3.A.2.15 struct kacs_mount_policy_args #
Total size 32 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | policy |
| 4 | 4 | __u32 | flags |
| 8 | 4 | __u32 | generation |
| 12 | 4 | __u32 | __pad0 |
| 16 | 8 | __u64 | template_sd_ptr |
| 24 | 4 | __u32 | template_sd_len |
| 28 | 4 | __u32 | __pad1 |
3.A.2.16 struct kacs_generic_mapping #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | read |
| 4 | 4 | __u32 | write |
| 8 | 4 | __u32 | execute |
| 12 | 4 | __u32 | all |
3.A.3 Token constants #
From uapi/pkm/token.h.
kacs_open_self_token (SYS_KACS_OPEN_SELF_TOKEN) flags.
| Constant | Value |
|---|---|
KACS_TOKEN_OPEN_REAL | 0x01 (1) |
Per-handle token rights (the low 16 bits of a token access mask).
| Constant | Value |
|---|---|
KACS_TOKEN_ASSIGN_PRIMARY | 0x0001 (1) |
KACS_TOKEN_DUPLICATE | 0x0002 (2) |
KACS_TOKEN_IMPERSONATE | 0x0004 (4) |
KACS_TOKEN_QUERY | 0x0008 (8) |
KACS_TOKEN_QUERY_SOURCE | 0x0010 (16) |
KACS_TOKEN_ADJUST_PRIVS | 0x0020 (32) |
KACS_TOKEN_ADJUST_GROUPS | 0x0040 (64) |
KACS_TOKEN_ADJUST_DEFAULT | 0x0080 (128) |
KACS_TOKEN_ADJUST_INTERACTIVITY_SCOPE | 0x0100 (256) |
KACS_TOKEN_ALL_ACCESS | 0x000F01FF |
Token ioctl interface identifier.
| Constant | Value |
|---|---|
KACS_IOC_MAGIC | 0x4B (75) |
kacs_priv_entry.attributes bits.
| Constant | Value |
|---|---|
KACS_PRIVILEGE_ATTR_ENABLED | 0x00000002 (2) |
KACS_PRIVILEGE_ATTR_REMOVED | 0x00000004 (4) |
kacs_adjust_privs bulk-reset flag (not a per-entry attribute).
| Constant | Value |
|---|---|
KACS_PRIVILEGE_RESET_ALL_DEFAULTS | 0x80000000 |
kacs_restrict_args.flags bits.
| Constant | Value |
|---|---|
KACS_TOKEN_RESTRICT_WRITE_RESTRICTED | 0x00000001 (1) |
Token type (KACS_TOKEN_CLASS_TYPE).
| Constant | Value |
|---|---|
KACS_TOKEN_TYPE_PRIMARY | 0x01 (1) |
KACS_TOKEN_TYPE_IMPERSONATION | 0x02 (2) |
Impersonation level (KACS_TOKEN_CLASS_IMPERSONATION_LEVEL).
| Constant | Value |
|---|---|
KACS_IMLEVEL_ANONYMOUS | 0x00 (0) |
KACS_IMLEVEL_IDENTIFICATION | 0x01 (1) |
KACS_IMLEVEL_IMPERSONATION | 0x02 (2) |
KACS_IMLEVEL_DELEGATION | 0x03 (3) |
Elevation type (KACS_TOKEN_CLASS_ELEVATION_TYPE).
| Constant | Value |
|---|---|
KACS_ELEVATION_DEFAULT | 0x01 (1) |
KACS_ELEVATION_FULL | 0x02 (2) |
KACS_ELEVATION_LIMITED | 0x03 (3) |
Mandatory-policy bits (KACS_TOKEN_CLASS_MANDATORY_POLICY).
| Constant | Value |
|---|---|
KACS_TOKEN_MANDATORY_POLICY_NO_WRITE_UP | 0x00000001 (1) |
KACS_TOKEN_MANDATORY_POLICY_NEW_PROCESS_MIN | 0x00000002 (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.
| Constant | Value |
|---|---|
KACS_AUDIT_POLICY_OBJECT_ACCESS_SUCCESS | 0x00000001 (1) |
KACS_AUDIT_POLICY_OBJECT_ACCESS_FAILURE | 0x00000002 (2) |
KACS_AUDIT_POLICY_PRIVILEGE_USE_SUCCESS | 0x00000004 (4) |
KACS_AUDIT_POLICY_PRIVILEGE_USE_FAILURE | 0x00000008 (8) |
Logon type (KACS_TOKEN_CLASS_LOGON_TYPE).
| Constant | Value |
|---|---|
KACS_LOGON_TYPE_INTERACTIVE | 2 |
KACS_LOGON_TYPE_NETWORK | 3 |
KACS_LOGON_TYPE_BATCH | 4 |
KACS_LOGON_TYPE_SERVICE | 5 |
KACS_LOGON_TYPE_NETWORK_CLEARTEXT | 8 |
KACS_LOGON_TYPE_NEW_CREDENTIALS | 9 |
Maximum number of groups a token may carry.
| Constant | Value |
|---|---|
KACS_TOKEN_MAX_GROUPS | 1024 |
Number of 64-bit words in a group enabled-state bitmask (KACS_TOKEN_MAX_GROUPS / 64).
| Constant | Value |
|---|---|
KACS_TOKEN_GROUP_MASK_WORDS | 16 |
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.
| Constant | Value |
|---|---|
KACS_TOKEN_SPEC_VERSION | 2 |
KACS_TOKEN_SPEC_HEADER_BYTES | 192 |
KACS_TOKEN_SPEC_MIN_BYTES | 192 |
KACS_TOKEN_SPEC_MAX_BYTES | 65536 |
Byte offsets of the fixed token-spec header fields.
| Constant | Value |
|---|---|
KACS_TOKEN_SPEC_OFF_VERSION | 0 |
KACS_TOKEN_SPEC_OFF_TOKEN_TYPE | 4 |
KACS_TOKEN_SPEC_OFF_IMPERSONATION_LEVEL | 5 |
KACS_TOKEN_SPEC_OFF_RESERVED0 | 6 |
KACS_TOKEN_SPEC_OFF_INTEGRITY_RID | 8 |
KACS_TOKEN_SPEC_OFF_MANDATORY_POLICY | 12 |
KACS_TOKEN_SPEC_OFF_PRIVS_PRESENT | 16 |
KACS_TOKEN_SPEC_OFF_PRIVS_ENABLED | 24 |
KACS_TOKEN_SPEC_OFF_RESERVED1 | 32 |
KACS_TOKEN_SPEC_OFF_PROJECTED_UID | 36 |
KACS_TOKEN_SPEC_OFF_PROJECTED_GID | 40 |
KACS_TOKEN_SPEC_OFF_AUDIT_POLICY | 44 |
KACS_TOKEN_SPEC_OFF_EXPIRATION | 48 |
KACS_TOKEN_SPEC_OFF_LOGON_SESSION_ID | 56 |
KACS_TOKEN_SPEC_OFF_OWNER_SID_INDEX | 64 |
KACS_TOKEN_SPEC_OFF_PRIMARY_GROUP_INDEX | 68 |
KACS_TOKEN_SPEC_OFF_SOURCE_NAME | 72 |
KACS_TOKEN_SPEC_OFF_SOURCE_ID | 80 |
KACS_TOKEN_SPEC_OFF_USER_SID_OFFSET | 88 |
KACS_TOKEN_SPEC_OFF_GROUPS_OFFSET | 92 |
KACS_TOKEN_SPEC_OFF_GROUPS_COUNT | 96 |
KACS_TOKEN_SPEC_OFF_DEFAULT_DACL_OFFSET | 100 |
KACS_TOKEN_SPEC_OFF_DEFAULT_DACL_LEN | 104 |
KACS_TOKEN_SPEC_OFF_USER_CLAIMS_OFFSET | 108 |
KACS_TOKEN_SPEC_OFF_USER_CLAIMS_LEN | 112 |
KACS_TOKEN_SPEC_OFF_DEVICE_CLAIMS_OFFSET | 116 |
KACS_TOKEN_SPEC_OFF_DEVICE_CLAIMS_LEN | 120 |
KACS_TOKEN_SPEC_OFF_DEVICE_GROUPS_OFFSET | 124 |
KACS_TOKEN_SPEC_OFF_DEVICE_GROUPS_COUNT | 128 |
KACS_TOKEN_SPEC_OFF_RESTRICTED_SIDS_OFFSET | 132 |
KACS_TOKEN_SPEC_OFF_RESTRICTED_SIDS_COUNT | 136 |
KACS_TOKEN_SPEC_OFF_CONFINEMENT_SID_OFFSET | 140 |
KACS_TOKEN_SPEC_OFF_CONFINEMENT_SID_LEN | 144 |
KACS_TOKEN_SPEC_OFF_CONFINEMENT_CAPS_OFFSET | 148 |
KACS_TOKEN_SPEC_OFF_CONFINEMENT_CAPS_COUNT | 152 |
KACS_TOKEN_SPEC_OFF_CONFINEMENT_EXEMPT | 156 |
KACS_TOKEN_SPEC_OFF_WRITE_RESTRICTED | 157 |
KACS_TOKEN_SPEC_OFF_USER_DENY_ONLY | 158 |
KACS_TOKEN_SPEC_OFF_ISOLATION_BOUNDARY | 159 |
KACS_TOKEN_SPEC_OFF_SUPP_GIDS_OFFSET | 160 |
KACS_TOKEN_SPEC_OFF_SUPP_GIDS_COUNT | 164 |
KACS_TOKEN_SPEC_OFF_RESTRICTED_DEVICE_GROUPS_OFFSET | 168 |
KACS_TOKEN_SPEC_OFF_RESTRICTED_DEVICE_GROUPS_COUNT | 172 |
KACS_TOKEN_SPEC_OFF_ORIGIN | 176 |
KACS_TOKEN_SPEC_OFF_INTERACTIVITY_SCOPE | 184 |
KACS_TOKEN_SPEC_OFF_LCS_CREDENTIALS_OFFSET | 188 |
Byte length of the fixed token-source-name field.
| Constant | Value |
|---|---|
KACS_TOKEN_SPEC_SOURCE_NAME_BYTES | 8 |
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.
| Constant | Value |
|---|---|
KACS_TOKEN_LCS_EXT_VERSION | 1 |
KACS_TOKEN_LCS_EXT_HEADER_BYTES | 16 |
KACS_TOKEN_LCS_SCOPE_GUID_BYTES | 16 |
KACS_TOKEN_LCS_MAX_SCOPE_GUIDS | 256 |
KACS_TOKEN_LCS_MAX_PRIVATE_LAYERS | 256 |
KACS_TOKEN_LCS_MAX_LAYER_NAME_BYTES | 255 |
Byte offsets of the fixed LCS-extension header fields.
| Constant | Value |
|---|---|
KACS_TOKEN_LCS_EXT_OFF_VERSION | 0 |
KACS_TOKEN_LCS_EXT_OFF_RESERVED | 4 |
KACS_TOKEN_LCS_EXT_OFF_SCOPE_COUNT | 8 |
KACS_TOKEN_LCS_EXT_OFF_PRIVATE_LAYER_COUNT | 12 |
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.
| Constant | Value |
|---|---|
KACS_LOGON_SESSION_SPEC_MIN_BYTES | 15 |
KACS_LOGON_SESSION_SPEC_MAX_BYTES | 4096 |
Byte offsets of the fixed-position session-spec fields.
| Constant | Value |
|---|---|
KACS_LOGON_SESSION_SPEC_OFF_LOGON_TYPE | 0 |
KACS_LOGON_SESSION_SPEC_OFF_AUTH_PKG_LEN | 1 |
KACS_LOGON_SESSION_SPEC_OFF_AUTH_PKG | 3 |
Token-handle ioctls.
| Constant | Value |
|---|---|
KACS_IOC_QUERY | 0xC0104B00 |
KACS_IOC_ADJUST_PRIVS | 0x40184B01 |
KACS_IOC_DUPLICATE | 0xC0104B02 |
KACS_IOC_INSTALL | 0x00004B03 |
KACS_IOC_RESTRICT | 0xC0284B04 |
KACS_IOC_LINK_TOKENS | 0x40104B05 |
KACS_IOC_GET_LINKED_TOKEN | 0xC0044B06 |
KACS_IOC_ADJUST_GROUPS | 0x40904B07 |
KACS_IOC_IMPERSONATE | 0x00004B08 |
KACS_IOC_ADJUST_DEFAULT | 0x40104B09 |
KACS_IOC_ADJUST_INTERACTIVITY_SCOPE | 0x40044B0A |
Token information classes (kacs_query_args.token_class).
| Constant | Value |
|---|---|
KACS_TOKEN_CLASS_USER | 0x01 (1) |
KACS_TOKEN_CLASS_GROUPS | 0x02 (2) |
KACS_TOKEN_CLASS_PRIVILEGES | 0x03 (3) |
KACS_TOKEN_CLASS_TYPE | 0x04 (4) |
KACS_TOKEN_CLASS_INTEGRITY_LEVEL | 0x05 (5) |
KACS_TOKEN_CLASS_OWNER | 0x06 (6) |
KACS_TOKEN_CLASS_PRIMARY_GROUP | 0x07 (7) |
KACS_TOKEN_CLASS_INTERACTIVITY_SCOPE | 0x08 (8) |
KACS_TOKEN_CLASS_RESTRICTED_SIDS | 0x09 (9) |
KACS_TOKEN_CLASS_SOURCE | 0x0A (10) |
KACS_TOKEN_CLASS_STATISTICS | 0x0B (11) |
KACS_TOKEN_CLASS_ORIGIN | 0x0C (12) |
KACS_TOKEN_CLASS_ELEVATION_TYPE | 0x0D (13) |
KACS_TOKEN_CLASS_DEVICE_GROUPS | 0x0E (14) |
KACS_TOKEN_CLASS_APPCONTAINER_SID | 0x0F (15) |
KACS_TOKEN_CLASS_CAPABILITIES | 0x10 (16) |
KACS_TOKEN_CLASS_MANDATORY_POLICY | 0x11 (17) |
KACS_TOKEN_CLASS_LOGON_TYPE | 0x12 (18) |
KACS_TOKEN_CLASS_LOGON_SID | 0x13 (19) |
KACS_TOKEN_CLASS_DEFAULT_DACL | 0x14 (20) |
KACS_TOKEN_CLASS_IMPERSONATION_LEVEL | 0x15 (21) |
KACS_TOKEN_CLASS_USER_CLAIMS | 0x16 (22) |
KACS_TOKEN_CLASS_DEVICE_CLAIMS | 0x17 (23) |
KACS_TOKEN_CLASS_PROJECTED_SUPPLEMENTARY_GIDS | 0x18 (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, …).
| Constant | Value |
|---|---|
KACS_SE_CREATE_TOKEN_PRIVILEGE | 4 |
KACS_SE_ASSIGN_PRIMARY_TOKEN_PRIVILEGE | 8 |
KACS_SE_LOCK_MEMORY_PRIVILEGE | 16 |
KACS_SE_INCREASE_QUOTA_PRIVILEGE | 32 |
KACS_SE_TCB_PRIVILEGE | 128 |
KACS_SE_SECURITY_PRIVILEGE | 256 |
KACS_SE_TAKE_OWNERSHIP_PRIVILEGE | 512 |
KACS_SE_LOAD_DRIVER_PRIVILEGE | 1024 |
KACS_SE_SYSTEM_PROFILE_PRIVILEGE | 2048 |
KACS_SE_SYSTEMTIME_PRIVILEGE | 4096 |
KACS_SE_PROFILE_SINGLE_PROCESS_PRIVILEGE | 8192 |
KACS_SE_INCREASE_BASE_PRIORITY_PRIVILEGE | 16384 |
KACS_SE_BACKUP_PRIVILEGE | 131072 |
KACS_SE_RESTORE_PRIVILEGE | 262144 |
KACS_SE_SHUTDOWN_PRIVILEGE | 524288 |
KACS_SE_DEBUG_PRIVILEGE | 0x100000 (1048576) |
KACS_SE_AUDIT_PRIVILEGE | 0x200000 (2097152) |
KACS_SE_CHANGE_NOTIFY_PRIVILEGE | 0x800000 (8388608) |
KACS_SE_REMOTE_SHUTDOWN_PRIVILEGE | 0x1000000 (16777216) |
KACS_SE_MANAGE_VOLUME_PRIVILEGE | 0x10000000 (268435456) |
KACS_SE_IMPERSONATE_PRIVILEGE | 0x20000000 (536870912) |
KACS_SE_RELABEL_PRIVILEGE | 0x100000000 (4294967296) |
KACS_SE_CREATE_SYMBOLIC_LINK_PRIVILEGE | 0x800000000 (34359738368) |
KACS_SE_BIND_PRIVILEGED_PORT_PRIVILEGE | 0x8000000000000000 (9223372036854775808) |
3.A.4 AccessCheck constants #
From uapi/pkm/access.h.
Full size of kacs_access_check_args the current kernel copies.
| Constant | Value |
|---|---|
KACS_ACCESS_CHECK_ARGS_SIZE | 136 |
Minimum caller_size the kernel accepts (the v1 / v0.20 layout).
| Constant | Value |
|---|---|
KACS_ACCESS_CHECK_ARGS_V1_SIZE | 40 |
Byte size of one kacs_object_type_entry in the object-type tree array.
| Constant | Value |
|---|---|
KACS_OBJECT_TYPE_ENTRY_SIZE | 20 |
Largest object-audit-context buffer the kernel accepts.
| Constant | Value |
|---|---|
KACS_ACCESS_CHECK_MAX_AUDIT_CONTEXT_LEN | 4096 |
Largest @Local claims blob the kernel accepts (local_claims_ptr).
| Constant | Value |
|---|---|
KACS_ACCESS_CHECK_MAX_LOCAL_CLAIMS_LEN | 65536 |
Largest object-type tree entry count the kernel accepts.
| Constant | Value |
|---|---|
KACS_ACCESS_CHECK_MAX_OBJECT_TYPE_COUNT | 1024 |
Claim value types — the discriminant of one attribute in the @Local claims array (local_claims_ptr).
| Constant | Value |
|---|---|
KACS_CLAIM_TYPE_INT64 | 0x0001 (1) |
KACS_CLAIM_TYPE_UINT64 | 0x0002 (2) |
KACS_CLAIM_TYPE_STRING | 0x0003 (3) |
KACS_CLAIM_TYPE_SID | 0x0005 (5) |
KACS_CLAIM_TYPE_BOOLEAN | 0x0006 (6) |
KACS_CLAIM_TYPE_OCTET | 0x0010 (16) |
Claim attribute flags.
| Constant | Value |
|---|---|
KACS_CLAIM_ATTR_CASE_SENSITIVE | 0x0002 (2) |
KACS_CLAIM_ATTR_USE_FOR_DENY_ONLY | 0x0004 (4) |
KACS_CLAIM_ATTR_DISABLED | 0x0010 (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.
| Constant | Value |
|---|---|
KACS_CAAP_SPEC_VERSION | 0x01 (1) |
KACS_CAAP_MAX_SPEC_BYTES | 262144 |
KACS_CAAP_MAX_RULE_COUNT | 256 |
KACS_CAAP_MAX_FIELD_BYTES | 65536 |
KACS_CAAP_MAX_ACL_BYTES | 65535 |
Byte offsets of the fixed CAAP-spec prefix fields.
| Constant | Value |
|---|---|
KACS_CAAP_SPEC_OFF_VERSION | 0 |
KACS_CAAP_SPEC_OFF_RULE_COUNT | 1 |
Byte length of the fixed CAAP-spec prefix (version + rule_count).
| Constant | Value |
|---|---|
KACS_CAAP_SPEC_PREFIX_BYTES | 5 |
3.A.5 File and open constants #
From uapi/pkm/file.h.
Minimum caller-supplied size accepted for each argument block.
| Constant | Value |
|---|---|
KACS_OPEN_HOW_MIN_SIZE | 16 |
KACS_MOUNT_POLICY_ARGS_MIN_SIZE | 16 |
Create dispositions (kacs_open_how.create_disposition).
| Constant | Value |
|---|---|
KACS_DISPOSITION_SUPERSEDE | 0 |
KACS_DISPOSITION_OPEN | 1 |
KACS_DISPOSITION_CREATE | 2 |
KACS_DISPOSITION_OPEN_IF | 3 |
KACS_DISPOSITION_OVERWRITE | 4 |
KACS_DISPOSITION_OVERWRITE_IF | 5 |
Create options (kacs_open_how.create_options).
| Constant | Value |
|---|---|
KACS_CREATE_OPT_DIRECTORY | 0x0001 (1) |
KACS_CREATE_OPT_DELETE_ON_CLOSE | 0x0002 (2) |
kacs_open_how.flags bits.
| Constant | Value |
|---|---|
KACS_BACKUP_INTENT | 0x00000001 (1) |
KACS_RESTORE_INTENT | 0x00000002 (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.
| Constant | Value |
|---|---|
KACS_FILE_READ_DATA | 0x00000001 (1) |
KACS_FILE_WRITE_DATA | 0x00000002 (2) |
KACS_FILE_APPEND_DATA | 0x00000004 (4) |
KACS_FILE_READ_EA | 0x00000008 (8) |
KACS_FILE_WRITE_EA | 0x00000010 (16) |
KACS_FILE_EXECUTE | 0x00000020 (32) |
KACS_FILE_DELETE_CHILD | 0x00000040 (64) |
KACS_FILE_READ_ATTRIBUTES | 0x00000080 (128) |
KACS_FILE_WRITE_ATTRIBUTES | 0x00000100 (256) |
KACS_FILE_LIST_DIRECTORY | 1 |
KACS_FILE_TRAVERSE | 32 |
KACS_FILE_ADD_FILE | 2 |
KACS_FILE_ADD_SUBDIRECTORY | 4 |
Mount-policy values (kacs_mount_policy_args.policy).
| Constant | Value |
|---|---|
KACS_MOUNT_POLICY_UNMANAGED | 1 |
KACS_MOUNT_POLICY_DENY_MISSING | 2 |
KACS_MOUNT_POLICY_SYNTHESIZE_EPHEMERAL | 3 |
KACS_MOUNT_POLICY_SYNTHESIZE_PERSISTENT | 4 |
Status word kacs_open writes back, describing what happened to the file.
| Constant | Value |
|---|---|
KACS_STATUS_OPENED | 1 |
KACS_STATUS_CREATED | 2 |
KACS_STATUS_OVERWRITTEN | 3 |
KACS_STATUS_SUPERSEDED | 4 |
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.
| Constant | Value |
|---|---|
KACS_PROCESS_TERMINATE | 0x00000001 (1) |
KACS_PROCESS_SIGNAL | 0x00000002 (2) |
KACS_PROCESS_VM_READ | 0x00000010 (16) |
KACS_PROCESS_VM_WRITE | 0x00000020 (32) |
KACS_PROCESS_DUP_HANDLE | 0x00000040 (64) |
KACS_PROCESS_SET_INFORMATION | 0x00000200 (512) |
KACS_PROCESS_QUERY_INFORMATION | 0x00000400 (1024) |
KACS_PROCESS_SUSPEND_RESUME | 0x00000800 (2048) |
KACS_PROCESS_QUERY_LIMITED | 0x00001000 (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.
| Constant | Value | Notes |
|---|---|---|
KACS_MIT_WXP | 0x001 (1) | Write-XOR-Execute protection |
KACS_MIT_TLP | 0x002 (2) | Trusted Library Paths |
KACS_MIT_LSV | 0x004 (4) | Library Signature Verification |
KACS_MIT_CFI | 0x008 (8) | legacy alias: CFIF | CFIB |
KACS_MIT_UI_ACCESS | 0x010 (16) | UI interaction (reserved) |
KACS_MIT_NO_CHILD | 0x020 (32) | cannot fork (one-way) |
KACS_MIT_CFIF | 0x040 (64) | forward-edge CFI (Intel IBT) |
KACS_MIT_CFIB | 0x080 (128) | backward-edge CFI (shadow stack) |
KACS_MIT_PIE | 0x100 (256) | reject non-PIE binaries at exec |
KACS_MIT_SML | 0x200 (512) | speculation mitigation lock |
All valid mitigation bits OR'd together — the accepted-request mask.
| Constant | Value |
|---|---|
KACS_MIT_ALL | 0x3FF (1023) |
3.A.8 Security descriptor constants #
From uapi/pkm/sd.h.
Byte length of the self-relative security-descriptor header.
| Constant | Value |
|---|---|
KACS_SD_HEADER_BYTES | 20 |
SECURITY_INFORMATION selector bits — which components of a security descriptor a kacs_get_sd / kacs_set_sd call reads or writes.
| Constant | Value |
|---|---|
KACS_SECINFO_OWNER | 0x00000001 (1) |
KACS_SECINFO_GROUP | 0x00000002 (2) |
KACS_SECINFO_DACL | 0x00000004 (4) |
KACS_SECINFO_SACL | 0x00000008 (8) |
KACS_SECINFO_LABEL | 0x00000010 (16) |
SECURITY_DESCRIPTOR_CONTROL bits — the SD header control field.
| Constant | Value |
|---|---|
KACS_SD_OWNER_DEFAULTED | 0x0001 (1) |
KACS_SD_GROUP_DEFAULTED | 0x0002 (2) |
KACS_SD_DACL_PRESENT | 0x0004 (4) |
KACS_SD_DACL_DEFAULTED | 0x0008 (8) |
KACS_SD_SACL_PRESENT | 0x0010 (16) |
KACS_SD_SACL_DEFAULTED | 0x0020 (32) |
KACS_SD_DACL_TRUSTED | 0x0040 (64) |
KACS_SD_SERVER_SECURITY | 0x0080 (128) |
KACS_SD_DACL_AUTO_INHERIT_REQ | 0x0100 (256) |
KACS_SD_SACL_AUTO_INHERIT_REQ | 0x0200 (512) |
KACS_SD_DACL_AUTO_INHERITED | 0x0400 (1024) |
KACS_SD_SACL_AUTO_INHERITED | 0x0800 (2048) |
KACS_SD_DACL_PROTECTED | 0x1000 (4096) |
KACS_SD_SACL_PROTECTED | 0x2000 (8192) |
KACS_SD_RM_CONTROL_VALID | 0x4000 (16384) |
KACS_SD_SELF_RELATIVE | 0x8000 (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.
| Constant | Value |
|---|---|
KACS_ACCESS_DELETE | 0x00010000 (65536) |
KACS_ACCESS_READ_CONTROL | 0x00020000 |
KACS_ACCESS_WRITE_DAC | 0x00040000 |
KACS_ACCESS_WRITE_OWNER | 0x00080000 |
KACS_ACCESS_SYNCHRONIZE | 0x00100000 |
KACS_ACCESS_ACCESS_SYSTEM_SECURITY | 0x01000000 |
KACS_ACCESS_MAXIMUM_ALLOWED | 0x02000000 |
KACS_ACCESS_GENERIC_ALL | 0x10000000 |
KACS_ACCESS_GENERIC_EXECUTE | 0x20000000 |
KACS_ACCESS_GENERIC_WRITE | 0x40000000 |
KACS_ACCESS_GENERIC_READ | 0x80000000 |
ACE types — the ace_type byte of an ACE header (MS-DTYP 2.4.4.1).
| Constant | Value |
|---|---|
KACS_ACE_TYPE_ACCESS_ALLOWED | 0x00 (0) |
KACS_ACE_TYPE_ACCESS_DENIED | 0x01 (1) |
KACS_ACE_TYPE_SYSTEM_AUDIT | 0x02 (2) |
KACS_ACE_TYPE_SYSTEM_ALARM | 0x03 (3) |
KACS_ACE_TYPE_ACCESS_ALLOWED_COMPOUND | 0x04 (4) |
KACS_ACE_TYPE_ACCESS_ALLOWED_OBJECT | 0x05 (5) |
KACS_ACE_TYPE_ACCESS_DENIED_OBJECT | 0x06 (6) |
KACS_ACE_TYPE_SYSTEM_AUDIT_OBJECT | 0x07 (7) |
KACS_ACE_TYPE_SYSTEM_ALARM_OBJECT | 0x08 (8) |
KACS_ACE_TYPE_ACCESS_ALLOWED_CALLBACK | 0x09 (9) |
KACS_ACE_TYPE_ACCESS_DENIED_CALLBACK | 0x0A (10) |
KACS_ACE_TYPE_ACCESS_ALLOWED_CALLBACK_OBJECT | 0x0B (11) |
KACS_ACE_TYPE_ACCESS_DENIED_CALLBACK_OBJECT | 0x0C (12) |
KACS_ACE_TYPE_SYSTEM_AUDIT_CALLBACK | 0x0D (13) |
KACS_ACE_TYPE_SYSTEM_ALARM_CALLBACK | 0x0E (14) |
KACS_ACE_TYPE_SYSTEM_AUDIT_CALLBACK_OBJECT | 0x0F (15) |
KACS_ACE_TYPE_SYSTEM_ALARM_CALLBACK_OBJECT | 0x10 (16) |
KACS_ACE_TYPE_SYSTEM_MANDATORY_LABEL | 0x11 (17) |
KACS_ACE_TYPE_SYSTEM_RESOURCE_ATTRIBUTE | 0x12 (18) |
KACS_ACE_TYPE_SYSTEM_SCOPED_POLICY_ID | 0x13 (19) |
KACS_ACE_TYPE_SYSTEM_PROCESS_TRUST_LABEL | 0x14 (20) |
KACS_ACE_TYPE_SYSTEM_ACCESS_FILTER | 0x15 (21) |
ACE header ace_flags byte — inheritance and audit control.
| Constant | Value |
|---|---|
KACS_ACE_FLAG_OBJECT_INHERIT | 0x01 (1) |
KACS_ACE_FLAG_CONTAINER_INHERIT | 0x02 (2) |
KACS_ACE_FLAG_NO_PROPAGATE_INHERIT | 0x04 (4) |
KACS_ACE_FLAG_INHERIT_ONLY | 0x08 (8) |
KACS_ACE_FLAG_INHERITED | 0x10 (16) |
KACS_ACE_FLAG_SUCCESSFUL_ACCESS | 0x40 (64) |
KACS_ACE_FLAG_FAILED_ACCESS | 0x80 (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.
| Constant | Value |
|---|---|
KACS_SYSTEM_MANDATORY_LABEL_NO_READ_UP | 0x00000001 (1) |
KACS_SYSTEM_MANDATORY_LABEL_NO_WRITE_UP | 0x00000002 (2) |
KACS_SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP | 0x00000004 (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.
| Constant | Value |
|---|---|
KACS_ACE_OBJECT_TYPE_PRESENT | 0x00000001 (1) |
KACS_ACE_INHERITED_OBJECT_TYPE_PRESENT | 0x00000002 (2) |
3.A.9 SID constants #
From uapi/pkm/sid.h.
Largest sub_authority_count a valid SID may declare.
| Constant | Value |
|---|---|
KACS_SID_MAX_SUB_AUTHORITIES | 15 |
Encoded byte length of a SID with the given sub-authority count.
| Constant | Value |
|---|---|
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.
| Constant | Value |
|---|---|
KACS_SID_GROUP_MANDATORY | 0x00000001 (1) |
KACS_SID_GROUP_ENABLED_BY_DEFAULT | 0x00000002 (2) |
KACS_SID_GROUP_ENABLED | 0x00000004 (4) |
KACS_SID_GROUP_OWNER | 0x00000008 (8) |
KACS_SID_GROUP_USE_FOR_DENY_ONLY | 0x00000010 (16) |
KACS_SID_GROUP_INTEGRITY | 0x00000020 (32) |
KACS_SID_GROUP_INTEGRITY_ENABLED | 0x00000040 (64) |
KACS_SID_GROUP_RESOURCE | 0x20000000 |
KACS_SID_GROUP_LOGON_ID | 0xC0000000 |
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).
| Constant | Value | Notes |
|---|---|---|
KACS_TR_DECISION | 0 | resolved allow/deny |
KACS_TR_BAD_ARGS | 1 | NULL/zero argument guard |
KACS_TR_NO_ISEC | 2 | inode has no i_security blob |
KACS_TR_UNMANAGED | 3 | superblock mount policy UNMANAGED |
KACS_TR_PIP_CONTEXT | 4 | current PIP context unavailable |
KACS_TR_NO_TOKEN | 5 | no effective subject token |
KACS_TR_NO_DENTRY_ALIAS | 6 | inode has no dentry alias yet |
KACS_TR_DELETE_ON_CLOSE_PENDING | 7 | open of a delete-on-close file |
KACS_TR_NATIVE_STAMP | 8 | native-open granted-access stamp |
KACS_TR_NATIVE_ARM | 9 | native-open delete-on-close arm |
KACS_TR_STAMP | 10 | legacy-open granted-access stamp |
KACS_TR_LAZY_DENTRY_RELOOKUP | 11 | native create lazy re-lookup |
KACS_TR_NEGATIVE_AFTER_CREATE | 12 | negative dentry after create |
KACS_TR_CHANGE_NOTIFY_PRIV | 13 | traverse via CHANGE_NOTIFY priv |
KACS_TR_CHANGE_NOTIFY_PRIV_EXHAUSTED | 14 | CHANGE_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.
| Constant | Value | Notes |
|---|---|---|
KACS_SDC_HIT | 0 | current, valid cache present |
KACS_SDC_MISS_NONE | 1 | no cache attached |
KACS_SDC_MISS_STALE_GEN | 2 | cache present but stale generation |
KACS_SDC_MISS_NEEDS_SYNTH | 3 | missing SD requires synthesis |
KACS_SDC_CORRUPT_EMPTY_OR_OVERSIZE | 4 | stored SD zero-length or oversize |
KACS_SDC_CORRUPT_VALIDATE_FAIL | 5 | stored 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.
| Constant | Value | Notes |
|---|---|---|
KACS_PA_ALLOW | 0 | access granted |
KACS_PA_BAD_ARGS | 1 | NULL subject/target guard |
KACS_PA_NO_TARGET | 2 | target has no process state/SD |
KACS_PA_NO_SD | 3 | target process SD unavailable |
KACS_PA_SD_ERROR | 4 | SD check failed (non-EACCES) |
KACS_PA_PIP_DENIED | 5 | denied by process-integrity policy |
KACS_PA_DEBUG_RESCUE | 6 | SD denial rescued by SeDebugPrivilege |
KACS_PA_DEBUG_DENIED | 7 | denied; no usable SeDebugPrivilege |
KACS_PA_PIP_DOMINANCE | 8 | caller 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.
| Constant | Value | Notes |
|---|---|---|
KACS_EXEC_CREDS_ALLOW | 0 | exec cred transition allowed |
KACS_EXEC_BAD_ARGS | 1 | NULL cred/token guard |
KACS_EXEC_ID_CHANGE_NO_TOKEN | 2 | uid/gid change, no subject token |
KACS_EXEC_ID_CHANGE_PRIV_UNSUPPORTED | 3 | id change + ASSIGN_PRIMARY_TOKEN priv |
KACS_EXEC_TOKEN_NPM_DERIVED | 4 | exec token derived via new-process-min |
KACS_EXEC_TOKEN_CLONE | 5 | exec token via primary clone fallback |
KACS_EXEC_NPM_NO_FILE | 6 | new-process-min needs file, none supplied |
KACS_EXEC_NPM_DERIVE_FAIL | 7 | new_process_min_exec derivation failed |
KACS_EXEC_TOKEN_INSTALL_FAIL | 8 | install token ref on new cred failed |
KACS_EXEC_TOKEN_CLONE_FAIL | 9 | primary token clone returned NULL |
KACS_EXEC_INTEGRITY_NO_ISEC | 10 | exec file inode has no i_security |
KACS_EXEC_INTEGRITY_NO_CACHE | 11 | exec file SD cache absent |
KACS_EXEC_INTEGRITY_INVALID_SD | 12 | exec file cached SD invalid/empty |
KACS_EXEC_IMPERSONATION_REVERT_FAIL | 13 | bprm impersonation revert failed |
KACS_EXEC_PIP_COMMITTED | 14 | pending exec PIP committed at commit |
KACS_EXEC_UMH_NOT_TCB | 15 | usermodehelper exec below PeiosTcb trust |
KACS_EXEC_SIGNATURE_UNVERIFIABLE | 16 | exec 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SIG_UNSIGNED | 0 | material source NONE (unsigned) |
KACS_SIG_BAD_KEY_TABLE | 1 | key table malformed / bad args |
KACS_SIG_NO_KEY_MATCH | 2 | no key verified the signature |
KACS_SIG_VERIFIED | 3 | a key verified; trust assigned |
KACS_SIG_CRYPTO_UNAVAILABLE | 4 | mldsa65 tfm allocation failed |
KACS_SIG_CRYPTO_MISMATCH | 5 | set-pubkey/verify returned nonzero |
KACS_SIG_PROBE_FOUND | 6 | valid signature material committed |
KACS_SIG_ELF_MAGIC_READ | 7 | failed reading ELF magic |
KACS_SIG_ELF_SHORT_EHDR | 8 | file too short for Elf64_Ehdr |
KACS_SIG_ELF_EHDR_READ | 9 | failed reading ELF header |
KACS_SIG_ELF_BAD_IDENT | 10 | unsupported e_ident class/data/version |
KACS_SIG_ELF_BAD_SHTABLE | 11 | bad shentsize/shstrndx |
KACS_SIG_ELF_SHDRS_RANGE | 12 | section-header table offset/len out of range |
KACS_SIG_ELF_SHSTR_READ | 13 | failed reading shstrtab section header |
KACS_SIG_ELF_STRTAB_RANGE | 14 | shstrtab offset/len out of range |
KACS_SIG_ELF_SHDR_READ | 15 | failed reading a section header |
KACS_SIG_ELF_NAME_READ | 16 | failed reading a section name |
KACS_SIG_ELF_BAD_SIG_SECTION | 17 | sig section wrong type/size/range |
KACS_SIG_ELF_BAD_BLOB | 18 | sig blob read failed or invalid |
KACS_SIG_ELF_HASH_FAIL | 19 | hashing failed for ELF sig |
KACS_SIG_XATTR_BAD_BLOB | 20 | xattr sig blob invalid |
KACS_SIG_XATTR_HASH_FAIL | 21 | hashing failed for xattr sig |
KACS_SIG_SIZE_CHANGED | 22 | file 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SOCK_BAD_ARGS | 0 | NULL/guard argument rejected |
KACS_SOCK_NOT_UNIX | 1 | not AF_UNIX / unsupported type |
KACS_SOCK_NO_SECURITY | 2 | sock has no sk_security blob |
KACS_SOCK_NO_TOKEN | 3 | no effective subject/client token |
KACS_SOCK_BAD_LEVEL | 4 | invalid impersonation level |
KACS_SOCK_WRONG_STATE | 5 | socket state forbids the op |
KACS_SOCK_NO_PEER_TOKEN | 6 | no captured peer token present |
KACS_SOCK_PIP_CONTEXT | 7 | caller PIP context unavailable |
KACS_SOCK_SD_DECISION | 8 | socket-SD check produced verdict |
KACS_SOCK_NO_SD | 9 | no socket SD; allowed without check |
KACS_SOCK_HAVE_SD | 10 | socket SD present; check performed |
KACS_SOCK_ALREADY_BOUND | 11 | socket SD already installed |
KACS_SOCK_BIND | 12 | abstract-socket SD bind result |
KACS_SOCK_CONNECT | 13 | unix_stream_connect result |
KACS_SOCK_LEVEL_SET | 14 | impersonation level updated |
KACS_SOCK_OPEN_TOKEN | 15 | open peer-token fd result |
KACS_SOCK_IMPERSONATE | 16 | impersonate 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.
| Constant | Value | Notes |
|---|---|---|
KACS_NS_PRIMARY | 0 | the op's principal decision |
KACS_NS_PARENT_FALLBACK | 1 | delete: parent DELETE_CHILD fallback |
KACS_NS_SOURCE | 2 | link/rename source-side decision |
KACS_NS_DEST | 3 | link/rename destination-parent add |
KACS_NS_DELETE_EXISTING | 4 | rename: 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_*.
| Constant | Value | Notes |
|---|---|---|
KACS_PSB_APPLY_OK | 0 | mitigations applied; result_bits set |
KACS_PSB_APPLY_NORMALIZE | 1 | requested mask bad or unsupported (EINVAL/ENODEV) |
KACS_PSB_APPLY_MM_ACQUIRE | 2 | could not acquire target mm (EACCES) |
KACS_PSB_APPLY_CFIF | 3 | forward-CFI (IBT) activation failed |
KACS_PSB_APPLY_SML | 4 | speculative-mitigation-lock activation failed |
KACS_PSB_APPLY_CFIB | 5 | backward-CFI (shadow stack) activation failed |
KACS_PSB_WXP_MMAP | 6 | W^X blocked a W+X mmap |
KACS_PSB_WXP_MPROTECT | 7 | W^X blocked an mprotect transition |
KACS_PSB_WXP_EXISTING_VMA | 8 | W^X activation blocked by an existing W+X vma |
KACS_PSB_LSV_PROBE | 9 | LSV signing probe of the image failed |
KACS_PSB_LSV_VERIFY | 10 | LSV signature not verified/trusted |
KACS_PSB_LSV_PIP_DOMINANCE | 11 | LSV image PIP does not dominate process PIP |
KACS_PSB_PIE_ET_EXEC | 12 | PIE blocked a non-PIE ET_EXEC image |
KACS_PSB_PRCTL_SML | 13 | prctl blocked by SML lock |
KACS_PSB_PRCTL_CFIB | 14 | prctl blocked by shadow-stack (CFIB) lock |
KACS_PSB_PRCTL_PIP | 15 | prctl 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.
| Constant | Value | Notes |
|---|---|---|
KACS_TOK_QUERY | 0 | KACS_IOC_QUERY |
KACS_TOK_ADJUST_PRIVS | 1 | KACS_IOC_ADJUST_PRIVS |
KACS_TOK_ADJUST_GROUPS | 2 | KACS_IOC_ADJUST_GROUPS |
KACS_TOK_DUPLICATE | 3 | KACS_IOC_DUPLICATE |
KACS_TOK_INSTALL | 4 | KACS_IOC_INSTALL |
KACS_TOK_RESTRICT | 5 | KACS_IOC_RESTRICT |
KACS_TOK_LINK | 6 | KACS_IOC_LINK_TOKENS |
KACS_TOK_GET_LINKED | 7 | KACS_IOC_GET_LINKED_TOKEN |
KACS_TOK_IMPERSONATE | 8 | KACS_IOC_IMPERSONATE |
KACS_TOK_ADJUST_DEFAULT | 9 | KACS_IOC_ADJUST_DEFAULT |
KACS_TOK_ADJUST_INTERACTIVITY_SCOPE | 10 | KACS_IOC_ADJUST_INTERACTIVITY_SCOPE |
KACS_TOK_UNKNOWN | 11 | unrecognised 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.
| Constant | Value | Notes |
|---|---|---|
KACS_TREF_TO_FD | 0 | token installed into a new fd (ret == fd) |
KACS_TREF_RELEASE | 1 | token-fd released; ref dropped |
KACS_TREF_BIND | 2 | token cloned + bound onto an existing file |
KACS_TREF_OPEN | 3 | token 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SES_CREATE | 0 | create_logon_session published a session |
KACS_SES_CREATE_PRIV_DENIED | 1 | create_logon_session: TCB privilege gate denied |
KACS_SES_DESTROY | 2 | destroy_empty_logon_session outcome |
KACS_SES_DESTROY_PRIV_DENIED | 3 | destroy: TCB privilege gate denied |
KACS_SES_CREATE_TOKEN | 4 | create_token issued a token fd |
KACS_SES_CREATE_TOKEN_PRIV_DENIED | 5 | create_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.
| Constant | Value | Notes |
|---|---|---|
KACS_CRED_PREPARE | 0 | cred_prepare token clone |
KACS_CRED_TRANSFER | 1 | cred_transfer token clone |
KACS_CRED_ALLOC_BLANK | 2 | cred_alloc_blank cleared sec |
KACS_CRED_FREE | 3 | cred_free released token/state |
KACS_CRED_INSTALL_TOKEN_REF | 4 | install token ref on a cred |
KACS_CRED_CLONE_THREAD_SHARE | 5 | CLONE_THREAD shares parent primary cred |
KACS_CRED_CLONE_FORK_COPY | 6 | fork deep-copies parent primary token |
KACS_CRED_PROJECT_UID0_BLOCKED | 7 | uid0 projection not allowed by token |
KACS_CRED_PROJECT_GROUPS_ALLOC_FAIL | 8 | groups_alloc failed (ENOMEM) |
KACS_CRED_PROJECT_E2BIG | 9 | supplementary 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SETID_SETUID_NO_TOKEN | 0 | setuid gate: no subject token |
KACS_SETID_SETUID_PRIV_GATE | 1 | setuid gate: ASSIGN_PRIMARY priv |
KACS_SETID_SETGID_NO_TOKEN | 2 | setgid gate: no subject token |
KACS_SETID_SETGID_PRIV_GATE | 3 | setgid gate: ASSIGN_PRIMARY priv |
KACS_SETID_SETGROUPS_NO_TOKEN | 4 | setgroups gate: no subject token |
KACS_SETID_SETGROUPS_PRIV_GATE | 5 | setgroups 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.
| Constant | Value | Notes |
|---|---|---|
KACS_TASK_ALLOC_NO_CHILD_BLOCKED | 0 | clone blocked by NO_CHILD mitigation |
KACS_TASK_ALLOC_INHERIT_ENOMEM | 1 | process-state inherit failed (ENOMEM) |
KACS_TASK_ALLOC | 2 | task_alloc completed |
KACS_TASK_FREE | 3 | task_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.
| Constant | Value | Notes |
|---|---|---|
KACS_PRIM_INSTALL_OK | 0 | primary token install committed |
KACS_PRIM_SD_REALLOC | 1 | user-SID changed; process SD reallocated |
KACS_PRIM_SD_ALLOC_FAIL | 2 | process SD realloc failed (ENOMEM) |
KACS_PRIM_APPLY_COMMIT | 3 | new real creds committed (commit_creds) |
KACS_PRIM_IMPERSONATE_INSTALL | 4 | impersonation token installed (override_creds) |
KACS_PRIM_IMPERSONATE_REVERT | 5 | impersonation reverted (revert_creds) |
KACS_PRIM_SIBLING_REQUEUE | 6 | queued sibling install re-queued on ENOMEM |
KACS_PRIM_SIBLING_FAILED | 7 | queued 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.
| Constant | Value | Notes |
|---|---|---|
KACS_PTO_OPEN_OK | 0 | token fd opened |
KACS_PTO_BAD_ARGS | 1 | NULL subject/state/task guard |
KACS_PTO_NO_TARGET | 2 | target has no token |
KACS_PTO_BAD_ACCESS | 3 | invalid access mask rejected |
KACS_PTO_ACCESS_DENIED | 4 | process access check denied |
KACS_PTO_SELF | 5 | self-target inspection allowed |
KACS_PTO_CROSS | 6 | cross-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.
| Constant | Value | Notes |
|---|---|---|
KACS_PST_ALLOC | 0 | process state allocated |
KACS_PST_ALLOC_FAIL | 1 | process state alloc failed (ENOMEM) |
KACS_PST_FREE | 2 | process state freed (refcount hit 0) |
KACS_PST_INHERIT_SHARE | 3 | CLONE_THREAD: parent state shared |
KACS_PST_INHERIT_FORK | 4 | fork: new state allocated from parent |
KACS_PST_EXEC_PIP_STAGE | 5 | pending exec PIP staged |
KACS_PST_EXEC_PIP_COMMIT | 6 | pending exec PIP committed to state |
KACS_PST_DUMPABLE | 7 | exec dumpable hardened by PIP |
KACS_PST_CLONE_BLOCKED_NOCHILD | 8 | clone blocked by NO_CHILD mitigation |
KACS_PST_SD_ALLOC | 9 | default process SD allocated |
KACS_PST_SD_ALLOC_FAIL | 10 | process/socket SD alloc failed |
KACS_PST_SD_WRAP_FAIL | 11 | process SD wrapper alloc failed (ENOMEM) |
KACS_PST_SD_REPLACE | 12 | process SD replaced on state |
KACS_PST_SOCKET_SD_ALLOC | 13 | default 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.
| Constant | Value | Notes |
|---|---|---|
KACS_MP_SET_OK | 0 | policy committed; generation bumped |
KACS_MP_BAD_ARGS | 1 | NULL subject/sb/args guard (EINVAL) |
KACS_MP_NO_SECURITY | 2 | superblock has no s_security (EOPNOTSUPP) |
KACS_MP_UNMANAGED | 3 | magic-derived UNMANAGED; not settable (EOPNOTSUPP) |
KACS_MP_VALIDATE | 4 | mount-policy args validation failed (EINVAL) |
KACS_MP_TEMPLATE_INVALID | 5 | template SD bytes failed validation (EINVAL) |
KACS_MP_TCB_DENIED | 6 | SeTcbPrivilege gate denied (EPERM) |
KACS_MP_GET_OK | 7 | policy snapshot returned |
KACS_MP_GET_NO_SECURITY | 8 | get: no s_security; magic-derived policy returned |
KACS_MP_FIXED_POLICY | 9 | filesystem 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SDS_KIND_TOKEN | 0 | token-fd target |
KACS_SDS_KIND_FILE | 1 | file/inode target |
KACS_SDS_KIND_PROCESS | 2 | pidfd process target |
KACS_SDS_KIND_PATH | 3 | path-resolved file target |
KACS_SDS_KIND_ACCESS_CHECK | 4 | AccessCheck 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SDS_QUERY_OK | 0 | SD subset extracted and returned |
KACS_SDS_SET_OK | 1 | SD merged/replaced |
KACS_SDS_BAD_ARGS | 2 | NULL/zero argument guard (EINVAL) |
KACS_SDS_UNMANAGED | 3 | superblock UNMANAGED (EOPNOTSUPP) |
KACS_SDS_ACCESS_DENIED | 4 | SD access check denied (EACCES) |
KACS_SDS_NO_SD | 5 | target has no usable SD (EACCES) |
KACS_SDS_RESTORE_BYPASS | 6 | set via SeRestorePrivilege bypass |
KACS_SDS_QUERY_FAIL | 7 | subset 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.
| Constant | Value | Notes |
|---|---|---|
KACS_ACK_OK | 0 | ingress dispatched to the ABI bridge |
KACS_ACK_EVAL_CONTEXT | 1 | token-eval-context gate denied (EACCES) |
KACS_ACK_TOKEN_RESOLVE | 2 | token/args resolution failed |
KACS_ACK_CAAP_LOCK_FAIL | 3 | caap-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).
| Constant | Value | Notes |
|---|---|---|
KACS_FSOP_ACCESS | 0 | generic snapshot-grant access check |
KACS_FSOP_PERMISSION | 1 | file_permission hook |
KACS_FSOP_IOCTL | 2 | file ioctl snapshot |
KACS_FSOP_LOCK | 3 | file lock snapshot |
KACS_FSOP_FCNTL | 4 | file fcntl snapshot |
KACS_FSOP_TRUNCATE | 5 | file truncate snapshot |
KACS_FSOP_FALLOCATE | 6 | file fallocate snapshot |
KACS_FSOP_MMAP | 7 | file mmap snapshot |
KACS_FSOP_MPROTECT | 8 | file mprotect snapshot |
KACS_FSOP_WRITE_INTENT | 9 | write-intent snapshot |
KACS_FSOP_SYSFS_WRITE_GATE | 10 | unmanaged 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.
| Constant | Value | Notes |
|---|---|---|
KACS_FSR_DECISION | 0 | resolved allow/deny (grant compare) |
KACS_FSR_SIGNED_EXEC | 1 | signed-exec content mutation denied |
KACS_FSR_GRANT_DENY | 2 | granted access lacked required right |
KACS_FSR_APPEND_DENY | 3 | append/write intent lacked write grant |
KACS_FSR_UNMANAGED_SYSFS | 4 | unmanaged fd: sysfs write gate applied |
KACS_FSR_AUDIT_EMIT_FAIL | 5 | continuous-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).
| Constant | Value | Notes |
|---|---|---|
KACS_META_DECISION | 0 | generic metadata decision |
KACS_META_CONSUME_HIT | 1 | consumed a pre-staged decision |
KACS_META_BEGIN_BUSY | 2 | begin failed: a decision already active |
KACS_META_CANONICAL_SD | 3 | canonical SD xattr access denied |
KACS_META_CAPS_XATTR | 4 | capability xattr mutation denied (EPERM) |
KACS_META_ACL | 5 | POSIX ACL xattr denied |
KACS_META_SIGNED_EXEC | 6 | signed-exec xattr/size mutation denied |
KACS_META_BAD_ARGS | 7 | NULL name / dentry guard |
KACS_META_INTERNAL_SD | 8 | internal SD read/write re-entry allowed |
KACS_META_GETSECURITY | 9 | inode_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).
| Constant | Value | Notes |
|---|---|---|
KACS_NOX_PREPARE_OK | 0 | prepare accepted the request |
KACS_NOX_PREPARE_BAD_FLAGS | 1 | flags/create_options/__pad rejected |
KACS_NOX_PREPARE_BAD_SD_ARGS | 2 | sd_ptr/sd_len/disposition-sd combo bad |
KACS_NOX_PREPARE_BAD_DISPOSITION | 3 | create_disposition out of range |
KACS_NOX_PREPARE_BAD_ACCESS | 4 | desired-access mask invalid/empty |
KACS_NOX_PREPARE_UNSUPPORTED | 5 | valid but unsupported combination |
KACS_NOX_RESOLVE | 6 | resolve-existing-path outcome |
KACS_NOX_BUILD_CREATED_SD | 7 | build-created-file-SD outcome |
KACS_NOX_DELETE_ON_CLOSE_ARM | 8 | delete-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.
| Constant | Value | Notes |
|---|---|---|
KACS_OBJ_DELETE_ON_CLOSE_UNLINK | 0 | file_release delete-on-close unlink attempt |
KACS_OBJ_SIGNED_EXEC_PIN | 1 | inode pinned as signed-exec (immutable) |
KACS_OBJ_SIGNED_EXEC_MUTATION_BLOCKED | 2 | content 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SFS_LOGON_SESSIONS_NO_TOKEN | 0 | sessions read: no effective subject token |
KACS_SFS_LOGON_SESSIONS_PIP_CONTEXT | 1 | sessions read: caller PIP context unavailable |
KACS_SFS_LOGON_SESSIONS_ACCESS_CHECK | 2 | sessions read: rust access check denied |
KACS_SFS_OPEN_SELF | 3 | open of kacs/self self-token file outcome |
KACS_SFS_INIT | 4 | securityfs 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.
| Constant | Value | Notes |
|---|---|---|
KACS_CAAP_TCB_GATE | 0 | SeTcbPrivilege gate denied the caller |
KACS_CAAP_SET | 1 | cache set (insert/evict); cache_len is post-set count |
KACS_CAAP_INIT | 2 | CAAP cache created |
KACS_CAAP_DESTROY | 3 | CAAP 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.
| Constant | Value | Notes |
|---|---|---|
KACS_CAP_ALLOW_GRANT | 0 | allow-cap auto-granted (no privilege needed) |
KACS_CAP_HARD_DENY | 1 | SETPCAP/SETFCAP/MAC_OVERRIDE hard-denied |
KACS_CAP_PRIV_NOT_ENABLED | 2 | mapped privilege not enabled on token |
KACS_CAP_USE_MARK_FAIL | 3 | privilege use-mark failed |
KACS_CAP_CAPSET | 4 | capset core outcome |
KACS_CAP_PRCTL_GUARD | 5 | prctl capability-guard outcome |
KACS_CAP_CAPABLE | 6 | capable() hook guard-deny outcome |
KACS_CAP_CAPGET | 7 | capget 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.
| Constant | Value | Notes |
|---|---|---|
KACS_PRIV_NULL_OR_ZERO | 0 | null token or zero privilege mask |
KACS_PRIV_NOT_ENABLED | 1 | privilege not enabled on token |
KACS_PRIV_USE_MARK_FAIL | 2 | privilege use-mark failed |
KACS_PRIV_CHANGE_NOTIFY | 3 | open_by_handle_at CHANGE_NOTIFY gate outcome |
KACS_PRIV_RCU_ENOMEM_FALLBACK | 4 | deferred-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.
| Constant | Value | Notes |
|---|---|---|
KACS_TLP_CHECK_PATH | 0 | executable transition denied: no prefix match |
KACS_TLP_REPLACE | 1 | TLP 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.
| Area | Departure | Why |
|---|---|---|
Conditional ACE @Local. | Resolved from an AccessCheck parameter rather than a token field | The context is per-call and varies between checks. |
| Virtual groups in expressions | Member_of({S-1-3-4}) returns true for the owner | Keeps the SID matcher and the expression evaluator semantically consistent. |
| INT64/UINT64 promotion | Relational operators promote between the two | Without promotion, UINT64 claims cannot be used in conditions at all. |
Member_of filtering | Filtered by ACE polarity, so deny-only groups do not satisfy allow-ACE conditions | Consistent with deny-only group semantics everywhere else. |
Exists scope | Extended to all four attribute namespaces | No reason to restrict existence tests to Local and Resource. |
| ACE mask mapping | ACE masks are mapped through GenericMapping at evaluation time | Required for GENERIC_ALL in central access policy recovery ACEs (§3.8.8). |
MAXIMUM_ALLOWED | First-writer-wins for targeted and maximum-allowed requests | Eliminates disagreement between "what can I do?" and "can I do this?" on a non-canonically ordered DACL. |
| Zero desired mask | Succeeds rather than returning access denied | "Asked for nothing, got nothing" is a valid answer. |
| Alarm ACEs | Repurposed for continuous per-operation auditing (§3.8.9) | Reserved but never implemented in the reference model. |
| Multiple scoped policy ACEs | Several permitted per SACL | AND semantics make composition safe. |
| Mandatory policy mutability | mandatory_policy is immutable on the token (§3.2.2) | A mutable policy reduces MIC to advisory. |
| Impersonation integrity ceiling | Enforced unconditionally; SeImpersonatePrivilege does not bypass it (§3.5.2) | MIC is a real boundary precisely because the mandatory policy is immutable. |
| Impersonation origin check | Dropped | Eliminates hidden impersonation paths. |
| PIP determination | Kernel-only, from the binary signature, with no parent input (§3.3.2) | One input, one answer, no ambiguity. |
| Object type list validation | Duplicate GUIDs and level gaps rejected (§3.8.5) | Prevents node lookup returning the wrong node and propagation becoming undefined. |
| Composite equality | Element-wise ordered comparison | Never 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 type | Emitted by |
|---|---|
access-audit | The SACL walk, and token audit-policy forcing. |
continuous-audit | Enforcement points, per operation, against a handle's continuous audit mask. |
privilege-use | Privilege-use auditing, for the five AccessCheck-influencing privileges. |
caap-policy-diagnostic | A CAAP rule SACL error, or a staged-versus-effective mismatch. |
logon-session-destroyed | LogonSession teardown (§3.2.7). |
corrupt-sd | A descriptor xattr that exists but fails structural validation (§3.9.5). |
STRATAFS_COPY_UP | StrataFS copy-up lifecycle and failure (§3.9.7). |
STRATAFS_MUTATION_REFUSED | A 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.
| Class | Payload |
|---|---|
USER | Bare SID. |
GROUPS | SID array. |
PRIVILEGES | 32 bytes: present, enabled, enabled-by-default and used, four u64 in that order. |
TYPE | u32, 4 bytes. |
INTEGRITY_LEVEL | The mandatory-label SID S-1-16-<level>, 12 bytes. |
OWNER | Bare SID, resolved through the owner index: 0 is the user SID, N is groups[N-1]. |
PRIMARY_GROUP | Bare SID, resolved the same way. |
INTERACTIVITY_SCOPE | u32, 4 bytes. |
RESTRICTED_SIDS | SID array; count 0 on an unrestricted token. |
SOURCE | 16 bytes: an 8-byte name followed by a u64 LUID. |
STATISTICS | 40 bytes: token id, LogonSession id, modified id, token type, a reserved zero, and expiration. |
ORIGIN | u64, 8 bytes. |
ELEVATION_TYPE | u32, 4 bytes. |
DEVICE_GROUPS | SID array. |
APPCONTAINER_SID | Bare SID; empty when the token is unconfined. |
CAPABILITIES | SID array. |
MANDATORY_POLICY | u32, 4 bytes. |
LOGON_TYPE | u32, 4 bytes, read from the LogonSession. |
LOGON_SID | Bare SID, derived from the LogonSession id. |
DEFAULT_DACL | Binary ACL; empty when none is set. |
IMPERSONATION_LEVEL | u32, 4 bytes. |
USER_CLAIMS | Claims array. |
DEVICE_CLAIMS | Claims 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-DTYP | uapi 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_TOKEN | KACS_TOKEN_OPEN_REAL |
KACS_LEVEL_* | KACS_IMLEVEL_* |
KACS_FILE_SUPERSEDE, _OPEN, ... | KACS_DISPOSITION_* |
OWNER_SECURITY_INFORMATION, ... | KACS_SECINFO_* |
SE_PRIVILEGE_ENABLED / _REMOVED | KACS_PRIVILEGE_ATTR_ENABLED / _REMOVED |
KACS_PRIV_RESET_ALL_DEFAULTS | KACS_PRIVILEGE_RESET_ALL_DEFAULTS |
KACS_RESTRICT_WRITE_RESTRICTED | KACS_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.
| Flag | Bit | Meaning |
|---|---|---|
create | STRATAFS_F_CREATE | This stratum receives newly created objects and is the destination of copy-up. |
ro | STRATAFS_F_RO | stratafs does not modify this stratum. |
am | STRATAFS_F_AM | This 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 absent | There 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 absolute | Tested on the raw first byte, which is exact since / is not escapable |
| A path is empty after unescaping | Defensive; the absolute-path test already guarantees one byte |
An unescaped , appears in a path, or inside a flag token | The 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 one | Flag names are matched by exact length and content |
| The same flag appears more than once on one stratum | |
| More than 16 strata | The 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 #
| Condition | Error |
|---|---|
| The stratum stack is empty | EINVAL |
| The stack exceeds 16 strata | EINVAL |
More than one stratum carries create | EINVAL |
A stratum carries both create and ro | EINVAL |
| The same directory appears as more than one stratum | EINVAL |
A malformed strata= value (§4.2.2) | EINVAL |
A create-bearing stack from outside the initial user namespace, or without CAP_SYS_ADMIN there | EPERM |
| A stratum's path names something other than a directory | ENOTDIR |
A stratum's directory is absent and the stratum does not carry am | ENOENT |
| The composed stack reaches the kernel's maximum stacking depth | ELOOP |
| A stratum lies within the mount point, or within another stratafs mount whose strata include this mount point | ELOOP |
| Sixteen consecutive collisions allocating a mount cookie | EAGAIN |
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 detect | Why it is unnecessary here |
|---|---|
| A stratum gaining or losing a name | The next walk resolves the name afresh |
| A stratum's directory appearing | The next walk finds it |
| A stratum's directory removed, renamed away, or renamed over | The 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.
- If the mount is read-only, the route is read-only. This term short-circuits everything else.
- If the provider accepts modification, the operation is performed against the provider's object.
- 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.
- 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.
| Operation | Routes |
|---|---|
| Writing or appending | Yes |
Truncating, or any other setattr | Yes |
| Changing mode, owner, timestamps, or the security descriptor | Yes |
| Setting or removing an extended attribute | Yes |
fallocate | Yes |
splice into the file | Yes |
copy_file_range and remap_file_range | Yes |
| Establishing a shared writable mapping | Yes |
| Reading contents, attributes, or extended attributes | No |
| Opening, for any access | No |
| Taking or releasing a lock, or a lease | No |
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 type | Result in the create stratum |
|---|---|
| Regular file | A regular file with the same contents and mode |
| Symbolic link | A symbolic link with the same target |
| Directory | An 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.
- If there is no create stratum, or it is absent, the operation fails
with
EROFS. - 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. - 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:
Pmust accept modification. OtherwiseEROFS— the source cannot be removed, so the rename would leave it still visible and amount to a copy.- The destination must not be provided by a stratum of higher
precedence than
P. OtherwiseEROFS— the renamed object would be shadowed at its destination and unreadable through the path it was renamed to. Pmust hold the directory containing the destination. OtherwiseEXDEV. That directory is not created to satisfy the condition, whether or notPis the create stratum: parent materialisation exists to receive a copy-up, and a rename is not one.- Where the source is a directory, the merged directory must contain
no entries provided by a stratum other than
P. OtherwiseEXDEV, 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 withEACCESwithout disclosing whether other strata contributed. - 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 withENOTDIR. Only the provider's type is compared, since a lower stratum's entry of a different type is masked and contributes no inode. - Where the destination's provider is a directory, that merged
directory must contain no entries at all — the same merged-emptiness
test
rmdiruses, with the same access requirement. OtherwiseENOTEMPTY. - The rename is performed within
P: both parents are resolved at the provider's index, and a singlevfs_renameis 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.
| Flag | Behaviour |
|---|---|
RENAME_NOREPLACE | The 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_EXCHANGE | Both 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_WHITEOUT | Fails 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:
Pmust accept modification. OtherwiseEXDEV.- The destination must not be held by any participating stratum.
Otherwise
EEXIST. Pmust hold the directory containing the destination. OtherwiseEXDEV. That directory is not created to satisfy the condition, whether or notPis the create stratum.- 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
flockunlock 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:
- The per-open-file mutation lock, taken by every operation that may copy up.
- The per-outer-inode rebind lock, taken inside it whenever an inode's provider is swapped.
- 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.
| Operation | Checked against |
|---|---|
| Resolving a name | Search |
| Enumerating | Enumerate, before the listing is captured |
| Reading an object's attributes | Search, plus whatever the object's own descriptor requires |
| Reading the origin attribute on a merged directory | Search, plus read-EA on every participant (§4.7) |
| Creating a name | Search, plus add-file or add-subdirectory on the create stratum's directory |
| Copy-up | Nothing beyond what the operation it serves already required |
| Removing a name | Search, plus delete-child on the provider's directory |
| Removing a directory | Enumerate — emptiness is judged across every participant — plus delete-child on the provider's directory |
| Rename, source | Search, plus delete-child on the source provider's directory |
| Rename, destination | Search, 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 directory | Both of the above, plus Enumerate on the object being renamed, and Enumerate on the destination where its provider is a directory |
RENAME_EXCHANGE | Search on both, plus add-entry and delete-child on the directory of the stratum providing both names |
| Link | Search on both, plus add-file on the source provider's directory |
| Creating or linking an unnamed file | Search, 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 | |
|---|---|
path | The relative path within the mount, /-prefixed |
provider_index | The stratum the object was copied from |
provider_stratum | That stratum's path |
create_index | The stratum it was copied into |
create_stratum | That stratum's path |
result_errno | Zero 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 #
| Condition | Error |
|---|---|
| Caller lacks the access to resolve a stratum path or read its attributes | EACCES |
Empty stratum stack, or strata= absent | EINVAL |
| Stack longer than 16 strata | EINVAL |
More than one stratum carries create | EINVAL |
A stratum carries both create and ro | EINVAL |
| The same directory appears twice in the stack | EINVAL |
Any malformed strata= value (§4.2.2) | EINVAL |
strata= supplied on a remount | EINVAL |
| An allocation failure while parsing | ENOMEM |
A create-bearing stack established outside the initial user namespace, or without CAP_SYS_ADMIN there | EPERM |
| A stratum path names a non-directory | ENOTDIR |
A stratum is absent without am | ENOENT |
| A stratum lies within the mount point, or within another stratafs mount whose strata include this mount point | ELOOP |
| The composed stack reaches the kernel's maximum stacking depth | ELOOP |
| Sixteen consecutive collisions allocating a mount cookie | EAGAIN |
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 #
| Condition | Error |
|---|---|
| No participating stratum holds the name | ENOENT |
| An ancestor of the path is masked by a non-directory provider | ENOTDIR |
| Operation requires a non-directory; provider is a directory | EISDIR |
| Traverse or list refused on any participating directory | EACCES |
| A stratum walk fails for a reason other than absence | that error |
A joined stratum path or child relative path exceeds PATH_MAX | ENAMETOOLONG |
| A task re-enters the same superblock while resolving | ELOOP |
4.8.3 Mutation #
| Condition | Error |
|---|---|
| Provider will not accept modification, and no create stratum has higher precedence | EROFS |
| Any mutation on a read-only-mounted stratafs | EROFS |
| Creation, unnamed-file creation, or unnamed-file link with no create stratum, or it absent | EROFS |
| Create stratum holds a path component as a non-directory | ENOTDIR |
| Exclusive creation where any stratum holds the name | EEXIST |
| Copy-up cannot preserve an extended attribute | EIO |
| Copy-up cannot preserve the descriptor | EACCES, 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 publication | ESTALE |
| Copy-up of a device node, FIFO or socket | EROFS |
| Unlink where the provider will not accept modification | EROFS, or EPERM where the provider inode is immutable |
| Rmdir where another stratum holds entries within | ENOTEMPTY |
| Rmdir or directory rename where list is refused on a participant | EACCES |
| Modifying a special file's mode, descriptor or extended attributes where its provider will not accept modification | EROFS |
| Establishing a shared write-capable mapping where routing yields read-only | EROFS |
| Replacing disposition where §4.5.4 would refuse the removal, or where a stratum above the create stratum would still hold the name | EROFS |
| Replacing disposition on anything but a regular file | EOPNOTSUPP |
| Rename where the source's provider will not accept modification | EROFS |
| Rename where a stratum above the source's provider provides the destination | EROFS |
| Rename where the source's provider does not hold the destination's directory | EXDEV |
| Rename of a directory containing entries from other strata | EXDEV |
| Rename of a non-directory onto a directory provider | EISDIR |
| Rename of a directory onto a non-directory provider | ENOTDIR |
| Rename onto a destination directory not empty across every stratum | ENOTEMPTY |
RENAME_NOREPLACE where any stratum holds the destination | EEXIST |
RENAME_EXCHANGE where the two names are not provided by one stratum that accepts modification | EROFS |
RENAME_EXCHANGE where another stratum holds entries under either directory name | EXDEV |
RENAME_WHITEOUT | EINVAL |
| Hard link whose source's provider will not accept modification, or does not hold the destination's directory | EXDEV |
| Hard link where any stratum holds the destination | EEXIST |
| Linking an unnamed file into a different mount | EXDEV |
| A dentry whose private state is missing, or whose provider identity no longer matches | ESTALE |
ioctl on a regular file | ENOTTY, or ENOIOCTLCMD for the compat form |
remap_file_range with flags outside dedupe and advisory | EINVAL |
4.8.4 Interface #
| Condition | Error |
|---|---|
| Set or remove of any reserved attribute | EPERM |
Read of a reserved attribute other than origin | ENODATA |
| Origin read where read-EA is refused on any participant | EACCES |
| Origin read into an undersized buffer | ERANGE |
| Freezing the filesystem | EOPNOTSUPP |
| Reading a mount's policy without TCB privilege | refused 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
EROFSarrives 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
ESTALEon 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). fstatandstatmay 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
readdiroffset 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 #
| Constant | Value | Meaning |
|---|---|---|
STRATAFS_MAGIC | 0x53545241 | Superblock magic, reported by statfs (§4.2.2) |
STRATAFS_NAME | "stratafs" | The name the filesystem registers under |
STRATAFS_MAX_STRATA | 16 | Longest 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 #
| Constant | Value | Meaning |
|---|---|---|
STRATAFS_F_CREATE | 0x1 | The create flag (§4.2.1) |
STRATAFS_F_RO | 0x2 | The ro flag |
STRATAFS_F_AM | 0x4 | The am flag |
4.A.3 Extended attributes #
| Constant | Value | Meaning |
|---|---|---|
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 #
| Constant | Value | Meaning |
|---|---|---|
STRATAFS_STAGE_MARKER_MAGIC | 0x53544731 | Marker magic |
STRATAFS_STAGE_MARKER_VERSION | 1 | Marker version |
STRATAFS_COPY_BUFFER_SIZE | 65536 | Copy-up read/write chunk, in bytes (§4.5.2) |
STRATAFS_STAGE_PREFIX | ".stratafs-stage-" | Staged-name prefix |
STRATAFS_RECOVERY_BATCH | 128 | Names 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.
| Offset | Size | Field | Type |
|---|---|---|---|
| 0 | 4 | magic | __le32 |
| 4 | 2 | version | __le16 |
| 6 | 2 | size | __le16 |
| 8 | 8 | boot_cookie | __le64 |
| 16 | 8 | mount_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 enumerator | Value | Rust |
|---|---|---|
STRATAFS_ROUTE_IN_PLACE | 0 | InPlace |
STRATAFS_ROUTE_COPY_UP | 1 | CopyUp |
STRATAFS_ROUTE_READ_ONLY | 2 | ReadOnly |
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.
| Constant | Value |
|---|---|
MAX_STRATA | 16 |
FLAG_CREATE | 0x1 |
FLAG_READ_ONLY | 0x2 |
FLAG_ABSENT_MAY | 0x4 |
FLAG_MASK | 0x7 |
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).
| Error | Discriminant |
|---|---|
Empty | 1 |
TooMany | 2 |
UnknownFlag | 3 |
RepeatedCreate | 4 |
CreateReadOnly | 5 |
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.olookup.oinode.ofile.odir.oxattr.ocopy_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.
- 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.
- Values are layered. Every write is tagged with a layer; the effective value is the highest-precedence entry. Tombstones can actively mask lower layers.
- 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.
- 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.
- 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.
- 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 #
| Property | Layered | Resolved by | Survives layer deletion |
|---|---|---|---|
| Path existence | Yes | Highest precedence, then highest sequence | No |
| Values | Yes | Highest precedence, then highest sequence | No |
| Value tombstones | Yes | Masking lower precedence | No |
| Blanket tombstones | Yes | Masking all lower-precedence values on the key | No |
| Key hiding | Yes | Masking lower precedence | No |
| Key GUID | No | Direct on the key object | Yes |
| Security Descriptor | No | Direct on the key object | Yes |
| Volatile flag | No | Direct on the key object | Yes |
| Symlink flag | No | Direct on the key object | Yes |
| Last write time | No | Direct on the key object | Yes |
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.
| Windows | LCS | |
|---|---|---|
| Backing store | Kernel-internal hive files | Userspace sources over the RSI, so a storage backend is not a kernel change |
| Hive routing | A fixed set of predefined hives | Any source may register any name at runtime |
| Layers | None; registry.pol is applied by flattening values | Precedence-ordered layers with tombstones, resolved at query time, so removal reverts rather than tattoos |
| Change observation | RegNotifyChangeKeyValue, single-shot | Persistent watches, closing the re-registration race |
| Key identity | Hive cell offsets | GUIDs, stable across storage reorganisation |
| Forward slash | Not accepted | Accepted on input, normalised to backslash |
| Case comparison | RtlCompareUnicodeString | Unicode 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.
| Field | Description |
|---|---|
| Name | The hive name, e.g. Machine, Users. Case-preserving, compared case-insensitively. |
| Root GUID | The GUID of the hive's root key, supplied by the source at registration. |
| Source | The source slot backing this hive. |
| Status | Active 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_PRIVATEflag 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 returnedENOENT. - Unknown flag bits are rejected.
- Opening
/dev/pkm_registryat all requires an enabledSeTcbPrivilege, checked in the device'sopen()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).
| Field | Mutable | Layered | Description |
|---|---|---|---|
| GUID | No | No | Identity, assigned by LCS at creation, persisted by the source. |
| Name | No | No | The key's own name component. Informational; the authoritative name is in the path entry. |
| Parent GUID | No | No | The parent key. Nil for a hive root. |
| Security Descriptor | Yes | No | Computed from the parent at creation; changed at runtime with WRITE_DAC / WRITE_OWNER. |
| Last write time | Yes | No | Updated when a value is written or deleted or the descriptor changes. |
| Volatile | No | No | The source stores this key in non-persistent storage only. |
| Symlink | No | No | This 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_KEYon the parent, as for any key;KEY_CREATE_LINKon the parent;- either an enabled
SeTcbPrivilegeor 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.
| Field | Description |
|---|---|
| Parent GUID | The parent key. |
| Child name | The key's name under that parent — one component, not a path. |
| Layer | The layer this entry belongs to. |
| Target | The GUID of the key at this path in this layer, or HIDDEN. |
| Sequence | A 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.
| Field | Description |
|---|---|
| Key GUID | The key this value belongs to. |
| Name | The value's name; the empty string is the default value. Case-preserving, compared case-insensitively. |
| Type | A registry value type. |
| Data | An opaque byte array, up to MaxValueSize (default 1 MB). |
| Layer | The layer this entry belongs to. Every write is tagged. |
| Sequence | Assigned 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.
| Field | Mutable | Description |
|---|---|---|
| Name | No | The layer's identity — not a GUID, not an integer. Case-preserving, compared with Unicode Simple Case Folding. Bounded by MaxPathComponentLength. |
| Precedence | Yes | Higher wins. Default 0. |
| Enabled | Yes | A 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:
| Value | Type | Default if missing |
|---|---|---|
Precedence | REG_DWORD | 0 |
Enabled | REG_DWORD, 0 or 1 | true |
Owner | REG_BINARY, a SID | the 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 #
| Operation | Requirement |
|---|---|
| Create a layer at precedence 0 | KEY_CREATE_SUB_KEY on Layers\ |
| Create a layer above precedence 0 | KEY_CREATE_SUB_KEY on Layers\ and SeTcbPrivilege |
| Write into a layer | KEY_SET_VALUE on the layer's metadata key |
| Modify layer metadata | KEY_SET_VALUE on the metadata key; raising precedence above 0 additionally requires SeTcbPrivilege |
| Delete a layer | DELETE 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:
- 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.
- 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).
- 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).
- If there are no candidates, the answer is not-found.
- Take the maximum. A winning HIDDEN path entry, a winning
REG_TOMBSTONEvalue 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.
-
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.
-
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.
-
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_ALLOWEDis the exception, and the only way to ask for whatever is available. AccessCheck computes the full allowed set and that becomes the granted mask. -
The granted mask is stored on the fd. It never changes.
-
Per-ioctl checks are bitmask tests. Each ioctl has a required right; LCS tests the fd's granted mask against it and returns
EACCESwithout 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_ALLOWEDmay appear alone or combined with anything else.SYNCHRONIZE(0x00100000) is not a registry right, so it is an unknown bit and failsEINVAL.
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 #
| Operation | Required |
|---|---|
REG_IOC_QUERY_VALUE, QUERY_VALUES_BATCH, ENUM_VALUES | KEY_QUERY_VALUE |
REG_IOC_SET_VALUE, DELETE_VALUE, BLANKET_TOMBSTONE, FLUSH | KEY_SET_VALUE |
REG_IOC_ENUM_SUBKEYS | KEY_ENUMERATE_SUB_KEYS |
REG_IOC_QUERY_KEY_INFO | READ_CONTROL |
REG_IOC_DELETE_KEY, HIDE_KEY | DELETE |
REG_IOC_NOTIFY | KEY_NOTIFY |
REG_IOC_GET_SECURITY | READ_CONTROL for owner, group and DACL; ACCESS_SYSTEM_SECURITY for the SACL |
REG_IOC_SET_SECURITY | WRITE_OWNER for owner or group; WRITE_DAC for the DACL; ACCESS_SYSTEM_SECURITY for the SACL |
REG_IOC_BACKUP | SeBackupPrivilege, no per-key check |
REG_IOC_RESTORE | SeRestorePrivilege, no per-key check |
| creating a key | KEY_CREATE_SUB_KEY on the parent |
| creating a symlink key | KEY_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\:
| Principal | Rights | Inheritance |
|---|---|---|
| SYSTEM | KEY_ALL_ACCESS | Container-inherit |
| Administrators | KEY_ALL_ACCESS | Container-inherit |
| Authenticated Users | KEY_READ | Container-inherit |
Users\<SID>\:
| Principal | Rights | Inheritance |
|---|---|---|
| the user's SID | KEY_ALL_ACCESS | Container-inherit |
| SYSTEM | KEY_ALL_ACCESS | Container-inherit |
| Administrators | KEY_ALL_ACCESS | Container-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.
| Event | Emitted when |
|---|---|
LCS_KEY_OPEN_AUDIT | A key open matched a SACL audit ACE. |
LCS_BACKUP_START | Before REG_IOC_BACKUP reads any subtree data. |
LCS_BACKUP_COMPLETE | After a backup completes or fails after starting. |
LCS_RESTORE_START | Before REG_IOC_RESTORE modifies any source state. |
LCS_RESTORE_COMPLETE | After a restore completes or fails after starting. |
LCS_SOURCE_VALIDATION_FAILURE | LCS rejected malformed source data. |
LCS_SELF_CONFIG_INVALID | LCS 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 withEIOand 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 returnsEIOand 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 withEIO. 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:
| Field | Description |
|---|---|
| Source and key GUID | The identity of the opened key. |
| Granted access mask | Computed once by AccessCheck at open. Immutable. |
| Resolved path | After symlink resolution and CurrentUser\ rewriting. |
| Ancestor chain | The GUID at each path component from the hive root down. Captured during the open walk, used for subtree watch dispatch (§5.6.3). |
| Watch state | Armed 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 ;
Opens an existing key. Fails if it does not exist after layer resolution.
| Parameter | Description |
|---|---|
parent_fd | An open key fd to resolve relative to, or -1 for an absolute path. No AccessCheck is performed on the parent. |
path | A null-terminated registry path — absolute with a hive prefix when parent_fd is -1, relative to the parent key otherwise. |
desired_access | Requested rights, raw generic bits, MAXIMUM_ALLOWED, or a combination (§5.4.2). Zero and unknown bits are EINVAL. |
flags | REG_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.
- Parse and canonicalise the path: normalise separators, reject empty components and a trailing separator, check the total length and each component length.
- Rewrite a leading
CurrentUser\toUsers\<caller SID>\— only for an absolute path, and only the first component (§5.2.1). - 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.
- Walk the path component by component through
RSI_LOOKUP, resolving each through the layer stack and following symlinks — except a final component whenREG_OPEN_LINKis set. Collect the ancestor chain as you go. - Run AccessCheck against the final key's descriptor.
- 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.
| Errno | Condition |
|---|---|
ENOENT | The key does not exist after layer resolution. |
EACCES | AccessCheck did not grant everything requested. |
EINVAL | Malformed path; zero or unknown desired_access bits; a symlink whose effective default value is not REG_LINK; maximum depth exceeded. |
ELOOP | Symlink depth limit exceeded. |
ENAMETOOLONG | A component or the total path is too long. |
ETIMEDOUT | The source did not answer within RequestTimeoutMs. |
EIO | The source failed or is unavailable. |
ENOMEM | Kernel allocation failure. |
5.5.2.2 reg_create_key (1101) #
int ;
Opens the key if it exists after layer resolution, creates it with an inherited descriptor if not, and reports which happened.
| Field | Description |
|---|---|
parent_fd | As reg_open_key. |
path_ptr | Pointer to a null-terminated path. |
desired_access | As reg_open_key. |
flags | REG_OPTION_VOLATILE (0x01), REG_OPTION_CREATE_LINK (0x02). Other bits reserved. |
layer_ptr | Pointer to a null-terminated layer name for creation, or null for the base layer. Ignored if the key already exists. |
txn_fd | A transaction fd, or -1. A non-negative value makes creation a mutating operation that binds or reuses that transaction. |
disposition_ptr | Receives REG_CREATED_NEW (1) or REG_OPENED_EXISTING (2). May be null. |
_pad0, _pad1 | Reserved; 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:
- Resolve the parent, which must exist. Check that the parent's depth
plus one is within
MaxKeyDepth. - AccessCheck the parent for
KEY_CREATE_SUB_KEY. - Perform layer write authorization against the target layer's metadata key (§5.3.4).
- Mint a fresh UUIDv4 GUID.
- Compute the new key's descriptor from the parent's, through KACS.
- Create the path entry with a new sequence number, then the key record (§5.2.5).
- AccessCheck the new key's inherited descriptor against
desired_access— an inherited descriptor may not grant everything the creator asked for. - 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).
| Errno | Condition, in addition to reg_open_key's |
|---|---|
ENOENT | The parent does not exist, or the named layer is not in the layer table. |
EACCES | The parent denied KEY_CREATE_SUB_KEY, the inherited descriptor denied the requested access, or layer write authorization failed. |
EPERM | REG_OPTION_CREATE_LINK without KEY_CREATE_LINK on the parent, or without SeTcbPrivilege or Administrators. |
ENOSPC | The per-value layer cap was exceeded. |
EINVAL | A non-volatile key under a volatile parent; a non-zero reserved field. |
5.5.2.3 reg_begin_transaction (1102) #
int ;
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 #
| Errno | Condition |
|---|---|
EACCES | The granted mask lacks the required right, or layer write authorization failed. |
EFAULT | An input pointer is invalid, or a non-zero-length output buffer pointer is null or unwritable. |
EIO | The source is unavailable, failed, or the transaction's source went Down. |
ETIMEDOUT | The source did not answer within RequestTimeoutMs, or the transaction had timed out. |
ENOMEM | Kernel allocation failure. |
EXDEV | The transaction is bound to a different hive. |
ENOENT | The named layer is not in the layer table. |
EINVAL | A non-zero reserved or padding field. |
ENOTTY | An 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:
| Errno | Condition |
|---|---|
| — | Success. The object becomes COMMITTED, poll waiters are woken, watch events are delivered, and further use of the fd returns EINVAL. |
EINVAL | Already committed, or never bound to a source. |
EBUSY | The source could not take the write lock. The transaction stays ACTIVE_BOUND; retry. |
EIO | The source failed to commit. The transaction stays ACTIVE_BOUND. |
ETIMEDOUT | The 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.
| State | terminal_errno |
|---|---|
REG_TXN_ACTIVE_UNBOUND | 0 |
REG_TXN_ACTIVE_BOUND | 0 |
REG_TXN_COMMITTED | 0 |
REG_TXN_ABORTED | EINVAL |
REG_TXN_TIMED_OUT | ETIMEDOUT |
REG_TXN_SOURCE_DOWN | EIO |
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.
| Errno | What it means here |
|---|---|
ENOENT | A 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. |
EACCES | AccessCheck denied a right, the fd's granted mask lacks it, or layer write authorization failed. |
EINVAL | An 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. |
EFAULT | An invalid userspace pointer. A zero-length output probe ignores its pointer; a non-zero length with a null or unwritable pointer does not. |
ENAMETOOLONG | A key or value name exceeds MaxPathComponentLength, or a path exceeds MaxTotalPathLength. |
ELOOP | The symlink resolution depth limit was exceeded. |
ETIMEDOUT | The source did not answer within RequestTimeoutMs, or a timed-out transaction fd was used. |
EIO | The source failed, is unavailable, returned malformed data, or a transaction's bound source went Down. |
ENOMEM | Kernel allocation failure. |
ENOSPC | A layer cap was exceeded, or value data exceeds MaxValueSize. |
ENOTEMPTY | A key with visible children cannot be deleted. |
EXDEV | An operation targeted a different hive from the one its transaction is bound to. |
EPERM | A privilege the caller does not hold: symlink creation, creating or raising a layer above precedence 0, backup, restore. |
EAGAIN | A conditional write failed — the layer entry's sequence did not match. Re-read and retry. |
EBUSY | A commit could not take the source's write lock, or MaxBoundTransactionsPerSource or MaxReadOnlyTransactionsPerSource was exceeded. |
ERANGE | An output buffer is too small. Every determinable required size is written; buffers are not partially filled. Retry with larger ones. |
EEXIST | A path entry or key already exists at the source. |
ENOTSUP | The source does not support the transaction mode being requested. |
EBADF | An 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. |
EOVERFLOW | A 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. |
ESTALE | A 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 #
| Event | Code | Name field | Meaning |
|---|---|---|---|
REG_WATCH_VALUE_SET | 1 | value name | The effective value at this name changed or appeared. |
REG_WATCH_VALUE_DELETED | 2 | value name | The effective value at this name disappeared. |
REG_WATCH_SUBKEY_CREATED | 3 | subkey name | A child key became visible. |
REG_WATCH_SUBKEY_DELETED | 4 | subkey name | A child key became invisible. |
REG_WATCH_SD_CHANGED | 5 | empty | The watched key's Security Descriptor was modified. |
REG_WATCH_KEY_DELETED | 6 | empty | The watched key itself became invisible. |
REG_WATCH_OVERFLOW | 7 | empty | Events 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 bit | Value | Admits |
|---|---|---|
REG_NOTIFY_VALUE | 0x01 | VALUE_SET, VALUE_DELETED |
REG_NOTIFY_SUBKEY | 0x02 | SUBKEY_CREATED, SUBKEY_DELETED |
REG_NOTIFY_SD | 0x04 | SD_CHANGED |
REG_NOTIFY_ALL | 0x07 | all 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.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | total_len |
| 4 | 2 | event_type |
| 6 | 2 | name_len |
| 8 | name_len | name, 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:
| Size | Field |
|---|---|
| 2 | path_depth |
2 + n each | path_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:
- Look up B's GUID in the watch map and queue the event, with
path_depth0, to every watcher whose filter admits it. - 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:
- The oldest queued event is dropped.
- An
OVERFLOWrecord is queued in its place. - 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 #
| State | Value | Meaning |
|---|---|---|
REG_TXN_ACTIVE_UNBOUND | 0 | Active, no source chosen. |
REG_TXN_ACTIVE_BOUND | 1 | Active, bound to a source. |
REG_TXN_COMMITTED | 2 | Commit completed. |
REG_TXN_ABORTED | 3 | Explicitly or implicitly aborted. |
REG_TXN_TIMED_OUT | 4 | The lifetime timer fired. |
REG_TXN_SOURCE_DOWN | 5 | The 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_COMMITon the transaction fd. The source applies everything atomically, watch events fire, and the object becomesCOMMITTED. Later use of the fd returnsEINVAL. The fd should be closed. - Explicit abort.
close()without committing. The source is told to discard, and the object becomesABORTEDduring 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_COMMITandREG_IOC_TXN_STATUS; every other ioctl isENOTTY. - 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_PRIVATEcarries 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 withinMaxRegisteredSources(32). Either isENOSPC. - The reported maximum sequence can be advanced past without
overflowing 64 bits, or registration fails
EOVERFLOWand 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
EIOto its caller. - An
LCS_SOURCE_VALIDATION_FAILUREaudit 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_LAYERresponse. - 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:
- The slot is marked Down and its hives become unavailable.
- Every pending request fails
EIO, including ones queued but not yet delivered. - 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
EIOuntil the source comes back. - Bound transactions enter
REG_TXN_SOURCE_DOWN, their poll waiters are woken withPOLLERR | POLLHUP, their mutation logs are released, and further use of those fds returnsEIO. - Watches stay armed. Watch state is kernel-side and does not
depend on the source at all. Nothing is delivered during the window,
and
OVERFLOWarrives 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:
NotificationQueueSizeper queue, timesRLIMIT_NOFILEqueues 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 byMaxLayersPerValue. - 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 #
- Validate the whole stream, including the trailer's record count and checksum, and retain every replayable record.
- Read the layer manifest and apply the precedence gate (below).
- Verify the root record and its immutable flags against the live target.
- Tear down the target's contents and descendants — path entries, values, blanket tombstones, and descendant key records — inside the transaction.
- Write the root's mutable fields, then replay its section's values and blanket tombstones.
- For each non-root key in stream order: create it, write its last write time, then replay its path entries, values and blanket tombstones.
- 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:
- LCS is always operational with compiled-in defaults. It accepts syscalls from the moment PKM initialises.
- The base layer requires no persisted state.
- Hot-swap is the only configuration transition. LCS never blocks, restarts, or re-initialises.
- 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.
| Value | Default | Range | Bounds |
|---|---|---|---|
RequestTimeoutMs | 30000 | 1000–600000 | A source round trip (§5.8.3). |
TransactionTimeoutMs | 30000 | 1000–600000 | The lifetime of an open transaction (§5.7.1). |
NotificationQueueSize | 256 | 16–65536 | Queued events per watcher before overflow (§5.6.4). |
SymlinkDepthLimit | 16 | 1–64 | Symlink resolution depth (§5.2.4). |
MaxValueSize | 1048576 | 4096–67108864 | One value's data, in bytes. |
MaxKeyDepth | 512 | 32–4096 | Key hierarchy nesting. |
MaxPathComponentLength | 255 | 64–1024 | One key, value or layer name, in UTF-8 bytes. |
MaxTotalPathLength | 16383 | 1024–65535 | A whole path, in UTF-8 bytes. |
MaxLayersPerValue | 128 | 1–1024 | Layers writing to one (key, value name) (§5.3.1). |
MaxBoundTransactionsPerSource | 16 | 1–256 | Concurrently bound transactions per source (§5.7.1). |
MaxReadOnlyTransactionsPerSource | 16 | 1–256 | Concurrent backup snapshots per source (§5.9.2). |
MaxTotalLayers | 1024 | 16–65536 | Distinct layers in the in-memory table (§5.3.1). |
MaxRegisteredSources | 32 | 1–256 | Concurrently registered sources. |
MaxHivesPerSource | 64 | 1–1024 | Hives one source may register. |
MaxConcurrentRSIRequests | 256 | 8–4096 | In-flight RSI requests per source (§5.8.3). |
MaxScopeGUIDsPerToken | 8 | 1–256 | Private hive scope GUIDs on a token (§5.2.2). |
MaxPrivateLayersPerToken | 16 | 1–256 | Private layer names on a token (§5.3.5). |
MaxSubtreeWatchDepth | 0 | 0–4096 | Subtree watch depth; 0 is unlimited (§5.6.3). |
MaxTransactionWatchEventBurst | 4096 | 256–65536 | Watch 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_INVALIDaudit 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 #
- A source registers. LCS reads
Machine\System\Registry\*; the keys do not exist; compiled-in defaults are retained. - Seed restore populates them. The subtree watch fires, LCS validates and hot-swaps to the seed values.
- 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/.
| Number | Constant | Signature |
|---|---|---|
| 1100 | SYS_REG_OPEN_KEY | reg_open_key(int parent_fd, const char __user *path, u32 desired_access, u32 flags) |
| 1101 | SYS_REG_CREATE_KEY | reg_create_key(const struct reg_create_key_args __user *args) |
| 1102 | SYS_REG_BEGIN_TRANSACTION | reg_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.
| Ioctl | Number | Direction | Argument | Arg size | Encoded |
|---|---|---|---|---|---|
REG_SRC_REGISTER | 0 | _IOW | struct reg_src_register_args | 24 | 0x40185200 |
Key fd.
| Ioctl | Number | Direction | Argument | Arg size | Encoded |
|---|---|---|---|---|---|
REG_IOC_QUERY_VALUE | 0 | _IOWR | struct reg_query_value_args | 64 | 0xC0405200 |
REG_IOC_SET_VALUE | 1 | _IOW | struct reg_set_value_args | 64 | 0x40405201 |
REG_IOC_DELETE_VALUE | 2 | _IOW | struct reg_delete_value_args | 40 | 0x40285202 |
REG_IOC_BLANKET_TOMBSTONE | 3 | _IOW | struct reg_blanket_tombstone_args | 24 | 0x40185203 |
REG_IOC_QUERY_VALUES_BATCH | 4 | _IOWR | struct reg_query_values_batch_args | 24 | 0xC0185204 |
REG_IOC_ENUM_VALUES | 5 | _IOWR | struct reg_enum_value_args | 40 | 0xC0285205 |
REG_IOC_ENUM_SUBKEYS | 6 | _IOWR | struct reg_enum_subkey_args | 40 | 0xC0285206 |
REG_IOC_QUERY_KEY_INFO | 7 | _IOWR | struct reg_query_key_info_args | 64 | 0xC0405207 |
REG_IOC_DELETE_KEY | 8 | _IOW | struct reg_delete_key_args | 24 | 0x40185208 |
REG_IOC_HIDE_KEY | 9 | _IOW | struct reg_hide_key_args | 24 | 0x40185209 |
REG_IOC_GET_SECURITY | 10 | _IOWR | struct reg_get_security_args | 16 | 0xC010520A |
REG_IOC_SET_SECURITY | 11 | _IOW | struct reg_set_security_args | 24 | 0x4018520B |
REG_IOC_NOTIFY | 12 | _IOW | struct reg_notify_args | 8 | 0x4008520C |
REG_IOC_FLUSH | 13 | _IO | none | 0 | 0x0000520D |
REG_IOC_BACKUP | 14 | _IOW | struct reg_backup_args | 4 | 0x4004520E |
REG_IOC_RESTORE | 15 | _IOW | struct reg_restore_args | 4 | 0x4004520F |
Transaction fd.
| Ioctl | Number | Direction | Argument | Arg size | Encoded |
|---|---|---|---|---|---|
REG_IOC_COMMIT | 16 | _IO | none | 0 | 0x00005210 |
REG_IOC_TXN_STATUS | 17 | _IOR | struct reg_txn_status_args | 8 | 0x80085211 |
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.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __s32 | parent_fd |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | path_ptr |
| 16 | 4 | __u32 | desired_access |
| 20 | 4 | __u32 | flags |
| 24 | 8 | __u64 | layer_ptr |
| 32 | 4 | __s32 | txn_fd |
| 36 | 4 | __u32 | _pad1 |
| 40 | 8 | __u64 | disposition_ptr |
5.A.3.2 struct reg_query_value_args #
Total size 64 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | name_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | name_ptr |
| 16 | 4 | __u32 | type |
| 20 | 4 | __u32 | data_len |
| 24 | 4 | __s32 | txn_fd |
| 28 | 4 | __u32 | layer_buf_len |
| 32 | 8 | __u64 | data_ptr |
| 40 | 8 | __u64 | sequence |
| 48 | 4 | __u32 | layer_len |
| 52 | 4 | __u32 | _pad1 |
| 56 | 8 | __u64 | layer_ptr |
5.A.3.3 struct reg_set_value_args #
Total size 64 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | name_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | name_ptr |
| 16 | 4 | __u32 | type |
| 20 | 4 | __u32 | data_len |
| 24 | 8 | __u64 | data_ptr |
| 32 | 4 | __u32 | layer_len |
| 36 | 4 | __u32 | _pad1 |
| 40 | 8 | __u64 | layer_ptr |
| 48 | 4 | __s32 | txn_fd |
| 52 | 4 | __u32 | _pad2 |
| 56 | 8 | __u64 | expected_seq |
5.A.3.4 struct reg_delete_value_args #
Total size 40 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | name_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | name_ptr |
| 16 | 4 | __u32 | layer_len |
| 20 | 4 | __u32 | _pad1 |
| 24 | 8 | __u64 | layer_ptr |
| 32 | 4 | __s32 | txn_fd |
| 36 | 4 | __u32 | _pad2 |
5.A.3.5 struct reg_blanket_tombstone_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | layer_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | layer_ptr |
| 16 | 1 | __u8 | set |
| 17 | 3 | __u8``[3] | _pad1 |
| 20 | 4 | __s32 | txn_fd |
5.A.3.6 struct reg_query_values_batch_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | buf_len |
| 4 | 4 | __u32 | count |
| 8 | 8 | __u64 | buf_ptr |
| 16 | 4 | __s32 | txn_fd |
| 20 | 4 | __u32 | _pad |
5.A.3.7 struct reg_enum_value_args #
Total size 40 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | index |
| 4 | 4 | __u32 | name_len |
| 8 | 8 | __u64 | name_ptr |
| 16 | 4 | __u32 | type |
| 20 | 4 | __u32 | data_len |
| 24 | 8 | __u64 | data_ptr |
| 32 | 4 | __s32 | txn_fd |
| 36 | 4 | __u32 | _pad |
5.A.3.8 struct reg_enum_subkey_args #
Total size 40 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | index |
| 4 | 4 | __u32 | name_len |
| 8 | 8 | __u64 | name_ptr |
| 16 | 8 | __u64 | last_write_time |
| 24 | 4 | __u32 | subkey_count |
| 28 | 4 | __u32 | value_count |
| 32 | 4 | __s32 | txn_fd |
| 36 | 4 | __u32 | _pad |
5.A.3.9 struct reg_query_key_info_args #
Total size 64 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | name_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | name_ptr |
| 16 | 8 | __u64 | last_write_time |
| 24 | 4 | __u32 | subkey_count |
| 28 | 4 | __u32 | value_count |
| 32 | 4 | __u32 | max_subkey_name_len |
| 36 | 4 | __u32 | max_value_name_len |
| 40 | 4 | __u32 | max_value_data_size |
| 44 | 4 | __u32 | sd_size |
| 48 | 1 | __u8 | volatile_key |
| 49 | 1 | __u8 | symlink |
| 50 | 6 | __u8``[6] | _pad1 |
| 56 | 8 | __u64 | hive_generation |
5.A.3.10 struct reg_delete_key_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | layer_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | layer_ptr |
| 16 | 4 | __s32 | txn_fd |
| 20 | 4 | __u32 | _pad1 |
5.A.3.11 struct reg_hide_key_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | layer_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | layer_ptr |
| 16 | 4 | __s32 | txn_fd |
| 20 | 4 | __u32 | _pad1 |
5.A.3.12 struct reg_get_security_args #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | security_info |
| 4 | 4 | __u32 | sd_len |
| 8 | 8 | __u64 | sd_ptr |
5.A.3.13 struct reg_set_security_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | security_info |
| 4 | 4 | __u32 | sd_len |
| 8 | 8 | __u64 | sd_ptr |
| 16 | 4 | __s32 | txn_fd |
| 20 | 4 | __u32 | _pad |
5.A.3.14 struct reg_notify_args #
Total size 8 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | filter |
| 4 | 1 | __u8 | subtree |
| 5 | 3 | __u8``[3] | _pad |
5.A.3.15 struct reg_backup_args #
Total size 4 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __s32 | output_fd |
5.A.3.16 struct reg_restore_args #
Total size 4 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __s32 | input_fd |
5.A.3.17 struct reg_txn_status_args #
Total size 8 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | state |
| 4 | 4 | __s32 | terminal_errno |
5.A.3.18 struct reg_src_register_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | hive_count |
| 4 | 4 | __u32 | _pad |
| 8 | 8 | __u64 | max_sequence |
| 16 | 8 | __u64 | hives_ptr |
5.A.3.19 struct reg_src_hive_entry #
Total size 56 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | name_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | name_ptr |
| 16 | 16 | __u8``[16] | root_guid |
| 32 | 4 | __u32 | flags |
| 36 | 4 | __u32 | _pad1 |
| 40 | 16 | __u8``[16] | scope_guid |
5.A.4 Constants #
Grouped as the header groups them.
Syscall and ioctl argument sizes.
| Constant | Value |
|---|---|
REG_CREATE_KEY_ARGS_SIZE | 48 |
REG_QUERY_VALUE_ARGS_SIZE | 64 |
REG_SET_VALUE_ARGS_SIZE | 64 |
REG_DELETE_VALUE_ARGS_SIZE | 40 |
REG_BLANKET_TOMBSTONE_ARGS_SIZE | 24 |
REG_QUERY_VALUES_BATCH_ARGS_SIZE | 24 |
REG_ENUM_VALUE_ARGS_SIZE | 40 |
REG_ENUM_SUBKEY_ARGS_SIZE | 40 |
REG_QUERY_KEY_INFO_ARGS_SIZE | 64 |
REG_DELETE_KEY_ARGS_SIZE | 24 |
REG_HIDE_KEY_ARGS_SIZE | 24 |
REG_GET_SECURITY_ARGS_SIZE | 16 |
REG_SET_SECURITY_ARGS_SIZE | 24 |
REG_NOTIFY_ARGS_SIZE | 8 |
REG_BACKUP_ARGS_SIZE | 4 |
REG_RESTORE_ARGS_SIZE | 4 |
REG_TXN_STATUS_ARGS_SIZE | 8 |
REG_SRC_REGISTER_ARGS_SIZE | 24 |
REG_SRC_HIVE_ENTRY_SIZE | 56 |
_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.
| Constant | Value |
|---|---|
REG_IOC_QUERY_KEY_INFO | 0xC0405207 |
REG_IOC_DELETE_KEY | 0x40185208 |
REG_IOC_HIDE_KEY | 0x40185209 |
REG_IOC_GET_SECURITY | 0xC010520A |
REG_IOC_SET_SECURITY | 0x4018520B |
REG_IOC_NOTIFY | 0x4008520C |
REG_IOC_FLUSH | 0x0000520D |
REG_IOC_BACKUP | 0x4004520E |
REG_IOC_RESTORE | 0x4004520F |
Transaction state codes.
| Constant | Value |
|---|---|
REG_TXN_ACTIVE_UNBOUND | 0 |
REG_TXN_ACTIVE_BOUND | 1 |
REG_TXN_COMMITTED | 2 |
REG_TXN_ABORTED | 3 |
REG_TXN_TIMED_OUT | 4 |
REG_TXN_SOURCE_DOWN | 5 |
Syscall flags and dispositions.
| Constant | Value |
|---|---|
REG_OPEN_LINK | 0x01 |
REG_OPTION_VOLATILE | 0x01 |
REG_OPTION_CREATE_LINK | 0x02 |
REG_CREATED_NEW | 1 |
REG_OPENED_EXISTING | 2 |
Registry key access rights.
| Constant | Value |
|---|---|
KEY_QUERY_VALUE | 0x00000001 |
KEY_SET_VALUE | 0x00000002 |
KEY_CREATE_SUB_KEY | 0x00000004 |
KEY_ENUMERATE_SUB_KEYS | 0x00000008 |
KEY_NOTIFY | 0x00000010 |
KEY_CREATE_LINK | 0x00000020 |
DELETE | 0x00010000 |
READ_CONTROL | 0x00020000 |
WRITE_DAC | 0x00040000 |
WRITE_OWNER | 0x00080000 |
ACCESS_SYSTEM_SECURITY | 0x01000000 |
MAXIMUM_ALLOWED | 0x02000000 |
GENERIC_ALL | 0x10000000 |
GENERIC_EXECUTE | 0x20000000 |
GENERIC_WRITE | 0x40000000 |
GENERIC_READ | 0x80000000 |
KEY_READ | 0x00020019 |
KEY_WRITE | 0x00020006 |
KEY_ALL_ACCESS | 0x000F003F |
REG_VALID_DESIRED_ACCESS_MASK | 0xF30F003F |
REG_VALID_MAPPED_ACCESS_MASK | 0x010F003F |
REG_VALID_ACE_ACCESS_MASK | 0xF10F003F |
Security information flags for REG_IOC_GET_SECURITY / SET_SECURITY.
| Constant | Value |
|---|---|
OWNER_SECURITY_INFORMATION | 0x00000001 |
GROUP_SECURITY_INFORMATION | 0x00000002 |
DACL_SECURITY_INFORMATION | 0x00000004 |
SACL_SECURITY_INFORMATION | 0x00000008 |
REG_VALID_SECURITY_INFORMATION | 0x0000000F |
Registry value types.
| Constant | Value |
|---|---|
REG_NONE | 0 |
REG_SZ | 1 |
REG_EXPAND_SZ | 2 |
REG_BINARY | 3 |
REG_DWORD | 4 |
REG_DWORD_BIG_ENDIAN | 5 |
REG_LINK | 6 |
REG_MULTI_SZ | 7 |
REG_RESOURCE_LIST | 8 |
REG_FULL_RESOURCE_DESCRIPTOR | 9 |
REG_RESOURCE_REQUIREMENTS_LIST | 10 |
REG_QWORD | 11 |
REG_TOMBSTONE | 0xFFFF |
Watch event types and filters.
| Constant | Value |
|---|---|
REG_WATCH_VALUE_SET | 1 |
REG_WATCH_VALUE_DELETED | 2 |
REG_WATCH_SUBKEY_CREATED | 3 |
REG_WATCH_SUBKEY_DELETED | 4 |
REG_WATCH_SD_CHANGED | 5 |
REG_WATCH_KEY_DELETED | 6 |
REG_WATCH_OVERFLOW | 7 |
Watch event raw byte layout.
| Constant | Value |
|---|---|
REG_WATCH_EVENT_TOTAL_LEN_OFFSET | 0 |
REG_WATCH_EVENT_TYPE_OFFSET | 4 |
REG_WATCH_EVENT_NAME_LEN_OFFSET | 6 |
REG_WATCH_EVENT_NAME_OFFSET | 8 |
REG_WATCH_EVENT_MIN_SIZE | 8 |
REG_WATCH_SUBTREE_PATH_DEPTH_REL_OFFSET | 0 |
REG_WATCH_SUBTREE_PATH_DEPTH_SIZE | 2 |
REG_WATCH_SUBTREE_PATH_COMPONENTS_REL_OFFSET | 2 |
REG_WATCH_PATH_COMPONENT_LEN_SIZE | 2 |
REG_NOTIFY_VALUE | 0x01 |
REG_NOTIFY_SUBKEY | 0x02 |
REG_NOTIFY_SD | 0x04 |
REG_NOTIFY_ALL | 0x07 |
RSI common wire layout.
| Constant | Value |
|---|---|
RSI_REQUEST_TOTAL_LEN_OFFSET | 0 |
RSI_REQUEST_ID_OFFSET | 4 |
RSI_REQUEST_OP_CODE_OFFSET | 12 |
RSI_REQUEST_TXN_ID_OFFSET | 14 |
RSI_REQUEST_HEADER_SIZE | 22 |
RSI_RESPONSE_TOTAL_LEN_OFFSET | 0 |
RSI_RESPONSE_ID_OFFSET | 4 |
RSI_RESPONSE_OP_CODE_OFFSET | 12 |
RSI_RESPONSE_HEADER_SIZE | 14 |
RSI_RESPONSE_STATUS_OFFSET | 14 |
RSI_STATUS_SIZE | 4 |
RSI_MIN_RESPONSE_SIZE | 18 |
RSI_LENGTH_PREFIX_SIZE | 4 |
RSI_GUID_SIZE | 16 |
RSI_RESPONSE_BIT | 0x8000 |
RSI op codes and response op codes.
| Constant | Value |
|---|---|
RSI_LOOKUP | 0x0001 |
RSI_CREATE_ENTRY | 0x0002 |
RSI_HIDE_ENTRY | 0x0003 |
RSI_DELETE_ENTRY | 0x0004 |
RSI_ENUM_CHILDREN | 0x0005 |
RSI_CREATE_KEY | 0x0010 |
RSI_READ_KEY | 0x0011 |
RSI_WRITE_KEY | 0x0012 |
RSI_DROP_KEY | 0x0013 |
RSI_QUERY_VALUES | 0x0020 |
RSI_SET_VALUE | 0x0021 |
RSI_DELETE_VALUE_ENTRY | 0x0022 |
RSI_SET_BLANKET_TOMBSTONE | 0x0023 |
RSI_BEGIN_TRANSACTION | 0x0030 |
RSI_COMMIT_TRANSACTION | 0x0031 |
RSI_ABORT_TRANSACTION | 0x0032 |
RSI_FLUSH | 0x0040 |
RSI_DELETE_LAYER | 0x0050 |
RSI_LOOKUP_RESPONSE | 0x8001 |
RSI_CREATE_ENTRY_RESPONSE | 0x8002 |
RSI_HIDE_ENTRY_RESPONSE | 0x8003 |
RSI_DELETE_ENTRY_RESPONSE | 0x8004 |
RSI_ENUM_CHILDREN_RESPONSE | 0x8005 |
RSI_CREATE_KEY_RESPONSE | 0x8010 |
RSI_READ_KEY_RESPONSE | 0x8011 |
RSI_WRITE_KEY_RESPONSE | 0x8012 |
RSI_DROP_KEY_RESPONSE | 0x8013 |
RSI_QUERY_VALUES_RESPONSE | 0x8020 |
RSI_SET_VALUE_RESPONSE | 0x8021 |
RSI_DELETE_VALUE_ENTRY_RESPONSE | 0x8022 |
RSI_SET_BLANKET_TOMBSTONE_RESPONSE | 0x8023 |
RSI_BEGIN_TRANSACTION_RESPONSE | 0x8030 |
RSI_COMMIT_TRANSACTION_RESPONSE | 0x8031 |
RSI_ABORT_TRANSACTION_RESPONSE | 0x8032 |
RSI_FLUSH_RESPONSE | 0x8040 |
RSI_DELETE_LAYER_RESPONSE | 0x8050 |
RSI status codes.
| Constant | Value |
|---|---|
RSI_OK | 0 |
RSI_NOT_FOUND | 1 |
RSI_ALREADY_EXISTS | 2 |
RSI_STORAGE_ERROR | 3 |
RSI_NOT_EMPTY | 4 |
RSI_TOO_LARGE | 5 |
RSI_TXN_BUSY | 6 |
RSI_INVALID | 7 |
RSI_CAS_FAILED | 8 |
RSI_TXN_NOT_SUPPORTED | 9 |
RSI path target types.
| Constant | Value |
|---|---|
RSI_PATH_TARGET_GUID | 0 |
RSI_PATH_TARGET_HIDDEN | 1 |
RSI_WRITE_KEY field mask bits.
| Constant | Value |
|---|---|
RSI_WRITE_KEY_FIELD_SD | 0x01 |
RSI_WRITE_KEY_FIELD_LAST_WRITE_TIME | 0x02 |
RSI_WRITE_KEY_FIELD_KNOWN_MASK | 0x00000003 |
RSI transaction modes and source-registration flags.
| Constant | Value |
|---|---|
RSI_TXN_READ_WRITE | 0 |
RSI_TXN_READ_ONLY | 1 |
RSI_HIVE_PRIVATE | 0x01 |
Backup record types and magic.
| Constant | Value |
|---|---|
REG_BACKUP_HEADER | 0x01 |
REG_BACKUP_LAYER | 0x02 |
REG_BACKUP_KEY | 0x03 |
REG_BACKUP_PATH_ENTRY | 0x04 |
REG_BACKUP_VALUE | 0x05 |
REG_BACKUP_BLANKET_TOMBSTONE | 0x06 |
REG_BACKUP_TRAILER | 0xFF |
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.
| Constant | Value | Notes |
|---|---|---|
LCS_OP_LOOKUP | 0 | RSI_LOOKUP |
LCS_OP_READ_KEY | 1 | RSI_READ_KEY |
LCS_OP_ENUM_CHILDREN | 2 | RSI_ENUM_CHILDREN |
LCS_OP_QUERY_VALUES | 3 | RSI_QUERY_VALUES |
LCS_OP_SET_VALUE | 4 | RSI_SET_VALUE |
LCS_OP_DELETE_VALUE | 5 | RSI_DELETE_VALUE_ENTRY |
LCS_OP_BLANKET_TOMBSTONE | 6 | RSI_SET_BLANKET_TOMBSTONE |
LCS_OP_DROP_KEY | 7 | RSI_DROP_KEY |
LCS_OP_CREATE_ENTRY | 8 | RSI_CREATE_ENTRY |
LCS_OP_HIDE_ENTRY | 9 | RSI_HIDE_ENTRY |
LCS_OP_DELETE_ENTRY | 10 | RSI_DELETE_ENTRY |
LCS_OP_CREATE_KEY | 11 | RSI_CREATE_KEY |
LCS_OP_WRITE_KEY | 12 | RSI_WRITE_KEY |
LCS_OP_TXN_BEGIN | 13 | RSI_BEGIN_TRANSACTION |
LCS_OP_TXN_COMMIT | 14 | RSI_COMMIT_TRANSACTION |
LCS_OP_TXN_ABORT | 15 | RSI_ABORT_TRANSACTION |
LCS_OP_FLUSH | 16 | RSI_FLUSH |
LCS_OP_DELETE_LAYER | 17 | RSI_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.
| Constant | Value | Notes |
|---|---|---|
LCS_RESP_ACCEPTED | 0 | response matched an in-flight request |
LCS_RESP_DESYNC | 1 | no matching delivered/unaccepted record |
LCS_RESP_OP_MISMATCH | 2 | response op != request op | RESPONSE_BIT |
LCS_RESP_UNKNOWN_STATUS | 3 | rsi_status not a known status code |
LCS_RESP_MALFORMED_PAYLOAD | 4 | per-op response body failed validation |
LCS_RESP_LATE_COMMIT_FAIL | 5 | commit late-effect failed; source DOWN |
LCS_RESP_LATE_MUTATION_FAIL | 6 | mutation late-effect failed; source DOWN |
LCS_RESP_LATE_BEGIN_FAIL | 7 | begin-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.
| Constant | Value | Notes |
|---|---|---|
LCS_SRC_OPEN | 0 | new source fd issued (post-TCB check) |
LCS_SRC_RELEASE | 1 | fd .release() teardown |
LCS_SRC_MALFORMED | 2 | malformed protocol frame -> mark down |
LCS_SRC_EXPLICIT | 3 | explicit mark-down of this fd |
LCS_SRC_MARK_BY_ID | 4 | mark-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.
| Constant | Value | Notes |
|---|---|---|
LCS_IF_INSERT | 0 | request inserted into in-flight table |
LCS_IF_DELIVERED | 1 | request delivered to source read() |
LCS_IF_RELEASE | 2 | request released from in-flight table |
lcs_route op — which resolution the lcs:lcs_route event describes.
| Constant | Value | Notes |
|---|---|---|
LCS_ROUTE_HIVE_NAME | 0 | hive-name -> source/root resolution |
LCS_ROUTE_ABSOLUTE_PATH | 1 | absolute-path -> source/root resolution |
LCS_ROUTE_SYMLINK_TARGET | 2 | symlink-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.
| Constant | Value | Notes |
|---|---|---|
LCS_REG_NEW | 0 | new source slot admitted |
LCS_REG_RESUME_DOWN | 1 | down source slot resumed |
LCS_REG_COPY | 2 | registration input copied from user |
LCS_REG_REPLAY_FAIL | 3 | resume pending-delete replay failed (EIO) |
LCS_REG_OVERFLOW_FAIL | 4 | resume 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.
| Constant | Value | Notes |
|---|---|---|
LCS_BOOT_REGISTRY | 0 | registry root discover phase |
LCS_BOOT_KMES | 1 | kmes config root discover phase |
LCS_BOOT_LAYERS | 2 | layer metadata root discover phase |
LCS_BOOT_SELF_WATCH | 3 | self-watch arm phase |
LCS_BOOT_COMPLETE | 4 | bootstrap refresh completed |
LCS_BOOT_SELF_CONFIG_REFRESH | 5 | self-config refresh-from-key outcome |
LCS_BOOT_SELF_CONFIG_PARAM_INVALID | 6 | self-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.
| Constant | Value | Notes |
|---|---|---|
LCS_LIM_REQUEST_TIMEOUT_MS | 0 | |
LCS_LIM_TRANSACTION_TIMEOUT_MS | 1 | |
LCS_LIM_NOTIFICATION_QUEUE_SIZE | 2 | |
LCS_LIM_SYMLINK_DEPTH_LIMIT | 3 | |
LCS_LIM_MAX_VALUE_SIZE | 4 | |
LCS_LIM_MAX_KEY_DEPTH | 5 | |
LCS_LIM_MAX_PATH_COMPONENT_LENGTH | 6 | |
LCS_LIM_MAX_TOTAL_PATH_LENGTH | 7 | |
LCS_LIM_MAX_LAYERS_PER_VALUE | 8 | |
LCS_LIM_MAX_BOUND_TRANSACTIONS_PER_SOURCE | 9 | |
LCS_LIM_MAX_READ_ONLY_TRANSACTIONS_PER_SOURCE | 10 | |
LCS_LIM_MAX_TOTAL_LAYERS | 11 | |
LCS_LIM_MAX_REGISTERED_SOURCES | 12 | |
LCS_LIM_MAX_HIVES_PER_SOURCE | 13 | |
LCS_LIM_MAX_CONCURRENT_RSI_REQUESTS | 14 | |
LCS_LIM_MAX_SCOPE_GUIDS_PER_TOKEN | 15 | |
LCS_LIM_MAX_PRIVATE_LAYERS_PER_TOKEN | 16 | |
LCS_LIM_MAX_SUBTREE_WATCH_DEPTH | 17 | |
LCS_LIM_MAX_TRANSACTION_WATCH_EVENT_BURST | 18 | |
LCS_LIM_ALL | 19 | whole-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.
| Constant | Value | Notes |
|---|---|---|
LCS_AUDIT_KEY_OPEN | 0 | key-open SACL audit |
LCS_AUDIT_BACKUP_START | 1 | |
LCS_AUDIT_BACKUP_COMPLETE | 2 | |
LCS_AUDIT_RESTORE_START | 3 | |
LCS_AUDIT_RESTORE_COMPLETE | 4 | |
LCS_AUDIT_VALIDATION_FAILURE | 5 | source validation-failure audit |
LCS_AUDIT_SELF_CONFIG_INVALID | 6 | self-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.
| Constant | Value | Notes |
|---|---|---|
LCS_TXN_ST_ACTIVE_UNBOUND | 0 | allocated, not yet source-bound |
LCS_TXN_ST_ACTIVE_BOUND | 1 | bound to a source + root guid |
LCS_TXN_ST_COMMITTED | 2 | commit round-trip succeeded |
LCS_TXN_ST_ABORTED | 3 | aborted (close / layer writer abort) |
LCS_TXN_ST_TIMED_OUT | 4 | deadline timer or commit timeout |
LCS_TXN_ST_SOURCE_DOWN | 5 | bound 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.
| Constant | Value | Notes |
|---|---|---|
LCS_KCMD_NONE | 0 | no ioctl verb (publish/release/read) |
LCS_KCMD_SET_VALUE | 1 | |
LCS_KCMD_DELETE_VALUE | 2 | |
LCS_KCMD_BLANKET_TOMBSTONE | 3 | |
LCS_KCMD_DELETE_KEY | 4 | |
LCS_KCMD_HIDE_KEY | 5 | |
LCS_KCMD_QUERY_VALUE | 6 | |
LCS_KCMD_QUERY_VALUES_BATCH | 7 | |
LCS_KCMD_ENUM_VALUES | 8 | |
LCS_KCMD_ENUM_SUBKEYS | 9 | |
LCS_KCMD_QUERY_KEY_INFO | 10 | |
LCS_KCMD_GET_SECURITY | 11 | |
LCS_KCMD_SET_SECURITY | 12 | |
LCS_KCMD_FLUSH | 13 | |
LCS_KCMD_BACKUP | 14 | |
LCS_KCMD_RESTORE | 15 | |
LCS_KCMD_NOTIFY | 16 |
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.