Peios Kernel
The Peios kernel as it is built: the KMES event subsystem, the KACS access control system, the stratafs layered filesystem, the LCS registry, and the NTFE packet engine.
Single-page view · as markdown
1.1 Overview
Peios / Peios technical reference / 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.
For kernel build-time and initialization requirements, see Kernel invariants.
1.2 Kernel invariants
Peios / Peios technical reference / PKM / Introduction
The boot chain — kernel-direct tokens, peinit at PID 1, the authd handoff — relies on a handful of kernel-level invariants to be safe. Things like "PKM is in the LSM stack and no incompatible LSM is", "the kernel rejects unsigned kernel modules", "physical memory is restricted to what is appropriate". If these invariants are violated, the security model the rest of these docs describes is no longer guaranteed.
This page covers the invariants, where each is enforced, and the consequences of failure. They are not user-facing settings in the sense of "things you might change" — they are kernel build-time and init-time configurations that the deployment requires. But knowing what they are helps with reasoning about the trust chain and diagnosing the rare case where something doesn't fit the expected model.
1.2.1 The LSM stack invariant #
The Linux Security Module framework lets the kernel host multiple security modules in a defined order. Peios's KACS is implemented as an LSM called PKM (Peios Kernel Module). For PKM to function correctly:
- PKM must be in the LSM stack.
- MAC (Mandatory Access Control) LSMs must not be in the stack — specifically SELinux, AppArmor, SMACK, and TOMOYO.
- The BPF LSM must not be active.
The reasoning: MAC LSMs and PKM both want to make authoritative access decisions, and the LSM framework's composition rules don't merge their decisions cleanly. A system with SELinux and PKM would have both modules trying to decide each access; the result would be that either both must agree (over-restrictive) or one supersedes the other (whichever the LSM order puts first), neither of which is the right semantic. Peios's model is that PKM is the sole authoritative module for the operations it covers.
1.2.1.1 What the kernel verifies #
The kernel verifies at PKM initialisation that no MAC LSM is active and that BPF LSM is not in use. If any are present, PKM refuses to activate. The kernel boots, but the access control is now broken — PKM is supposed to be enforcing, and it isn't. This is detected and reported as an init-time error.
Specifically, PKM refuses to activate if any of selinux, apparmor, smack, tomoyo, or bpf LSMs are present in the active stack.
1.2.1.2 The required stack #
The LSM stack Peios expects is, in order:
landlock, lockdown, integrity, pkm
commoncapis implicit (Linux always includes it; not listed inCONFIG_LSMexplicitly).landlockis permitted — it's a process-local sandboxing LSM that processes opt into; it doesn't conflict with PKM.lockdownis permitted — it gates specific kernel-debugging interfaces; orthogonal to PKM.integrityis permitted — Linux's IMA/EVM mechanisms can run alongside PKM for the use cases they cover.pkmis the Peios module.
yama is not built. Its ptrace_scope decides attach ahead of PKM, and its escape hatch is CAP_SYS_PTRACE, which PKM maps to SeDebugPrivilege — so with it present, a process descriptor that grants debugging never gets a say. PKM's own ptrace gating is the whole authority.
Distributors building a Peios kernel must include this stack. Building a kernel with SELinux enabled and active would produce a system where PKM cannot activate — boot would fail with a clear error.
1.2.2 Module signing #
CONFIG_MODULE_SIG_FORCE=y is required. This flag tells the kernel to refuse to load any kernel module that is not signed by a key the kernel recognises.
The reasoning: a kernel module runs with full kernel privileges. An attacker who can load arbitrary kernel modules bypasses every layer of PIP and KACS — they can read memory directly, modify any data structure, disable any check. The defence against this is to refuse to load unsigned modules.
CONFIG_MODULE_SIG_FORCE=y is the kernel-level switch that enforces it. With the flag set, unsigned modules are rejected at load time; a malicious or buggy administrator with SeLoadDriverPrivilege cannot load a malicious module unless they have the signing key, and it is not on a running system.
The module-signing key is deliberately a different key from the TCB key that signs binaries, and is not chained to it. Both are compiled into the image, so neither can rotate independently and neither can be an offline root — the TCB key signs binaries at build time. Chaining them would mean that compromising the TCB key also yielded the ability to load kernel modules. Keeping them independent contains binary signing and module loading separately. Both are ML-DSA-65.
The flag is verified at kernel build time. A kernel built without it doesn't enforce the rule and is not a valid Peios kernel for security purposes; the PIP threat model relies on this enforcement.
User-visible effect: kernel modules from the Peios distribution work (they are signed); modules built ad-hoc on a running system do not load. Third-party kernel modules (a vendor driver, say) must be signed by a key the kernel trusts, which in practice means going through the Peios signing infrastructure to add them.
1.2.3 The module-loading helper #
CONFIG_MODPROBE_PATH="/bin/modprobe" is required, and the binary it names must be TCB-signed.
When the kernel needs a module it does not have — mounting a filesystem whose driver is not built in, opening a socket for an unknown protocol family, resolving a crypto algorithm by name — it does not load it itself. It spawns a userspace helper to do it, and runs that helper at its own authority.
Two things follow. The path must be right: the upstream default is /sbin/modprobe, which does not exist on Peios, and left at that default every kernel-initiated load fails to find the helper while reporting nothing that names the cause.
And the path is a writable sysctl, which makes it an escalation vector — whoever can write /proc/sys/kernel/modprobe gets code of their choosing executed with the kernel behind it. The defence is not to protect the sysctl but to make redirecting it useless: KACS refuses any exec the kernel initiates on its own behalf unless the binary carries at least PeiosTcb trust. Pointing the sysctl elsewhere gains nothing without a TCB-signed binary to point it at.
This is the one place a signature gates execution rather than just labelling it. Everywhere else an unsigned binary runs and simply carries no integrity tier, because the process that asked for it bounds what it can do; a kernel-initiated exec has no such process behind it.
User-visible effect: a module the kernel asks for on your behalf loads only if the distribution's modprobe is in place and properly signed. A refusal is recorded as a kacs_exec event with reason umh-not-tcb, rather than appearing as an unexplained module-load failure.
1.2.4 Memory access restrictions #
CONFIG_STRICT_DEVMEM=y is required. This flag restricts /dev/mem and /dev/kmem access — physical-memory mapping pseudo-devices — to specific I/O regions, denying read or write access to actual system RAM.
The reasoning: a process that can read physical memory bypasses PIP. The DACL might say "this process cannot read the memory of authd"; the kernel's PIP check might agree; but if the process can mmap /dev/mem and read the corresponding physical pages, neither check matters. The defence is to restrict the device.
With CONFIG_STRICT_DEVMEM=y:
/dev/memis readable only for the kernel's defined I/O regions (memory-mapped I/O, the VGA framebuffer, etc.).- General RAM is not readable through
/dev/mem. /dev/kmemis unavailable.
This closes the physical-memory bypass for PIP. Combined with CONFIG_MODULE_SIG_FORCE, the two flags eliminate the kernel-level escapes from PIP that don't require an actual kernel compromise.
The flag is build-time; the kernel image either has it or doesn't. A kernel without it is not a valid Peios kernel for security purposes.
1.2.5 What the kernel will not have #
The Peios kernel is built with specific things excluded:
CONFIG_BPF_LSM=n— the BPF LSM is not built. BPF programs running as security modules would conflict with PKM and bypass its checks.- MAC LSMs disabled — SELinux, AppArmor, SMACK, TOMOYO are all disabled. Their config flags are off.
These exclusions are part of the kernel image. A distribution that includes these LSMs in its kernel image is not running Peios's intended security model.
1.2.6 What about user-space hardening #
A few things are explicit in the Peios environment that aren't strictly kernel invariants but are operational expectations:
peinitis signed at TCB level. If the binary peinit is exec'd from is not signed at TCB, the kernel's verification at exec setspip_type = None. peinit then runs unprotected, and any other process can interfere with it. This is operationally untenable; a properly-built Peios image has peinit signed.authd,loregd,eventd,lpsdare signed at TCB level. Same reasoning. These are the TCB daemons; they need PIP protection to function in their role.- No kernel modules are loaded other than signed ones. Per
CONFIG_MODULE_SIG_FORCE.
These are not enforced at boot in the sense of "the boot will fail" if violated — they are operational expectations. A Peios system where peinit is unsigned will boot, run, and "work" in a superficial sense; it just won't have the security properties Peios documents.
1.2.7 The invariant verification #
The kernel's init code does several checks at start:
- Verify PKM is in the LSM stack and no incompatible LSM is present. PKM refuses to activate if violated.
- Verify
CONFIG_MODULE_SIG_FORCEis set. (This is a build-time flag; at runtime the kernel just behaves consistently with the build.) - Verify
CONFIG_STRICT_DEVMEMis set. (Same; build-time.) - Construct the SYSTEM and Anonymous tokens. As covered in Bootstrap tokens.
- Attach the SYSTEM token to init. Init becomes the first process holding it.
- Start userspace. init execs the configured first program — prelude, the initramfs PID 1, which runs the initramfs and then execs the real init (peinit, in a normal Peios image). See The initramfs stage.
By the time userspace starts, the invariants are either satisfied (boot proceeds normally) or violated (boot fails or the security model is broken). The verification is internal; from the operator's perspective, the system either comes up cleanly or doesn't.
1.2.8 Failure modes #
If an invariant fails:
- MAC LSM detected at PKM init. PKM refuses to activate. The kernel boots but the access control is effectively absent. peinit's exec verification (looking for a TCB signature) returns "valid" but no PIP is set because PKM isn't running. The system is unsafe.
CONFIG_MODULE_SIG_FORCE=n. Modules can be loaded without signing. An attacker withSeLoadDriverPrivilegecan load arbitrary kernel modules and bypass everything. PIP is no longer meaningful.CONFIG_STRICT_DEVMEM=n./dev/memcan read physical memory. Any process withREAD_CONTROLon/dev/mem(or whose DACL grants it) can bypass PIP by reading memory directly.
Each failure mode is a security regression. The boot chain depends on the invariants holding. A deployment that needs to be sure its security model is operating correctly verifies these as part of the boot validation — typically via a post-boot tool that reads /proc/sys/kernel/lsm, checks build-time flags via the kernel config, etc.
For most Peios deployments built from the official kernel image, the invariants hold by construction. The verification is mostly relevant for custom kernels built from source — anyone modifying the kernel image needs to know which flags they cannot change.
1.2.9 Why these invariants #
Two themes underlie all of these:
The trust chain is rooted in kernel integrity. SYSTEM token, PIP, signed binaries — every layer of the model assumes the kernel is honest. If the kernel can be modified at runtime (unsigned modules), or its memory can be read or written from userspace (/dev/mem), the foundation crumbles.
PKM must be the sole authoritative LSM. Trying to compose PKM with MAC LSMs would give either over-restrictive or inconsistent semantics; the Peios access model is built on PKM's decisions, and that requires PKM to make the decisions alone.
The invariants are what make these themes operational: the kernel is built and initialised in ways that protect itself from runtime modification and that ensure PKM is in charge.
1.2.10 Where to go next #
For the tokens the verified kernel constructs at init, read Bootstrap tokens.
For the signing scheme module signing shares its keys with, read Binary signing.
For the protection model these invariants defend, read Process integrity protection.
2.1 Overview
Peios / Peios technical reference / 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), behaviour under failure (§2.7), and the emission policy that decides which of the kernel's own event types are written (§2.8).
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 / Peios technical reference / 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 / Peios technical reference / 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.
Neither entry point consults the emission policy. Whether a type is switched on is decided by the emitter before it builds the event, with the check described in §2.8; an event that reaches the emission API is written.
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 and bottom halves 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 window is a few hundred nanoseconds.
Bottom halves matter because rings are written from softirq context as
well as from tasks: the packet engine reports from the network receive
path, and RCU callbacks run in softirq. With only preemption disabled,
a softirq taken on interrupt exit could write the same CPU's ring
between a task writer's sequence increment and its write_pos
publication, interleaving two events in one reservation. Every ring
writer — single, kernel batch and the emit syscalls — therefore holds
bottom halves off across its reservation and write. A writer that runs
with interrupts already disabled needs no more: neither a softirq nor
a hard interrupt can reach its CPU.
Emission from hard interrupt or NMI context is refused. Bottom halves cannot be held off there, and such a writer could interrupt one holding the ring, so the event is dropped before any ring is touched: no sequence number is consumed, no ring drop is counted, and KMES tallies it in a private counter and logs the first such drop once. No kernel emitter runs in hard interrupt context.
The consumer wake that follows a write takes the futex hash bucket lock, which the futex core takes with bottom halves enabled; a softirq taking it while a task on the same CPU held it would spin forever. A writer outside task context therefore does not wake consumers itself: the write still advances the futex counter, and the wake is handed to a work item on the ring, which holds a reference to the ring until it has run in process context.
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, the drop counter is not incremented, and no gap is visible. Events emitted in this window are simply gone. KMES tallies them in a private counter and logs the total once when the rings come up.
- If the ring's recorded CPU identity does not match the executing CPU, the ring selection itself is untrustworthy, so every emission path — single, kernel batch and syscall — returns early without consuming a sequence number or counting a drop.
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 section with preemption and bottom halves disabled:
- 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 and bottom halves are re-enabled, and only then is the futex wake performed, outside the window — or, outside task context, handed to the ring's work item.
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 / Peios technical reference / 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 / Peios technical reference / 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 Consumer mapping permissions #
The producer metadata page and both aliases of the data region are read-only
to consumers. The consumer metadata page is read-write: ordinary stores and
authorised process_vm_writev writes can set need_wake. A consumer can remove
write permission from that page with mprotect and restore it later.
A write-enabling mprotect covering any producer or data byte fails with
EACCES, including requests spanning the consumer page and a protected region.
Write faults and writable page pins must not upgrade the protected pages.
These boundaries follow the original mapping offsets when mprotect splits a
VMA; a new VMA start does not move the consumer page. Process access gates for
remote writes still apply.
2.5.4 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.5 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.
For a batch, KMES first walks the reservations for every accepted event using local write and tail positions. It publishes the resulting tail with one release store before writing any event bytes, so a concurrent consumer can detect that the record it was reading has been overwritten. Publishing the tail only after the writes would allow the consumer's tail re-check to accept torn data.
After writing the events, KMES publishes the final write position with
one release store and checks need_wake once, provided at least one
event was written. New batch records become available at that final
write-position store; the tail still transitions once per batch rather
than once per overwritten event.
2.5.6 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.7 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 and is reported the same way, with -EIO as the
error. 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 / Peios technical reference / 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.
The capacity swap runs first, and a BufferCapacity change that
cannot be applied rolls back the capacity and nothing else:
MaxEventSize, MaxNestingDepth, and MaxEmitRatePerProcess still
commit in the same pass, against the capacity that is actually live,
so the stored configuration describes the running system rather than
the requested one. The situation that makes a swap fail — memory
pressure — is exactly the one in which an administrator raising
MaxEventSize and BufferCapacity together needs the MaxEventSize
change to survive. 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.config.value.rejected reports one missing or invalid value. Its
payload follows the event-field rules of PGSS §6: each dotted field
path is a nested msgpack map, one per segment, and a field that does
not apply is absent rather than nil. Every field lives under a single
top-level config map of five keys, in order:
config.key.path— alwaysMachine\System\KMES.config.name— the canonical value name.config.expected.type,config.expected.minandconfig.expected.max— the registry type code and range from the key's definition.config.received.kind— one ofmissing,wrong-typeorout-of-range. A malformed payload length reportswrong-type. AREG_DWORDand aREG_QWORDvalue out of range both reportout-of-range, sinceconfig.expected.typealready gives the width.config.received.type(the actual registry type code) is present exactly when the kind iswrong-type, andconfig.received.value(the number received) exactly when it isout-of-range. A missing value carries the kind alone.config.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.config.value.rejected
events and retains all four defaults.
kmes.buffer.swap.failed reports a valid BufferCapacity change that
could not be applied. Its payload is two maps: buffer, holding
buffer.capacity-requested (the capacity asked for) and
buffer.capacity (the capacity kept), and outcome, holding
outcome.errno as a negative signed integer — -ENOMEM when the
replacement rings could not be allocated, which is the usual case.
The swap is system-wide, so no CPU or ring is named.
Every failure is reported, not only allocation: a swap abandoned
because migration met a corrupt size field reports -EIO, and one the
kernel could not quiesce the CPUs for reports what stop_machine
returned.
kmes.config.applied records a read of Machine\System\KMES that
reached the commit, after the rejection reports and the commit
itself. Its payload is three maps: config, holding config.key.path
and config.counts with the four counts applied, retained-missing,
retained-invalid and ignored-unknown; buffer, holding
buffer.capacity; and emission, holding emission.rate-limit. The
capacity and rate are read back after the commit, so they are what is
in force rather than what the plan asked for.
A read whose capacity swap failed still reaches the commit — the other
three settings commit without it — so it is recorded as applied too,
with the capacity kept, beside the kmes.buffer.swap.failed that says
why. MaxEventSize and
MaxNestingDepth in force are not in the record: the catalogue has no
field for either yet.
kmes.config.refresh.failed records a re-read, after the watch saw a
change, that failed: the source did not answer, answered with
something malformed, or the plan was refused (more than four rejection
reports, or a configuration the second gate rejects). Its payload is
config, holding config.key.path, and outcome, holding
outcome.errno. The configuration in force stays as it was. The
record is essential: the registry and the running kernel disagree
until the next change, and it is the only sign. A failed capacity swap
is not a failed read and does not produce one.
A read that fails during the bootstrap refresh is not recorded this
way: it fails that refresh, which is traced and leaves the machine-root
fallback armed to try again (LCS §5.10.4). The emission policy reports
a failed walk of Machine\Generic\Events with the same record (§2.8).
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 / Peios technical reference / 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, or the migration meets a
corrupt ring, the existing rings stay live at their current size, the
capacity change is not applied, no generation changes, and consumers
are unaffected. A kmes.buffer.swap.failed event records the
requested and retained capacities as buffer.capacity-requested and
buffer.capacity, and the error as outcome.errno (§2.6). KMES does
not retry; the next configuration write or a reboot triggers another
attempt.
2.7.5 Configuration that cannot be read #
A re-read of Machine\System\KMES that fails keeps the configuration
in force, and a walk of the emission policy that fails keeps the mask
in force; each is recorded as kmes.config.refresh.failed (§2.6,
§2.8). Nothing retries on its own: the next change under the key reads
it again.
2.7.6 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.7 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.8 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.9 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.10 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.
2.8 Emission Policy
Peios / Peios technical reference / PKM / KMES
An event type that is switched off is never written. PGSS §6.9 defines
the policy: a tree of keys under Machine\Generic\Events, one per
event-type segment, any of which may hold an Enabled REG_DWORD of
0 or 1, where the deepest value on a type's path decides and the
type's tier decides when there is none. This section is how the kernel
applies that policy to the event types it writes itself. Userspace
components apply it to their own types through libpeios, peios-rs and
libp-go; KMES does not enforce the policy on what userspace emits.
2.8.1 The kernel's event types #
The kernel writes a closed set of event types, fixed when it is built.
They are generated from the kernel's own evman fragments
(pkm/evman/*.evman) by tools/gen-kmes-event-table.py into a C
header and a matching Rust module, each type with an id, its type
string and its tier. The generator has a --check mode, so a type
cannot change tier in its fragment without the kernel's table
following.
The policy is cached as a 64-bit mask with one bit per kernel type: bit i set means type i is switched on. A walk of the registry computes a whole new mask and publishes it with a single store, and an emitter reads it with a single load, so a decision is a load and a bit test and no lock is taken on any emission path. A mask is never published half-computed.
An emitter asks before it builds a payload, with
pkm_kmes_event_enabled(id), and Rust emitters through the generated
module's enabled(). An essential type's tier is a compile-time
constant, so the check for it folds to on when the emitter is
compiled and never reads the mask: no policy can switch an essential
type off, whatever the tree holds.
The policy and an event type's own gating — a SACL, an alarm mask, a
REPORT level — combine by AND, the policy first because it is the
cheaper of the two.
Every kernel emitter makes the check, and makes it first: before it resolves the subject, looks up a path, sizes the payload or allocates anything. A type that is switched off therefore costs one check and nothing else, and the emitter carries on as though the record had been written. Each emitter names its type with the generated table's string rather than one of its own, so the type written and the type the policy decided on cannot drift apart.
2.8.2 Before the policy can be read #
From the moment PKM initialises until a registry source for the
Machine hive has registered and the first walk has finished, the
mask holds the tier defaults: every essential and standard type on,
every verbose and debug type off. That is PGSS §6.9's rule for an
emitter that cannot read the policy, and the kernel writes events from
long before any source exists, so it is the policy the kernel runs on
for the whole of early boot. No event is ever held back to wait for
the registry.
A verbose type the registry switches on is therefore not written
before the walk, and a standard type the registry switches off is. A
boot-time event that must be written whatever the policy says has the
essential tier and nothing else.
If Machine\Generic\Events does not exist, no Enabled value can be
found anywhere and the tier decides for every type: the kernel
publishes the tier defaults. A bootstrap refresh that finds the key
gone after it existed does the same, so deleting the key undoes the
policy it held.
2.8.3 The walk #
One walk resolves every kernel type at once. It reads Enabled on
Machine\Generic\Events itself, then descends the trie of the kernel
types' segments — kacs, kacs\audit, kacs\audit\access and so on
— looking up only the keys some kernel type's path passes through.
Each key that exists costs one RSI_LOOKUP and one RSI_QUERY_VALUES.
A key that does not exist ends the walk down that path, and nothing
beneath it is asked for: an empty Events key costs one query and one
lookup per root. A key that is a link is not followed and ends the
walk the same way.
Only an Enabled value that is a REG_DWORD holding 0 or 1 is a
setting. A value of another type, of the wrong length, or holding any
other number is ignored as though it were absent, and the setting
found above it stands. Nothing reports the ignored value. Value names
compare ignoring case, as the registry does.
From what it read the walk computes the mask by PGSS §6.9's procedure:
for each type, the Enabled on the deepest existing key on its path
decides; with none, the tier decides; an essential type is on. The
mask is then published.
A walk that fails — the source does not answer in time, or answers
with something malformed — publishes nothing. The mask in force stays,
whether that is the tier defaults or the last mask a walk computed,
and a kmes.config.refresh.failed record names
Machine\Generic\Events and the error.
2.8.4 Noticing a change #
At bootstrap, after the KMES, layer, port reservation and network
policy keys, LCS discovers Machine\Generic\Events and walks it. If
it exists it gets an internal watch of its own; if not, the machine
root fallback watch stays armed (LCS §5.10.4), and creating the key —
Generic and Events two levels below Machine, with or without
Generic already there — re-runs the bootstrap, which then walks it
and watches it.
The watch covers the whole subtree at any depth, but it passes on only
what can change a kernel type: a change on Events itself, a change
beneath one of the kernel's five roots (kacs, kmes, lcs, ntfe,
stratafs), and among value changes only those to a value named
Enabled. A vendor writing under Events\org\…, which PGSS §6.9
lets an emitter ignore, costs the kernel nothing.
What the watch passes on is coalesced. The first change of a burst schedules one re-walk 50 ms later; changes that arrive before it runs join it rather than postponing it, and a change that arrives while a walk is running schedules the next one. A change to the policy applies within about 50 ms plus one walk of the write that made it — or, when a walk is already in progress, after that walk and one more — however busy the tree is, which is inside PGSS §6.9's one second.
A change is not retroactive. An event already written stays written, and a decision made before the walk publishes stays made.
2.8.5 The shipped seed #
The kernel package ships a registry seed, event-policy.reg in
/usr/share/regim/, that creates Machine\Generic\Events and sets no
Enabled anywhere: with nothing set, the tiers decide, which is the
policy a machine ships with. The key is there so the policy has a home
for an administrator to write beneath. Like every seed it is inert
until an image names it in [registry] autoapply, and until then
every emitter decides by tier just the same.
The seed gives the key the SACL PGSS §6.9 asks for, one that audits
writes: S:(AL;CI;0x10002;;;WD), a SYSTEM_ALARM ACE for Everyone,
inherited by every key beneath, over KEY_SET_VALUE | DELETE. A handle
opened for writing anywhere in the policy tree carries that
continuous-audit mask, so each change to what is recorded can itself be
recorded. Only the SACL is set; the owner and DACL stay inherited from
the Machine hive.
Appendix 2.A KMES ABI Reference
Peios / Peios technical reference / 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/.
The PKM UAPI exports the uppercase constants in <pkm/syscall.h>. Peios
glibc 2.44-5 and later exports the standard lowercase SYS_kmes_*
aliases in <sys/syscall.h>; each alias has the same number as its PKM
constant.
| Number | PKM constant | glibc alias | Signature |
|---|---|---|---|
| 1090 | SYS_KMES_EMIT | 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 | SYS_kmes_attach | kmes_attach(unsigned int cpu_id, u64 __user *capacity) |
| 1092 | SYS_KMES_EMIT_BATCH | 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 | Notes |
|---|---|---|
KMES_ORIGIN_USERSPACE | 0 | |
KMES_ORIGIN_KMES | 1 | |
KMES_ORIGIN_KACS | 2 | |
KMES_ORIGIN_LCS | 3 | |
KMES_ORIGIN_NTFE | 4 | net/ntfe: ntfe.verdict.reported, ntfe.policy.published, ntfe.policy.rejected |
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 |
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) |
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 |
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 / Peios technical reference / 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 in the table, but the
kernel is built without CONFIG_X86_X32_ABI, so the only entry point
that exists is x86-64: an x32 invocation (the number with bit 30 set)
fails ENOSYS like any other x32 syscall on this
kernel.
CONFIG_SECURITY_PKM_KUNIT compiles in the in-kernel test harness.
3.1 Overview
Peios / Peios technical reference / 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).
The kernel-private storage and call-site inventory is in §3.E: the LSM security blobs, every registered KACS hook, and the direct metadata entry points used by patched file-descriptor operations.
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. It is a ratchet carried by every token, primary or impersonation, and only ever goes down (§3.5.1).
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 / Peios technical reference / 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 / Peios technical reference / 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) #
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. On every token it is the ceiling on what may be derived from it: an impersonation token acts at this level, and a primary token bounds everything captured from, conveyed by, or duplicated out of the process carrying it (§3.5.1). |
3.2.2.3 Integrity (fixed) #
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) #
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) #
3.2.2.8 Mutation tracking (adjustable) #
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) #
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. |
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.
The audit records these categories produce describe the effective token
in their subject.token fields: the user SID as sid, the groups as
groups with their attribute words as the parallel group-attributes,
the integrity level as integrity, token_id as id, auth_id as
auth-id, the token type as type (primary or impersonation), the
impersonation level as impersonation (0 for a primary token), and the
projected Linux UID as uid (§3.C).
3.2.2.15 Credential projection (fixed) #
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 / Peios technical reference / 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 an explicit, 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 — no SACL, or no non-inherit-only mandatory label ACE — is unlabelled, and the token survives exec unchanged. This is deliberately not the access-check rule, under which an unlabelled object counts as Medium (§3.8.3): an image that labels nothing would otherwise demote every process on it, PID 1 first, to Medium — which is exactly what happened before PEI-530, leaving the TCB below every High-integrity user and unable to convey their identity.
- 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 — the impersonation level included — 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, and only to a level the image itself claims. 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.
The handle has to carry TOKEN_ASSIGN_PRIMARY, the token has to be a
primary token, and the caller's real token has to hold
SeAssignPrimaryTokenPrivilege enabled — an impersonating thread's
effective token does not satisfy the gate, and the privilege is marked
used on success. Unless that real token also
holds SeTcbPrivilege enabled, the token being installed has to carry
the same user SID and the same LogonSession as the caller's, or the
call fails with EPERM; this is what stops a non-TCB holder of
SeAssignPrimaryTokenPrivilege from installing an arbitrary
higher-privilege token on itself, and SeTcbPrivilege bypassing it is
how peinit installs service tokens with a different user SID and
LogonSession.
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.
Successful installations are ordered per process. Siblings converge to the latest successful installation in that order; older queued work must not restore a superseded token, including when retried after an allocation failure. Concurrent callers need not complete their syscalls in installation order. Once exec commits its primary token, delayed pre-exec installation work cannot replace it.
A new thread joins the same convergence rule even if it was not yet visible when an installation selected the siblings to update. This does not change a process fork's independent copy of the calling thread's primary token.
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; impersonation level Delegation, the top of
the ratchet; 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.
Its audit_policy is PRIVILEGE_USE_SUCCESS, so every privilege SYSTEM
spends — at a capability gate and in an access check — is recorded as
kacs.audit.privilege.used (§3.4), and tokens derived from it carry the
same policy. The Anonymous
token and the query-only SYSTEM token have an audit policy of 0.
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. A peer token captured
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 / Peios technical reference / 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 toowner_sid_index; - that the primary group SID is the user SID or a group on the token,
resolved to
primary_group_index; - that
auth_idreferences an existing LogonSession; - that
user_deny_onlyis true wheneverwrite_restrictedis; - that
confinement_sidis present wheneverisolation_boundaryis; - that the wire format's
elevation_typefield 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.
CreateToken accepts all four impersonation levels for a Primary token, including Identification and Anonymous, and preserves the chosen level and user identity. The level limits what may be derived from the token; it does not prevent the process from acting as its own primary identity. A peer capture takes the lower of the primary and socket levels: Identification conveys identity without authority to act as it, while Anonymous conveys the anonymous identity.
A minter chooses this ceiling deliberately (§3.5.1). authd uses Delegation for verified evidence and Impersonation for attested evidence; peinit's SYSTEM token uses Delegation. Each client may lower its level per connection. Older kernels refused Primary tokens below Impersonation as a migration guard; callers must not use an ignored or default Anonymous field when they intend peers to impersonate the identity.
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. The impersonation
level is chosen by the caller and is a ratchet: whatever the types
involved, the new level has to be equal to or lower than the source's,
and asking for more fails with EINVAL. A primary minted at
Delegation can be duplicated to an impersonation token at any level; a
primary at Impersonation cannot yield a Delegation-level token; an
Identification-level token cannot be duplicated up to Impersonation or
Delegation. The level only ever goes down — reaching a higher one
again means minting a fresh token with CreateToken.
Duplicating to Primary is how an impersonated identity becomes a process — the server's impersonation token, converted, is what a service manager installs on a child it launches as that client. The result keeps the identity and the level, so a primary made from a client captured at Impersonation is itself capped at Impersonation and so is every process and token descended from it. The kernel refuses a Primary result below Impersonation: a captured Identification or Anonymous identity must not become an acting process through duplication. This restriction applies even when the source is itself Primary. It is separate from privileged CreateToken, which may deliberately mint a primary with a low derivation ceiling.
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 / Peios technical reference / 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 / Peios technical reference / 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 / Peios technical reference / 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 kacs.session.destroyed
event through KMES. The last
token usually goes when a credential is freed, which happens in an RCU
callback where no record can be built, so the session is queued, still
referenced, and a kernel work item writes the event moments later and
then releases it. The session is gone at once — it no longer resolves —
but its event follows the teardown rather than coming from it, and its
header names the kernel worker that wrote it. The event is for whatever keeps per-session state
outside the kernel — an audit pipeline, an accounting tool, an authority
that caches credentials per session. authd keeps none and does not
subscribe.
The record describes the session under object.session: its LUID as
id (the subject.token.auth-id of every record made under it), the
user SID as user.sid, the logon type as logon-type, the
authentication package as auth-package, and the creation time as
logon-time. The logon type is written by name — interactive,
network, batch, service, network-cleartext, new-credentials
or remote-interactive — and the time in realtime nanoseconds, though
the kernel holds it to whole seconds.
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
kacs.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. Each
line reads
logon_session_id=<u64> user_sid=<hex> logon_type=<u32> auth_package=<hex> created_at=<u64>
with the user SID's binary form and the package name's bytes in
lowercase hexadecimal, and created_at in seconds since the Unix
epoch. securityfs itself is unclassified by FACS,
so its objects sit deny-missing until the mount is given a synthesising
policy (§3.9.5); only then can the file be opened at all. On Peios,
peinit gives it one at boot that lets Authenticated Users open and
traverse. Reading it is access-checked against a
synthetic descriptor granting read to SYSTEM and Administrators, 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 (§3.2.3). And the
CAP_SYS_BOOT mapping selects between SeShutdownPrivilege and
SeRemoteShutdownPrivilege by inspecting the session's logon type
(§3.10). 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. On
Peios authd does it, when asked over /run/logon.sock (PGSS Logon
§2.22):
- A caller asks authd to end the session, and authd decides whether it may: its own session as a person, anyone else's by an access check against local policy. SYSTEM's session (999), Anonymous's (998) and every session of logon type Service are never ended this way; a service is stopped through peinit.
- authd enumerates processes whose primary token carries the
target
auth_id, by walking/proc, opening each process withpidfd_open, opening its primary token withkacs_open_process_tokenon that pidfd, and readingTokenStatistics, which includesauth_id. No dedicated enumeration syscall exists or is needed. A thread's impersonation token is not consulted, so a service impersonating a member of the session is not one of its processes. - authd sends each
SIGTERMwithpidfd_send_signal, waits up to a grace period (5 seconds) for them to exit, and sendsSIGKILLto the survivors. - It walks again, since a process may have forked meanwhile, for at most three rounds.
- The processes terminate, dropping their token references.
- The last reference drops and the session object is cleaned up.
Each token handle authd opens in the walk is itself a reference to the
session, and is closed as soon as auth_id has been read.
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. The walk finds processes running under the session, not every process holding one of its tokens, so such a reference keeps the session alive after authd has ended every process in it, and authd reports nothing about it. A process that has exited but not been reaped likewise keeps its token, and the session, until it is reaped.
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 / Peios technical reference / 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.
Opening through /proc. /proc/<pid>/token and
/proc/<pid>/task/<tid>/token open a process's primary token and a
thread's effective token as a query-only handle: the fd always carries
TOKEN_QUERY and nothing else. Opening another process's file makes
both of the syscall's checks — PROCESS_QUERY_INFORMATION on the
process's descriptor, with PIP dominance, and TOKEN_QUERY on the
token's own descriptor — so the path reads nothing the syscall would
refuse. A process opens its own without either check.
The peer-token socket option (getsockopt(SOL_KACS, KACS_SO_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 | TOKEN_DUPLICATE. A token delivered
in a KACS_SCM_TOKEN control message carries the same three.
TOKEN_DUPLICATE is what lets a server turn a client's identity into a
primary token for a process it launches on the client's behalf
(§3.2.4); it is safe to hand out because the level ratchet means the
copy can never act above the level the client chose (§3.5.1).
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 #
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; - Administrators (
S-1-5-32-544)TOKEN_QUERY.
The Administrators grant is read-only: it lets an administrator see who any process runs as, its groups and privileges, and which logon session it belongs to, which is what a machine-wide process listing needs. Duplicating, impersonating, adjusting or rewriting the token still needs the token's own grants.
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 / Peios technical reference / 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 / Peios technical reference / PKM / KACS / The Process Security Block
3.3.2.1 Process identity (fixed at fork) #
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) #
Mitigations are inherited from the parent at fork and can be set by syscall. 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.
Do not infer an additional service policy from peinit launch: the source-checked peinit 0.0.12 path does not make a per-service PSB mitigation request. Inherited and self-applied flags are separate from that launcher behavior.
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 Reading the PSB #
/proc/<pid>/psb gives a process's PSB as one line of text, in this
order:
pip_type=<u32> pip_trust=<u32> mitigations=0x<hex> process_guid=<uuid>
mitigations is the committed mitigation bitfield in at least three
hexadecimal digits, including ui_access and no_child_process, with
the bit values of §3.A. process_guid is the 16 bytes in order,
formatted 8-4-4-4-12.
A process reads its own without any check. Reading another process's
needs PROCESS_QUERY_LIMITED on its process descriptor, with
SeDebugPrivilege rescuing a descriptor denial as it does for every
process right, and does not need PIP dominance. That a process is
protected is not a secret every other refusal keeps; it is what a
caller needs to see to understand those refusals.
The PSB is otherwise written only: kacs_set_psb sets mitigations and
returns nothing about them.
3.3.2.8 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 / Peios technical reference / 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 #
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.
psb is the process's PSB, read under PROCESS_QUERY_LIMITED with no
PIP dominance (§3.3.2). It is the one /proc entry whose check is
KACS's own rather than the metadata rule's.
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 / Peios technical reference / 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 / Peios technical reference / 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_BOOTmapping 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, of type kacs.audit.privilege.used, are emitted
only when the token's audit_policy opts in through
PRIVILEGE_USE_SUCCESS or PRIVILEGE_USE_FAILURE, and they come from
two places, told apart by operation.name.
3.4.1.5.1 In an access check #
An access check records the five AccessCheck-influencing privileges,
with operation.name access-check. 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 kacs.audit.privilege.used
events for any privilege.
3.4.1.5.2 At a capability gate #
A privilege that satisfies a Linux capability check is recorded too,
with operation.name linux-cap, the capability in linux.cap and the
privilege it mapped to in privilege.name, under the token's
PRIVILEGE_USE_SUCCESS policy. The volume-management gate the mount
paths consult in place of CAP_SYS_ADMIN records the same way with
operation.name volume-mount and no linux.cap. Such a record carries
no object, masks or contribution.
Each gate is recorded once per process and effective token: the first use of a capability by a process under a token is recorded, and later uses are not, until the process acts under a token with another GUID, when every gate counts as unused again. The set belongs to the process, not to the token, whose used flags derived tokens copy and every process sharing it shares.
Only successful uses are recorded, and only those of a user process's
own capability checks: a check made with CAP_OPT_NOAUDIT is a probe
and records nothing, nor does a kernel thread's check, a check from
interrupt context, or a check against another process's credentials. A
check that consumes no privilege — the capabilities every process holds
and the ones none may hold — records nothing either.
A capability check can run with spinlocks held, where a record cannot be built, so the gate notes the use and the record is written as the process returns to user space, or as it exits. The record therefore follows the system call that spent the privilege rather than appearing within it.
The boot SYSTEM token carries PRIVILEGE_USE_SUCCESS (§3.2.3), so on a
stock system these records come from SYSTEM and the services running on
its token: they answer who used SeTcbPrivilege, and for what.
3.4.2 Privilege Catalogue
Peios / Peios technical reference / 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 (§3.2.3). 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 #
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.
The PEI-549 block-layer change also accepts enabled SeManageVolumePrivilege
or SeTcbPrivilege, with successful privilege-use marking, for BLKRRPART
(partition-table rescan), BLKPG (partition management) and BLKFLSBUF
(buffer-cache flush). The caller still needs a usable block-device handle.
Administrators membership alone, or a present but disabled privilege, does
not pass this gate. Other CAP_SYS_ADMIN checks, including BLKROSET, retain
their existing TCB requirement. This change has native VM qualification;
older published kernels may still require SeTcbPrivilege for all three.
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, NewCredentials, or RemoteInteractive 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).
SeSystemtimePrivilegechanges the clock,SeIncreaseBasePriorityPrivilegeraises scheduling priority and sets CPU affinity for other processes,SeIncreaseQuotaPrivilegeoverrides resource limits,SeLockMemoryPrivilegelocks pages in physical memory,- and
SeAuditPrivilegewrites 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 #
No privilege. Binding a port is an access check against the port's
reservation — a security descriptor keyed by protocol and port range —
not a privilege test; see Port reservations.
Bit 63 was SeBindPrivilegedPortPrivilege until that object existed; the
bit is retired and not reused.
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 and is unused: no kernel
definition exists for it and nothing consults it. Submitting a
supervised job is not a privilege at all — it is governed by the file
Security Descriptor on the service manager's jobs socket, checked by
the kernel when a process connects (the Peinit TRM). 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 / Peios technical reference / 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, with EACCES — the same refusal the
kacs_access_check syscall reports for that token.
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.1.1 The level is a ratchet #
Every token carries an impersonation level, and on every token it
means the same thing: identity derived from this token may act at no
more than this level. An impersonation token acts at its level. A
primary token's level is the ceiling on everything the process
carrying it can convey — what a peer captures at connect, what
KACS_SO_PASS_TOKEN attaches, what KACS_IOC_DUPLICATE produces in
either direction.
The level only ever goes down:
- Socket capture and
KACS_SCM_TOKENattach store the lower of the socket's level and the source token's own; - DuplicateToken refuses a result above its source, whatever the types involved;
- fork, exec,
NEW_PROCESS_MIN, FilterToken and primary installation copy it unchanged.
The one way to a higher level is a fresh token from CreateToken, which
takes SeCreateTokenPrivilege. authd mints logon tokens at Delegation
and the bootstrap SYSTEM token is Delegation, so an identity starts at
the top and each hop can only lower it.
This is what makes the client's choice hold across any number of hops and process boundaries. A client that connects at Impersonation is captured at Impersonation; a server that converts that token to a primary and launches a process with it (§3.2.4) produces a process capped at Impersonation; and nothing that process captures, conveys or duplicates can ever be Delegation — so authd, which reads the level to decide whether to forward the client's credentials, is never shown a Delegation flag the client did not grant.
3.5.2 Impersonation Gates
Peios / Peios technical reference / 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 / Peios technical reference / PKM / KACS / Impersonation
3.5.3.1 The sequence #
The client connects. It optionally sets the maximum impersonation
level on the socket with setsockopt(SOL_KACS, KACS_SO_IMPERSONATION_LEVEL) — the default is Impersonation; the
option may be changed at any time and bounds every capture made from
then on — and calls connect(). The kernel's LSM hook fires on the
Unix stream connection.
Identity is captured, both ways. The hook examines the client
thread's effective credential together with the socket's maximum
level, derives the identity at the lower of that level and the
effective token's own (§3.5.1), and stores the result on the accepted
end. It also stores, on
the connecting end, the identity the listener captured when listen()
was called — so a client can read KACS_SO_PEER_TOKEN on its own
socket immediately after connect() and verify who it reached, at
Identification level unless the listener set its own level. The
listener's identity is what listen() saw; a process that inherits a
listener it did not create (from a descriptor store after a restart,
or from a broker) replaces it with its own using setsockopt(SOL_KACS, KACS_SO_RESTAMP), which is self-gated and fails with EINVAL on a
socket that is not listening. Clients that connect before the restamp
see the previous holder, which is honest: that is who was listening.
Squatting a name therefore buys nothing against a client that checks —
the squatter's own identity is what it conveys. 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 reads the peer token with getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN) on the accepted connection, which opens a token fd
carrying TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE for the
identity in that
end's conveyed-identity register — at this point, the identity captured
at connect (see Per-message identity below for how the register
moves).
It impersonates by installing that fd with KACS_IOC_IMPERSONATE:
the kernel 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. libpeios fuses the
two steps as peios_token_impersonate_peer for the handler that
impersonates, works and reverts on one thread.
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 installs another token with
KACS_IOC_IMPERSONATE 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 Per-message identity #
The connect-time capture is the first entry in a per-end
conveyed-identity register: the peer identity associated with the
data that end has consumed so far. Identity can also travel with the
data itself, as a KACS_SCM_TOKEN ancillary message under the
SOL_KACS level, and each such message the reader's position passes
advances the register. The anchor is the read position, never arrival:
a token still queued behind unread data is not yet visible, so a
handler can never be told an identity for a request it has not read.
KACS_SO_PEER_TOKEN returns a fresh fd to an immutable snapshot each
time; two calls may name different tokens, but a token never changes.
Sending. A sender conveys identity in one of two ways, and the receiver cannot tell them apart, because both mean the same thing — the kernel verified that the sender could be this identity when it sent these bytes:
- Automatically, with
KACS_SO_PASS_TOKENset on the sending end: every send carries the sender's effective token, derived at the lower of the end's impersonation level and the token's own. The derivation is cached against the effective token it was made from, so a run of sends under one identity costs one derivation. A thread that impersonates client A, writes to a pooled connection, reverts and writes again conveys A for the first write and its own identity for the second, with no change to the library doing the writing. - Explicitly, by attaching a token fd in a
KACS_SCM_TOKENcmsg. The kernel gates the attach exactly as it would gate impersonating that token: the fd must carryTOKEN_IMPERSONATE, a primary token is first derived to an impersonation token at the lower of the end's level and its own, and the two-gate model runs with the sender as installer. An attach the gates would cap below the token's own level fails withEPERM— an explicit act fails loudly where an install would silently downgrade. Attaching a token is therefore never more than impersonating it, sending, and reverting; it just avoids the install. A router forwarding on behalf of many clients re-attaches each client's token to the messages it forwards, and each downstream receiver gets a kernel-verified identity through the router.
Anonymous-level tokens attach without a gate, as assuming Anonymous is always a downgrade. At most one token per message.
Receiving. Every skb a send produces carries the conveyed token, so
a large stream write conveys the same identity on all of its
fragments. A stream read stops at an identity boundary rather than
gluing bytes conveyed under different identities into one return,
which keeps the register exact even on a byte stream; on
SOCK_SEQPACKET one send is one message and one identity, which is
why it is the recommended type for Peios-native protocols. As the
reader's position passes conveyed data, the register moves to that
identity. MSG_PEEK neither advances the register nor consumes; a
read() or splice() that consumes data advances it just as
recvmsg does, because the register is kept at the point of
consumption, not where ancillary data is copied out.
A KACS_SCM_TOKEN cmsg — carrying a fresh TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE fd — is delivered with a read when
the receive call
supplied room for it and the read's identity differs from the register
the reader had before the read; otherwise nothing is delivered, and
MSG_CTRUNC is set only when a token was due and no room was
supplied. A handler that never reads ancillary data still has the
register: recvmsg, then getsockopt(KACS_SO_PEER_TOKEN), gives the
identity of what it just read. Parsing the cmsg is the path for
pipelined or concurrent consumers that need the token bound to a
particular message.
SOCK_DGRAM sockets carry no register — many senders share one
receiving socket — so a datagram's token is delivered with every
datagram that carries one and is never retained.
3.5.3.8 Supported transports #
Peer-token capture works on two AF_UNIX 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(),
read with KACS_SO_PEER_TOKEN, impersonate, revert — with per-message
identity layered on top as described above. SOCK_DGRAM supports
per-message identity only: KACS_SO_PASS_TOKEN and KACS_SCM_TOKEN
work, since a send needs no connection, and
KACS_SO_IMPERSONATION_LEVEL bounds them as on any socket, but there
is no register.
Where the register does not apply, KACS_SO_PEER_TOKEN reports which
case it is. On a datagram socket, or any non-Unix family, the option
fails with EOPNOTSUPP. A socketpair() end is connected but starts
with an empty register — no connect happened to capture anything — so
the option fails with ENODATA until the peer conveys an identity,
after which the register works as on any connection. A socket that is
not yet connected fails with ENOTCONN. Pipes and FIFOs are not
sockets at all, so getsockopt itself fails with ENOTSOCK before
KACS is reached.
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 — the peer-token option, an
SCM_RIGHTS transfer, or any other path.
3.5.3.9 Audit records #
Every KACS_IOC_IMPERSONATE writes a kacs.impersonation.started
record through KMES (§3.C). Its subject is the server's token, the one
that acted, and its object is the client token: object.token.sid,
.type, .integrity, .auth-id and .restricted, with the level the
client token offered as object.token.impersonation and the level the
gate allowed as object.token.impersonation-permitted. The thread that
took the identity on is emitter.thread.tid, since impersonation is per
thread. Where the gate needed SeImpersonatePrivilege and used it, the
record carries privilege.name and privilege.held.
A level the gate lowered is not a failure: the record says
outcome.success with impersonation-permitted below
impersonation. A lowered level installs a new token, and the record's
object.token.guid and object.token.id are always the installed
token's — the client's own when the level was not lowered, the new one
when it was — so they match the header of the records the thread writes
next.
A refused impersonation is recorded too, with outcome.errno and, where
the kernel knows why, outcome.reason: primary-token for a primary
token (EINVAL), restriction-escape for a restricted server reaching
for an unrestricted token of its own user (EPERM),
no-impersonate-access for a handle without TOKEN_IMPERSONATE
(EACCES), and projection-failed when the identity could not be
projected onto the thread's credentials. A refusal before the gate
answered carries no impersonation-permitted.
Ending an impersonation writes kacs.impersonation.reverted, a
verbose type, so only where the emission policy (§2.8) switches it on.
The record names the
token given up in object.token.guid and object.token.sid, with the
thread's token after the revert as subject. operation.name says why:
revert for kacs_revert, exec for the revert every exec performs,
and replaced when a second impersonation ends the first. A revert with
no impersonation in force writes nothing, nor does the revert and
re-apply around a primary-token install, which leaves the impersonation
in place, nor does a thread that exits while impersonating.
Both records are best effort: a record that cannot be built does not
change the outcome of the impersonation or the revert.
3.6 Binary Signature Verification
Peios / Peios technical reference / 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.
/sys/kernel/security/kacs/signing_keys lists the table: one line for
each entry before the terminator, in table order, reading
key_sha256=<hex> pip_type=<u32> pip_trust=<u32>
where the key is named by the SHA-256 of its raw 1952-byte public key, in lowercase hexadecimal, and the type and trust are the entry's own, in decimal. Entries are listed as they are, whether or not the validator would accept them, so a table that disables signing shows why. The keys are public, so reading the file is not access-checked; whoever can open it can read it. Like every securityfs object, it can be opened only once the mount has a synthesising policy, which on Peios lets Authenticated Users in.
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.signature.crypto.failed KMES event
carrying signature.crypto-stage boot-probe and the error as
outcome.errno, negative. 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 Firmware verification #
A firmware load is not an exec, so none of the above would see it —
and firmware is code that runs on a device with DMA into host memory,
which makes a tampered blob a complete compromise. KACS therefore
implements the kernel_read_file and kernel_post_read_file LSM
hooks for READING_FIRMWARE (kacs/firmware.c).
The signature is the ordinary PIP blob, found by the lookup above.
Firmware is never ELF, so in practice it is the security.peios.sig
xattr, which peipkg stamps at install from the <file>.peios.sig
sidecar the package carries (PSPU §5.16). The bar is the PeiosTcb
tier — the same floor a usermodehelper exec must clear, since both are
the kernel choosing to run code it did not ship. A verified signature
at any other tier counts as no signature.
For a whole-file read, which is the common case, the post-read hook is handed the exact bytes the loader will decompress and give to the device, and those are what is hashed — no second read, no TOCTOU. A partial read (a driver pulling a window out of a large blob) only passes through the pre-read hook, where nothing has been read yet, so that path verifies the file through the reader probe instead. The hash covers the compressed bytes as they sit on disk, so verification precedes decompression.
Policy is CONFIG_SECURITY_PKM_FIRMWARE_SIG_ENFORCE, overridable for
one boot with kacs_fwsig=enforce or kacs_fwsig=log on the kernel
command line; there is no runtime switch. Under enforce an unsigned,
tampered or under-tier file is refused with -EPERM; the loader logs
that against the path it was refused at, moves on to its remaining
search paths, and reports the driver an ordinary load failure. Under log the
verdict goes to a rate-limited warning and the kacs:kacs_firmware_load
tracepoint and the load proceeds. 2026.8 ships in log mode: the
firmware set is being signed for the first time, and a blob the
packaging missed has to show up as a warning rather than as a device
that silently stops probing. Enforcement becomes the default once a
release has run clean.
CONFIG_FW_UPLOAD — a device's own sysfs update interface — is not
covered; it is selected by several drivers and cannot be turned off,
and the drivers that expose it are the place to gate it if that ever
matters. The test.fwsig build stage exercises both modes through
CONFIG_TEST_FIRMWARE's trigger_request, which pulls a named file
through the real loader path with no hardware.
3.6.8 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.9 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 / Peios technical reference / 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. The one exception is
/proc/<pid>/psb, which needs PROCESS_QUERY_LIMITED and no
dominance: a protected process's PIP type, trust and mitigations are
readable to whoever its descriptor lets query it (§3.3.2). 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()needsPROCESS_SET_INFORMATION;getpgid()andgetsid()needPROCESS_QUERY_LIMITED;- the scheduler, affinity and I/O priority queries need
PROCESS_QUERY_INFORMATION; - and the memory-placement mutations Linux routes through
task_movememoryneedPROCESS_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 Trust raising under a tracer or supervisor #
Dominance is tested when a tracer attaches and when a seccomp filter is
installed, and not again. That leaves one way to end up controlling a
process one does not dominate: be attached to it, or supervising its
syscalls through seccomp user notification, when it execs a binary
whose signature would raise its label. Linux marks exactly these execs
— LSM_UNSAFE_PTRACE when the task is traced, LSM_UNSAFE_NO_NEW_PRIVS
under no_new_privs, which every unprivileged seccomp filter requires
— and uses them to withhold setuid gains. KACS applies the same rule to
the label: the exec goes ahead, but the staged label is capped at
the process's current one whenever the new label would not be
dominated by it. The binary runs with the protection it can be given,
as a setuid binary runs without its setuid under strace.
There is no privilege exception. SeDebugPrivilege never bypasses
dominance, and a TCB tracer already dominates every label, so nothing
is lost by capping. An exec that does not raise the label — the common
case, and every unsigned binary — is untouched. A cap emits
kacs_exec with reason pip-capped-unsafe carrying the label that
was kept.
3.7.6 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.7 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.8 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.9 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 / Peios technical reference / 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 the staged scalar result differs from the effective one, when any node's staged granted mask differs from that node's effective granted mask, or when staged auditing differs from effective auditing — the scalar comparison is not mode-branched. 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 / Peios technical reference / 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 / Peios technical reference / 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 #
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 / Peios technical reference / 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 / Peios technical reference / 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.
- 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 / Peios technical reference / 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 / Peios technical reference / 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 / Peios technical reference / 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.
Every call that passes the privilege gate writes a
kacs.caap.policy.changed record (§3.C) naming the caller, the policy
SID as caap.policy.sid, and operation.name set or remove, with
outcome.success and, when the change did not apply, outcome.errno. 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 — in the effective and the staged evaluation
alike, and in the scalar total exactly as on every node of a
result-list check. 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 / Peios technical reference / 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: kacs.audit.access.checked for object-access events from the SACL
walk and from token audit-policy forcing, and kacs.audit.privilege.used for
privilege-use events. A third family is
emitted for the CAAP conditions of §3.8.8: kacs.caap.sacl.skipped for
a rule SACL that would not parse or evaluate, and
kacs.caap.staging.diverged for 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.
3.8.9.1.1 On the AccessCheck syscall #
Through kacs_access_check and kacs_access_check_list the descriptor
is the caller's own, SACL included. So that no process can write
kernel-origin audit records at will by supplying a SACL that matches
itself, the records a SACL generates there are written only when the
calling process's effective token holds SeAuditPrivilege, enabled.
Without it the check runs and answers exactly as it would otherwise,
and its SACL records are withheld; the privilege is marked used when it
admits them. The
privilege is the caller's, not the subject's: a daemon checking a
client through token_fd needs it on its own token. This is Windows'
rule for AccessCheckAndAuditAlarm.
The records the checked token's own audit policy forces, and privilege-use and CAAP records, are not gated: the policy belongs to the token, not to the caller.
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
kacs.audit.handle.used 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, as access.matched, beside
the whole stored mask as access.audit-mask.
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 identity from the audit context the caller supplied, exactly as a SACL-triggered record does. 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 —
subject.token.sid, .groups with their parallel .group-attributes,
.integrity, the token's own LUID as .id, its logon session as
.auth-id, .type, .impersonation and the projected .uid — and
the PIP state as subject.pip.type and subject.pip.trust; the
object, as object.kind and the fields beneath it; the access,
as access.requested and access.granted, with outcome.success;
the trigger, trigger.kind (sacl or policy) and, for sacl,
the matched ACE as trigger.ace, or for a privilege record the
privilege.* fields; and the process, as
emitter.process.pid, .name and .executable.
An access record also says what the mandatory checks withheld, since
that is often the whole reason a check the DACL would allow was denied:
access.denied-integrity is the requested bits the object's mandatory
label denied, and access.denied-trust the requested bits its
process-trust label denied — every bit each denied, for a
MAXIMUM_ALLOWED request — and each is absent when it denied none.
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.9.5.1 The object #
Where the kernel makes the check itself it knows the kind of object
and writes object.kind: file, process, token, socket (which
covers port-reservation binds) or ipc. A token check also writes the
target's object.token.id and object.token.guid. Some identity is
not yet reachable where the record is built, and is absent rather than
invented: object.file.path on file checks, object.process.pid and
object.process.guid on process checks. A mount-namespace check writes
object.kind mount-namespace and nothing beneath it: a namespace has
no name or identifier to carry.
A check made through kacs_access_check names its object through the
audit context (audit_context_ptr, audit_context_len), the
PGSS §6.7 map:
{kind: "service", service: {name: "jellyfin"}}
The kernel validates it before running the check and fails the call
with EINVAL unless it is well-formed msgpack no deeper than KMES's
default nesting depth less one; its root is a map of one or two
entries with string keys; it has exactly one kind, a string matching
the PGSS §6.3 segment grammar [a-z][a-z0-9]*(-[a-z0-9]+)*; and its
only other entry is keyed by that kind and holds a non-empty map whose
keys match the same grammar. The kernel copies it into the record as
object.kind and object.<kind>.* — object.service.name above. A
call without an audit context writes no object: the kernel does not
know what was checked.
These values are the caller's claim, which the kernel copied but did
not observe. So is everything else on the syscall path: the descriptor,
and with it trigger.ace, is the caller's, as is any PIP type or trust
it passes. Every record of kacs_access_check and
kacs_access_check_list therefore carries fields.attestation.userspace
set to true, with or without an audit context, and a record of a check
the kernel makes itself never does.
A context that is anything other than this map, such as the free-form
strings earlier components passed, fails with EINVAL.
3.8.10 The Algorithm
Peios / Peios technical reference / 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_restrictedtoken withoutuser_deny_onlyis rejected as invalid. - In the orchestrator, a null descriptor is rejected and
AccessCheckResultListis 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 event is kacs.audit.privilege.used. Its privilege.contributed
is provenance & mapped_desired, its privilege.surviving is
success_bits (empty on a failure event), and its access.requested
and access.granted are the whole check's mapped_desired and final
granted, not this privilege's share of them.
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 #
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 / Peios technical reference / 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: ramfs, 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_PATHdescriptors, 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/dup3andforkproduce the same open file description and therefore the same rights;- descriptors without
FD_CLOEXECsurvive exec unchanged; SCM_RIGHTStransfers the descriptor as a capability token, with possession as authorization and no re-check of the receiver's identity; andpidfd_getfd()is gated by an AccessCheck forPROCESS_DUP_HANDLEagainst 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 / Peios technical reference / 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 #
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 #
The descriptor format and size bound are defined in SD Structure.
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 / Peios technical reference / 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 / Peios technical reference / 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 #
A fallocate mode outside the supported set fails closed, and
PUNCH_HOLE additionally requires KEEP_SIZE.
3.9.4.2 Metadata operations #
Descriptor-based file_getattr and file_setattr with AT_EMPTY_PATH
retain the open file through the operation and metadata cleanup. A concurrent
close cannot invalidate an operation that has already acquired its file
reference. This also covers unsupported-operation and other error paths.
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 Watch placement #
Placing a watch — inotify_add_watch(), fanotify_mark() on an
inode, F_NOTIFY — is a read of the object by other means: a watch on
a directory reveals the names of its children as they come and go, a
watch on a file reports its activity. Linux gates it on read
permission, but under KACS a bare MAY_READ never reaches a
descriptor (read rights are decided at open), so the path_notify
hook checks the read-class right live against the object's own
descriptor: FILE_LIST_DIRECTORY for a directory, FILE_READ_DATA
for anything else. A denial is EACCES, and SeChangeNotifyPrivilege
does not bypass it — that privilege covers traversal, not reading.
Mount, filesystem and mount-namespace marks are not object watches and
have no single descriptor to check. Linux requires CAP_SYS_ADMIN for
them, and for the fanotify permission classes (FAN_CLASS_CONTENT,
FAN_CLASS_PRE_CONTENT — the ones whose holder can veto or delay
opens and reads system-wide); the capability switchboard (§3.10.2)
resolves that to SeTcbPrivilege, so these are TCB-only. PIP
dominance is not part of the decision: a permission-event holder
intercepts opens of the object, never the opening process, and a
holder that is by construction TCB dominates every process anyway.
3.9.4.5 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.6 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.7 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.8 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 / Peios technical reference / 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. An NTFS inode with no $Secure entry,
whose descriptor is an inline attribute the driver never reads, counts
as carrying no descriptor: the mount's missing-descriptor policy
decides for it, as it does for a file with no security.peios.sd.
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.
Both native and legacy open finish descriptor-cache resolution before acquiring the central access policy (CAAP) cache mutex. Resolution can take an inode security mutex, while set-security takes that inode mutex before CAAP; acquiring them in the opposite order could deadlock an open against a descriptor update. Once CAAP is locked, open pins the current immutable descriptor through RCU and evaluates it without taking the inode mutex. This keeps the descriptor alive and CAAP stable for the check without holding CAAP across lazy descriptor population.
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.
Invalidation is also driven from the other end, by the write itself: any canonical descriptor xattr landing on an inode drops that inode's cached entry, whoever wrote it and by whatever path. The set-security path above then publishes the new parsed object as usual; for anything else, the next reader re-reads the authoritative bytes.
This is keyed on the xattr rather than on the writer, because the writer is not always the one holding the cache. A descriptor set through a stacking filesystem reaches two inodes — the one the caller named, and the real one beneath, which the write is re-entered on. Only the first is refreshed by the set-security path. The second is not merely cold: a stacking filesystem resolves and caches descriptors during its own metadata work on an inode it has just created, so it can hold an entry from the moment the object existed and keep serving it after the xattr underneath has changed. Keying on the xattr closes that without either side needing to know the other is there.
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 mutable root filesystem is a tmpfs whatever the command line says.
Upstream Linux makes it a ramfs
when the command line carries root=, or a rootfstype= that does not
name tmpfs, and ramfs stores no extended attributes: the seed below
would have nowhere to go, nothing the boot archive unpacks would
inherit a descriptor, and StrataFS, which reads a provider's stored
descriptor and never a synthesised one, would refuse every view an
initramfs mounts over it. An installed system always boots with
root=, so the upstream rule is not followed.
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.
A rootfs seed that fails panics the kernel. A boot that went on without it would fail later, in whatever first reached the initramfs through a StrataFS view, with nothing to connect the refusal to its cause.
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 #
The privilege gate below is verified in kernel source
8e0e22de3a59cad506bbbf8873de456e16ad272d,
including its use by both set
and get. This is source-level
verification, not a deployed-kernel test or a historical release boundary.
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.
Setting a policy is volume management: the call requires
SeManageVolumePrivilege or SeTcbPrivilege, enabled, and must
successfully mark the enabled privilege as used; kacs_get_mount_policy
is gated the same way. Every change writes a
kacs.mount.policy.changed record (§3.C), whose subject is the caller
and which names the filesystem's type, the policy set
(object.mount.policy), the one it replaced
(object.mount.policy-previous) and the new generation
(object.mount.policy-generation). Every refusal is decided before the
change, so a record is always of a change made; the template is not
carried. The
public ABI accepts only the three managed classes; unmanaged,
unknown values, nonzero reserved flags and malformed arguments all
fail closed. The one
policy the kernel sets on its own initiative is for a tmpfs that a token
without the privilege brought into being inside its own mount namespace,
which is stamped synth-ephemeral with a creator-owned template at
sb_kern_mount (§3.13).
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.
Resolution does not nest FACS inode locks. It releases the current inode's lock before resolving an ancestor or calling the filesystem's descriptor xattr handler, which may itself resolve a lower inode in a stacked filesystem. Referenced dentries and immutable descriptor-cache entries keep the inputs alive across that work.
Before publishing the result, resolution reacquires the inode lock and checks both the mount-policy generation and the inode's cache mutation sequence. An explicit replacement or invalidation makes it discard the obsolete result and retry, including when an empty cache was populated and cleared or invalidated while still empty. Ordinary cached files also retry a competing reader publication, then use the current entry. A failed reread never makes an entry from an old policy generation current again.
StrataFS re-reads the current provider descriptor on every check, including when an earlier result was missing or corrupt. Concurrent reader publication does not restart these reads: without an explicit invalidation or policy change, N checks perform N descriptor reads (each may query size and then bytes). A reader may reuse a result published by another reader whose read began later than its own; an older read cannot overwrite that later result, including a missing or corrupt result. Its own read error is still returned. Explicit writes and policy changes continue to trigger revalidation and may require another read. This does not alter the fixed deny-missing policy or a handle's frozen grant.
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 #
Writing the xattr takes the inode's i_rwsem. Synthesis may be reached
from a metadata operation whose VFS caller already holds that lock, so
inline write-back would self-deadlock even though resolution releases its
FACS inode lock around parent resolution and xattr reads. 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 cached entry becomes an ordinary xattr-backed descriptor: durable, no longer generation-tagged, never synthesised again, and owed no further write-back.
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 / Peios technical reference / 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.
The blob is validated structurally — parseable, well-formed ACEs, valid SIDs, at most 65535 bytes. With an existing valid file security descriptor, only the indicated components are merged into it. 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.
A read-only mount withholds the write along that path whatever the
descriptor grants: the call fails with EROFS, as every modifying
operation through a read-only bind does, and the descriptor stays
writable through any writable path to the same object. The mount is
consulted after the access check, so a caller the descriptor refuses
sees the denial rather than a read-only error that would confirm the
object exists.
3.9.6.1 Component merging and replacement #
For a file whose cached descriptor is valid, the C dispatcher selects the merge path. The control-bit merge starts from the destination control value and replaces these bits from the input when their component is selected:
| Selected component | Replaced control bits |
|---|---|
| Owner | OWNER_DEFAULTED |
| Group | GROUP_DEFAULTED |
| DACL | DACL_DEFAULTED, DACL_TRUSTED, DACL_AUTO_INHERIT_REQ, DACL_AUTO_INHERITED, DACL_PROTECTED |
| Full SACL | SACL_DEFAULTED, SACL_AUTO_INHERIT_REQ, SACL_AUTO_INHERITED, SACL_PROTECTED |
This copies more than ACE lists, but is not byte-identical descriptor replacement. If group is not selected, the destination group remains. The resource-manager control byte remains the destination's, as does its RM_CONTROL_VALID state. A label-only merge does not replace the full-SACL control-bit set in the table.
A missing or invalid cached file security descriptor does not take that merge path. The dispatcher requires enabled SeRestorePrivilege and calls the replacement builder, which requires owner, group and DACL selections and validates the supplied owner and label. Do not apply valid-descriptor field-retention guarantees to that repair case.
These details are source-checked against kernel commit 8e0e22de, not a runtime-validated restore procedure.
3.9.6.2 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.3 Integrity labels #
Without enabled SeRelabelPrivilege a caller may set a label only at or below
its own integrity level; with it, the label-level check permits any level.
Other access and validation checks still apply.
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.
SeSecurityPrivilege or SeRestorePrivilege alone is not sufficient for
a label above the caller's integrity: the label-assignment check independently requires enabled
SeRelabelPrivilege.
3.9.6.4 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.5 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.6 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 / Peios technical reference / 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.
| 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 —
stratafs.file.copied-up and stratafs.mutation.refused (§4.6.5) —
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.9.8 Overlayfs Creates
Peios / Peios technical reference / PKM / KACS / FACS
Overlayfs is a different problem from the StrataFS context of §3.9.7. Nothing here needs an authorization exemption: the caller has already been authorized against the merged inode. What has to be corrected is the descriptor the new object ends up wearing.
Both of the inputs the inheritance algorithm needs (§PCDS 5.6) are wrong by the time the real create runs:
- The parent is wrong. Overlayfs creates on the upper filesystem, in the upper directory or — for a copy-up, or a create over a whiteout — in the overlay's workdir. Neither is the directory the caller named. A backing directory is also a distinct inode with its own descriptor cache, so a descriptor set at runtime through the overlay is not the one a create sees.
- The subject is wrong. Overlayfs performs upper-layer work under the mounter's credentials. The subject the inheritance algorithm sees is therefore the principal that mounted the filesystem, not the one making the object.
Left alone, the second is the more damaging: on a live image the root
is an overlay mounted by the system, so every object created anywhere
on it came out owned by SYSTEM, and every inherited CREATOR OWNER ACE
resolved to SYSTEM rather than to the principal that created the object.
KACS resolves the descriptor before the create, while both inputs are still right, and hands the answer down.
3.9.8.1 The descriptor rides on a credential #
The kernel offers two hooks for this, and both work the same way: they hand the LSM a credential to fill in, and overlayfs then performs the creation under it.
Either way the result is attached to that credential, and the inode creation path prefers it over computing inheritance itself.
The credential is what makes the lifetime sound, and it is the reason this needs no context of its own:
- Overlayfs installs the credential with
override_creds()and reverts it through a scope guard, on every exit path including errors. - Each scope wraps exactly one creation and nothing else. A create over a whiteout makes one temporary object and then renames it over the whiteout, rather than creating a second.
- Releasing the credential releases the descriptor.
So there is nothing to arm and nothing to disarm. Outside that scope the credential is simply not current.
A credential derived from one carrying a pending descriptor does not inherit it. Without that rule the descriptor would escape its create and be stamped on the next unrelated object the task made.
3.9.8.2 A copied-up object keeps its own descriptor #
A copy-up is not a new object, and inheritance is the wrong answer for it even from the right parent: the object already had a descriptor. KACS carries the source's across, whether that descriptor was stored on the lower inode or synthesized for it under a mount policy (§3.9.5).
Without this the defect runs in both directions. An object with a deliberately narrow descriptor is widened by the act of writing to it, and a deliberately permissive one is narrowed — in both cases changing what other principals may do to it as a side effect of somebody else's write. The principal doing the writing already held write access; nothing about that should re-decide the object's policy for everyone else.
3.9.8.3 An ordinary create inherits from the overlay #
A genuinely new object has nothing to preserve, so it inherits — but
from the merged directory the caller named and as the caller, which is
what it would have got had overlayfs not been in the way. The parent's
descriptor is read through the overlay inode, so a descriptor set at
runtime governs the children created after it, and CREATOR OWNER
resolves to the principal making the object.
A creator descriptor supplied explicitly to a native create (§3.9.2) is picked up here for the same reason. The request records the parent the caller resolved — an overlay inode — so a match attempted at inode creation time, where only the backing inode is visible, could never fire, and the descriptor the caller asked for would be silently replaced by inheritance.
A caller with no token is left alone rather than denied here, so the inode creation path reaches its own denial. Two places must not decide the same thing differently.
3.9.8.4 The descriptor xattr is not copied #
On copy-up the canonical descriptor xattr is discarded during the xattr copy rather than replicated, because the credential above already carries it; copying it as well would overwrite the descriptor with the same answer. It could not be copied through that path in any case — the canonical xattr is not readable or writable as an ordinary xattr, since descriptor mutation is the dedicated interface's job (§3.9.6).
3.9.8.5 Failure #
Failing to resolve a descriptor fails the create. That holds for both hooks: a source whose descriptor cannot be read fails the copy-up, and a parent whose descriptor cannot be resolved — or which does not grant the caller the right to add the object — fails the create.
The alternative in each case is to fall back to inheritance from a backing directory as the mounter, which is the outcome this exists to prevent, and which leaves nothing behind to find later.
An object on an unmanaged mount is the one case that passes through untouched: there is nothing there to preserve or to inherit.
3.9.9 Mount-policy syscall notes
Peios / Peios technical reference / PKM / KACS / FACS
These syscall notes preserve the argument and error descriptions from the operator guide. For the kernel contract, use File Descriptor Storage.
3.9.9.1 kacs_set_mount_policy #
The write syscall:
result = kacs_set_mount_policy(fd, args)
Where fd is a file descriptor referring to any object on the target superblock (the kernel uses the fd to identify which superblock — the file itself does not need to be the root of the mount). args is a kacs_mount_policy_args struct:
| Field | Meaning |
|---|---|
policy | The new policy class (one of KACS_MOUNT_POLICY_DENY_MISSING, KACS_MOUNT_POLICY_SYNTHESIZE_EPHEMERAL, KACS_MOUNT_POLICY_SYNTHESIZE_PERSISTENT). |
flags | Reserved; must be zero. |
generation | (Output) The new generation counter value after the change. |
template_sd_ptr, template_sd_len | Optional mount-level SD template. Max 64 KiB. |
The kernel:
- Validates the fd and identifies the superblock.
- Validates the policy value. Attempts to set
KACS_MOUNT_POLICY_UNMANAGED(which is not settable via this ABI) are rejected with-EINVAL. - Validates the template SD if provided — must parse, must be within size limits.
- Checks the caller's privileges. Enabled
SeManageVolumePrivilegeorSeTcbPrivilegeis required, and marking the privilege as used must succeed. - Atomically updates the superblock's policy class, the template SD, and increments the generation counter.
- Writes the new generation to
args.generation. - Returns 0.
The change is atomic at the superblock level — every future access against any inode on this superblock sees the new policy. There is no transitional state where some inodes are under the old policy and others under the new.
3.9.9.1.1 Privilege requirement #
Changing a mount's policy affects the entire filesystem's access semantics.
SeManageVolumePrivilege can author synthesised policy and shadow existing
paths; see its authority warning.
The SDK wrappers at libpeios
de0018bdcaed14abb796aeccec9c8387fd30e452 add no TCB-only gate despite their
comments. The command diagnostic
also retains a TCB-only hint. Do not automatically grant TCB or globally remap
CAP_SYS_ADMIN. A ready-to-use operator interface for changing an existing
policy is not established here.
The generated KACS ABI trace table
retains the KACS_MP_TCB_DENIED name and TCB-only descriptions. Those labels
do not override the source-verified gate; this clarification leaves the
generated table unchanged.
3.9.9.1.2 The template SD #
The optional template SD is the default SD used during synthesis when the parent's inheritance does not yield one. The kernel stores the template on the superblock; subsequent synthesis-on-missing calls use it.
The template is a complete self-relative SD — owner, primary group, DACL, optional SACL. The kernel validates the template at policy-set time:
- Must be in self-relative format.
- Must include an owner (an SD without one is malformed and rejected).
- Must fit within 65,535 bytes total; the template specifically is capped at 64 KiB.
- ACLs and ACEs must parse.
A bad template at policy-set time is rejected with -EINVAL; the current policy and template are unchanged.
The template can be omitted entirely (zero pointers) for mounts that don't need one. The synthesis chain then falls through to the hardcoded fallback for any file whose parent doesn't yield an SD.
3.9.9.2 kacs_get_mount_policy #
The read syscall:
result = kacs_get_mount_policy(fd, args)
Same fd and args semantics, returning the current policy class, current template (if buffer provided), and current generation counter.
The kernel:
- Validates the fd, identifies the superblock.
- Checks the caller's privileges. Enabled
SeManageVolumePrivilegeorSeTcbPrivilegeis required, with successful privilege-use marking. - Writes the policy class, generation, and template to
args(template only if a buffer was provided). - Returns 0.
Like the write syscall, this uses the pinned-source volume-management gate. A successful policy query does not grant authority to bypass any other mount or file-access checks.
This is the kernel's read-back-the-current-policy interface. A management tool reads the policy, perhaps applies a transformation, and writes the new value back. The read-modify-write pattern is what most policy management code uses.
3.9.9.2.1 Reading the generation #
Compare the value from kacs_get_mount_policy with your earlier read to detect
a change. Initial values are implementation-defined, and values from different
superblocks are unrelated. The counter identifies changes, not a wall-clock
change time. See the kernel administration
contract
for generation increments and cache invalidation.
3.9.9.3 Errors #
Possible failures from kacs_set_mount_policy:
| Error | Cause |
|---|---|
-EBADF | The fd is invalid. |
-EPERM | Neither accepted privilege is enabled, or privilege-use marking failed. |
-EINVAL | Setting an unmanaged policy via the public ABI; setting an unknown policy value; non-zero reserved flags; malformed template SD; template size exceeded. |
Possible failures from kacs_get_mount_policy:
| Error | Cause |
|---|---|
-EBADF | The fd is invalid. |
-EPERM | Neither accepted privilege is enabled, or privilege-use marking failed. |
-ERANGE | A template buffer was provided but is smaller than the template. The required size is written to the output. |
In normal operation both calls succeed. Failures are typically privilege issues or malformed inputs.
3.10.1 Credential Projection
Peios / Peios technical reference / 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. Only getuid() and getgid() are redirected to the
primary credential; geteuid() and getegid() keep their Linux
meaning and follow the effective credential, so during impersonation
they report the client.
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 / Peios technical reference / 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. |
| 10 | CAP_NET_BIND_SERVICE | The port's reservation SD decides at socket_bind; the Linux privileged-port floor never refuses. |
PRIVILEGE capabilities gate operations no other KACS hook covers, and map to a KACS privilege:
| CAP | Name | Privilege |
|---|---|---|
| 9 | CAP_LINUX_IMMUTABLE | SeTcbPrivilege |
| 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_mount_op(), whose first
rung accepts SeManageVolumePrivilege or SeTcbPrivilege, before
falling back to the ordinary CAP_SYS_ADMIN check. In a mount
namespace the caller created for itself, a second rung admits a bind
mount, an unmount, a pivot_root, or a new tmpfs, proc or read-only
stratafs on the namespace's own security descriptor instead (§3.13); in
the initial namespace the privilege is the only route. 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.
The CAP_SYS_ADMIN gate on creating a namespace (kernel/nsproxy.c,
patched) has one carve-out of the same kind: a request for a mount
namespace alone passes without it, because the table it yields is an
object its creator owns. Any other namespace type, alone or in
combination, still needs the TCB.
CAP_SYS_BOOT carries an extra condition the table cannot express: a
token whose logon session is of a remote origin — Network,
NetworkCleartext, NewCredentials or RemoteInteractive — 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 where they make no
identity-based access decision: landlock, lockdown and integrity stack
safely. Yama is not among them and is not built: its
relational ptrace_scope answers every attach to a non-descendant with
"does the caller hold CAP_SYS_PTRACE", which the switchboard maps to
SeDebugPrivilege, so it would decide ahead of the process descriptor
and PIP dominance that §3.7 makes the ptrace authority. The same-uid
attach it exists to close cannot arise under KACS, which never grants
on uid.
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 / Peios technical reference / 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).
Seccomp filters themselves install as on Linux — no_new_privs, or
CAP_SYS_ADMIN resolved to SeTcbPrivilege — and no_new_privs
keeps its Linux meaning for the one Peios gain an exec can confer:
under it, an exec cannot raise the process's PIP label (§3.7).
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.
3.10.4 Netlink
Peios / Peios technical reference / PKM / KACS / The Linux Credential Model
Netlink is the kernel's message interface: a process sends a request to a kernel subsystem (routing, firewalling, audit, generic netlink families) and the subsystem answers, or pushes notifications to multicast groups. The kernel authorises those requests with capability checks, and on Peios every one of them is a KACS privilege check.
3.10.4.1 Where the checks run #
A request to the kernel is handled synchronously: netlink_unicast
delivers the message to the family's receive function inside the
sending task's own sendmsg, under the family's mutex. There is no
later context in which a message is processed, so the sender at every
check is the current thread, and audit attribution needs no capture.
Only messages between two user sockets are queued.
The netlink permission helpers — netlink_capable,
netlink_ns_capable and netlink_net_capable, and through them the
generic-netlink GENL_ADMIN_PERM and GENL_UNS_ADMIN_PERM operation
flags — test two subjects and require both to pass: the credential
that opened the netlink socket, held on the socket's file, and the
sender, the current thread. netlink_allowed, which gates binding
to a family's multicast groups and sending to another port, tests the
current thread. This is Linux's own two-point rule, introduced after
CVE-2014-0181 so that a privileged program could not be turned into a
deputy by inheriting an unprivileged process's netlink socket as its
standard output.
3.10.4.2 What decides #
Every one of those tests reaches security_capable() with a specific
credential, and the capability switchboard (§3.10.2) evaluates that
credential's token: pkm_kacs_capable_in_cred_ns looks up the token
on the credential and asks whether it holds a privilege the capability
maps to. The opener's file credential carries the opener's token; the
current thread's credential carries the sender's effective token,
impersonation included. So a netlink request is authorised against two
KACS tokens, with the same privilege catalogue and the same fail-closed
behaviour as any other capability check — not against uid bits, and
not against a projection of the token.
The catalogue maps CAP_NET_ADMIN, CAP_NET_RAW and CAP_SYS_ADMIN
to SeTcbPrivilege. Configuring the network — adding a route, setting
an interface up, editing a firewall — is therefore a TCB operation on
Peios; an administrator's session cannot do it, and a RTM_NEWLINK
from an administrator is answered with EPERM. Audit control
(CAP_AUDIT_CONTROL, CAP_AUDIT_READ) maps to SeSecurityPrivilege
and audit writes (CAP_AUDIT_WRITE) to SeAuditPrivilege. Requests
that need no capability — dumps of the routing table or the link list
— are answered for any caller.
3.10.4.3 What netlink carries about the sender #
A netlink message's metadata (NETLINK_CB) records the sender's
projected UID and GID, in the same form SO_PEERCRED uses, and
delivers them to a receiving user socket that asked for
SCM_CREDENTIALS with SO_PASSCRED. As everywhere on Peios, those
values are a projection for compatibility and display, not an
authorisation input (§3.10.1). Netlink between two user sockets is not
a supported identity-carrying transport: it carries the projection and
nothing more, and a program that needs the peer's token uses an
AF_UNIX socket (§3.5).
3.11.1 Objects and Their Descriptors
Peios / Peios technical reference / PKM / KACS / System V IPC
System V IPC — message queues (msgget), shared memory segments
(shmget) and semaphore arrays (semget) — predates file descriptors
and paths: an object is named by an integer key, identified by an
integer id, and persists in the kernel until removed or reboot. Linux
protects each with a nine-bit mode in struct ipc_perm, checked by
ipcperms() against the caller's UID and GID.
On Peios that mode is inert. CAP_IPC_OWNER is in KACS's always-allow
set (§3.10.2), so ipcperms() never denies on the mode and always
reaches security_ipc_permission(); there KACS decides, against a
security descriptor the object carries.
3.11.1.1 The descriptor #
Each object gets a descriptor when it is created, built from the
creator's effective token: owner and group from the token; the
creator's user SID, BUILTIN\Administrators and SYSTEM at
GENERIC_ALL. It is held on the object's LSM blob for the object's
life — persisting with the object across the creator's exit, as SysV
objects do — and released when the object is removed.
The rights are those of <pkm/ipc.h>:
| Right | Grants |
|---|---|
KACS_IPC_READ | msgrcv; shmat read-only; semop without alter; GETVAL, GETPID, GETNCNT, GETZCNT, GETALL |
KACS_IPC_WRITE | msgsnd; shmat read-write; semop with alter; SETVAL, SETALL |
KACS_IPC_QUERY_INFORMATION | IPC_STAT and the SHM_STAT, MSG_STAT, SEM_STAT families — together with KACS_IPC_READ, since Linux's own ipcperms() read check runs before the hook |
KACS_IPC_SET_INFORMATION | SHM_LOCK, SHM_UNLOCK — SHM_LOCK additionally needs SeLockMemoryPrivilege, Linux's CAP_IPC_LOCK gate |
DELETE | IPC_RMID |
WRITE_DAC and WRITE_OWNER | IPC_SET — one command carries mode, uid and gid, so it needs both |
READ_CONTROL | reading the descriptor with kacs_get_sd |
The generic mapping: GENERIC_READ is read plus query and
READ_CONTROL; GENERIC_WRITE is write plus set-information and
READ_CONTROL; GENERIC_EXECUTE is query and READ_CONTROL;
GENERIC_ALL is everything above.
3.11.1.2 Where the checks run #
ipcperms() runs for every data operation with the mode bits the
operation would have needed — read, write or both — and
security_ipc_permission() maps them to KACS_IPC_READ and
KACS_IPC_WRITE and runs AccessCheck for the caller's effective token,
under the caller's PIP context, against the object's descriptor. The
*get path with an existing key goes through the same check with the
requested mode. The control operations go through the per-object
shmctl, msgctl and semctl hooks, which map the command to the
right in the table above; commands that address no object
(IPC_INFO, SHM_INFO, MSG_INFO, SEM_INFO) need nothing.
Linux's own ownership check on IPC_SET and IPC_RMID — that the
caller's effective UID matches the object's creator or owner, or holds
CAP_SYS_ADMIN — still runs first, on the projected UID, and cannot be
relaxed by the descriptor: the hooks only ever further restrict. So an
administrator who is not the object's creator needs both the
descriptor's DELETE (which the default grants Administrators) and
SeTcbPrivilege (what CAP_SYS_ADMIN maps to) to remove it. The
uid, gid and mode that IPC_SET writes remain as Linux stores
them and are what ipcs and IPC_STAT report; they are informational,
as file mode bits are under FACS.
3.11.1.3 Reading and changing a descriptor #
A SysV object has no fd and no path, so kacs_get_sd and kacs_set_sd
address it by kind and id: one of KACS_SD_AT_SYSV_SHM,
KACS_SD_AT_SYSV_MSG or KACS_SD_AT_SYSV_SEM in flags, the object
id in dirfd, and a NULL path. The object is looked up in the caller's
IPC namespace; an unknown id is EINVAL, and so is a removed one,
because IPC_RMID drops the id from the namespace in the same step
that marks the object deleted — EIDRM is reserved for a lookup that
races that removal and finds the object already dead.
Reading needs the rights the requested SECURITY_INFORMATION implies
(READ_CONTROL for owner, group and DACL; ACCESS_SYSTEM_SECURITY
for the SACL), changing needs WRITE_DAC or WRITE_OWNER as for any
object, and the new descriptor is merged into the existing one
component by component, as for a process descriptor (§3.3.3).
3.11.1.4 Names #
The key namespace is claim-on-create: whoever calls *get with
IPC_CREAT on a free key owns the object, and nothing prevents a
process from claiming a key another program expected to create — the
same shape as the abstract socket namespace and /dev/shm. The
descriptor protects the object once it exists; it cannot protect the
name. A program that needs a specific key uses IPC_EXCL and treats a
failure as a signal, and a program that needs identity-carrying
messaging uses an AF_UNIX socket (§3.5): a message queue conveys no
sender identity, and msgrcv returns bytes and a type, nothing more.
3.12.1 Port Reservations
Peios / Peios technical reference / PKM / KACS / Network Objects
Ports are a shared, unowned namespace. Linux protects it with one rule:
bind(2) below port 1024 needs CAP_NET_BIND_SERVICE, and everything
above is anyone's. That is too coarse in one direction — any privileged
process gets every low port — and absent in the other: nothing protects
8080, 3306 or 5432 from whichever process asks first.
On Peios the only thing worth authorising about a port is the claim
— binding a non-zero port — and it is authorised the way everything
else is: by a security descriptor. CAP_NET_BIND_SERVICE is in the
always-allow set (§3.10.2), so the Linux floor never refuses first, and
every claim reaches security_socket_bind(), where KACS decides.
The precedent is not Linux but HTTP.sys's URL reservations on Windows: a sparse table of namespace prefixes, each carrying a descriptor, consulted when a name is claimed, default-permissive where nothing is reserved. This chapter moves that model down to the port layer.
3.12.1.1 The object #
A reservation is a selector mapped to a descriptor. The selector names a set of protocols and an inclusive port range:
<proto>[,<proto>]:<lo>[-<hi>] proto ∈ tcp | udp | *
tcp:80, udp:53, tcp,udp:1-1023, *:8080. Ports are decimal,
1–65535, no leading zeros, lo <= hi; a protocol may not repeat, so
every selector has one spelling. The address family is deliberately not
part of the selector: a reservation covers IPv4 and IPv6 alike, or
dual-stack squatting — claiming the v6 side of a port reserved on v4 —
would bypass it.
The rights are those of <pkm/net.h>:
| Right | Grants |
|---|---|
KACS_PORT_BIND (0x00000001) | bind(2) to any port the selector contains |
READ_CONTROL | reading the descriptor |
That is the whole mask. Editing a reservation is a registry write,
governed by the registry key's own descriptor — one edit policy for the
table — so WRITE_DAC and WRITE_OWNER inside a port descriptor mean
nothing and are never honoured. The generic mapping is GENERIC_EXECUTE
→ PORT_BIND, GENERIC_READ → READ_CONTROL, GENERIC_WRITE → nothing,
GENERIC_ALL → both.
3.12.1.2 The table #
Reservations live as values under one registry key:
Machine\System\Network\TcpIp\PortReservations\
@ REG_BINARY SD the default reservation
tcp,udp:1-1023 REG_BINARY SD
tcp:80 REG_BINARY SD
Each value's name is its selector and its data a self-relative
descriptor. The key's unnamed default value, @, is the default
reservation: the descriptor for every port no selector contains. The
shipped seed grants PORT_BIND to Everyone on @ — an unreserved port
is anyone's, as on Windows — and to SYSTEM and the bind-low-ports
capability SID on tcp,udp:1-1023, which is the Linux convention
restated as policy. Services then carry their own entries:
tcp:80,443 → NT SERVICE\httpd: PORT_BIND, which composes with
per-service SIDs so that a compromised sshd cannot take 443 even
though both are "privileged".
The kernel reads the key whole and rejects it whole. Every name must
parse, every descriptor must parse, exactly one @ must exist, and no
two selectors of equal width may overlap — the most specific match
would be ambiguous, and the kernel refuses to guess. On any failure the
load is audited and the previous table stays in force.
The rejection is recorded as kacs.config.value.rejected (§3.C):
config.key.path names the key, config.name the value at fault where
one is, and outcome.reason why — empty, too-large, bad-selector,
bad-descriptor, duplicate-default, missing-default or overlap —
with policy.previous-retained true and policy.fallback saying whether
what stayed in force is the compiled-in table. Nested overlap,
and overlap between selectors of different widths, is how specificity
works and is accepted.
3.12.1.3 The decision #
At bind(2) on an AF_INET or AF_INET6 socket whose protocol is TCP,
UDP or UDP-Lite:
- If the requested port is 0 — ephemeral allocation — nothing is checked. Protocols no reservation covers (SCTP, raw) pass untouched.
- The most specific reservation containing
(protocol, port)is selected: the one covering the fewest ports, regardless of how many protocols it names. None → the default reservation. - The caller's effective token is evaluated against that descriptor
for
PORT_BIND, through the ordinary AccessCheck pipeline (§3.8) with PIP context, privilege-use marking and audit events — a port descriptor may carry a SACL, and its decisions surface through the same KMES stream as any other object's, withobject.kindsocket. - Deny →
-EACCES. Grant → the bind proceeds, and the caller's token is recorded on the socket as its binder.
An explicit bind(2) before an outbound connect(2) meets the same
rule; it is not special-cased.
Rebinding. Binding onto a port that is already bound — with
SO_REUSEADDR or SO_REUSEPORT — is still a bind(2) and still meets
the reservation. The rule beyond that is that the new binder's user SID
must equal the existing binder's, or the caller must hold
SeTcbPrivilege: rebind is a property of the current binding, not of
policy, and a right on the reservation that let one principal rebind
onto another's port is a thing nothing legitimately needs. The binder
recorded on each socket is what makes that comparison local; the
comparison itself is applied where the stack resolves bind conflicts,
and Linux already applies the same-owner half of it for SO_REUSEPORT
by comparing the sockets' projected UIDs.
3.12.1.4 Before the registry #
The table comes from the registry through the same self-configuration
path LCS uses for its own limits (§5.10): read at the first source's
registration, then watched, so a change to the key is live without a
restart. Until the first successful load — early boot, or a registry
that never becomes available — a compiled-in fallback answers:
owner and group SYSTEM, one entry granting SYSTEM PORT_BIND, nobody
else. It is stricter than the shipped seed on purpose: nothing but
system components should be claiming ports before the registry exists.
The fallback is replaced, never merged. The moment the key loads it is the sole authority; if a later load is rejected the last good table stays, not the fallback. Effective policy therefore never depends on boot timing.
3.12.1.5 What this retired #
SeBindPrivilegedPortPrivilege (bit 63) was the KACS privilege
CAP_NET_BIND_SERVICE mapped to before this object existed. A
reservation's grant is an ACE, and an ACE names a SID, not a privilege;
keeping the privilege would have been a second authorisation path
around the descriptor. It is retired and its bit is not reused. Low
ports are granted through the capability SID on the 1-1023
reservation, which reaches a token by confinement — peinit for a
service, or positive confinement from the authority at logon for a
user.
3.12.1.6 Tracing #
Every decision emits kacs:kacs_socket_bind with reason port-bind:
desired carries PORT_BIND, max_imp carries the port, and ret the
verdict. Table loads emit reason port-table. See Appendix 3.C for the audit
events an evaluated descriptor's SACL can produce.
3.12.2 Socket Identity
Peios / Peios technical reference / PKM / KACS / Network Objects
Network policy speaks of programs, and a packet carries no program. What
carries one is the socket: KACS records on every AF_INET and
AF_INET6 socket the identity that governs its traffic, and the
network policy engine (Chapter 6, §6.9) reads it at the first judgment
of each flow. AF_UNIX sockets carry the peer-identity machinery of
§3.5 instead and are not stamped.
3.12.2.1 The stamp #
The stamp is the caller's effective token — the impersonation token
during impersonation, the primary otherwise — held by a counted
reference, together with the process facts of that moment: the process
GUID (§3.3.2), the thread-group id, and the task's comm. Choosing the
effective token means a service thread acting for a client attributes
the socket to the client, exactly as file creation and audit do under
impersonation; the service's own identity is then not on the socket for
that flow.
It is taken at every act that commits the socket to a role, and the last one governs:
Restamping is what makes a hand-off well defined. A listener opened by
one program and passed to another — socket activation, a descriptor
store, a broker — is governed by whichever program last committed it,
and a program that receives a socket it did not create can say so
explicitly with KACS_SO_RESTAMP, self-gated like every attestation of
one's own identity. Sockets that never pass through these hooks (a task
with no token in early boot) read as unstamped, which the engine
treats as the kernel's and confesses.
3.12.2.2 Reading it #
The engine reads the stamp with pkm_kacs_socket_owner()
(<linux/peios_ntfe.h>): a counted token reference the caller releases
with pkm_kacs_socket_owner_put(), and a copy of the kind, GUID, pid and
comm. The copy is what makes the facts outlive the process; the
reference is what makes them outlive the socket — a flow keeps its
identity for its life (§6.9) even after the socket that started it is
gone. The fields live under their own lock, taken with bottom halves
disabled, because both the accept path and the engine's read run in
softirq context.
3.12.2.3 Final release #
Once the socket's last user is gone, KACS releases the token references and security descriptor owned by the socket. This also releases any peer, listener, binder and send-cache references; each field owns its own reference even when several refer to the same token. Final cleanup can run from an RCU callback and must not sleep or acquire the locks used to coordinate live socket users. It has exclusive ownership at this point. References already taken by a flow or another reader remain valid until that reader releases them.
3.12.2.4 Tracing #
Every stamp emits kacs:kacs_socket_token with reason owner; the
max_imp field carries the kind (1 program, 2 kernel). See Appendix
3.C.
3.13.1 A Private Mount Table
Peios / Peios technical reference / PKM / KACS / Mount Namespaces
A mount namespace is a private copy of the kernel's mount table: the
map from directory paths to the filesystems mounted there. Linux
answers "who may change this table" with CAP_SYS_ADMIN in the user
namespace that owns it, and lets an unprivileged process manufacture
that capability by creating a user namespace first — which is how
bubblewrap, and every unprivileged container tool built the same way,
gets a private root.
On Peios that route leads nowhere. The capability switchboard discards
the target user namespace and answers from the token's privileges
alone (§3.10.2), so being root in a user namespace confers nothing, and
CAP_SYS_ADMIN maps to SeTcbPrivilege. KACS answers the question the
way it answers every other: the mount namespace is an object with a
security descriptor, and changing it is an access check.
3.13.1.1 The object #
Every mount namespace other than the initial one carries a security
descriptor, minted in copy_mnt_ns() from the creating task's effective
token and released when the namespace is. The initial namespace, and the
anonymous namespaces open_tree(OPEN_TREE_CLONE) and fsmount create
for detached trees, carry none.
The minted descriptor names its creator and nobody else: owner and
group come from the token, and the DACL is a single allow ACE granting
the creator's user SID GENERIC_ALL. SYSTEM and
BUILTIN\Administrators, which the SysV IPC and socket defaults name,
are absent on purpose — the privilege rung of the mount gate admits them
before the descriptor is read, so an ACE would only pretend the
descriptor was what let them in.
The rights are those of <pkm/mntns.h>:
| Right | Grants |
|---|---|
KACS_MNTNS_MOUNT | changing the table: a bind mount, an unmount, pivot_root |
KACS_MNTNS_ENTER | joining the namespace with setns — defined so the descriptor's meaning is fixed; no code consults it yet, and setns into a mount namespace still needs SeTcbPrivilege |
READ_CONTROL, WRITE_DAC, WRITE_OWNER | the standard rights, which KACS_MNTNS_ALL_ACCESS includes; nothing exposes a namespace's descriptor to kacs_get_sd or kacs_set_sd yet |
The generic mapping: GENERIC_WRITE is KACS_MNTNS_MOUNT and
WRITE_DAC; GENERIC_EXECUTE is KACS_MNTNS_ENTER and READ_CONTROL;
GENERIC_READ is READ_CONTROL alone, there being nothing to read from
a namespace beyond its descriptor; GENERIC_ALL is
KACS_MNTNS_ALL_ACCESS.
3.13.1.2 Creating one #
unshare(CLONE_NEWNS), and clone with CLONE_NEWNS, need no
privilege when the mount namespace is the only namespace requested.
Every other namespace type — and any combination that includes one —
keeps the CAP_SYS_ADMIN gate in kernel/nsproxy.c, which the
switchboard answers with SeTcbPrivilege. A user namespace may still be
created alongside, as upstream allows; it changes nothing about what
follows.
A table created by a task that does not hold the mount privilege receives its mounts as slaves of the parent table's, exactly as upstream does for a copy owned by a different user namespace: propagation runs parent to child only, so whatever the creator mounts in its own table never appears in the one it copied. A table created with the privilege is copied as upstream copies it, peers and all.
3.13.1.3 The mount gate #
may_mount() in fs/namespace.c is the gate for mount, umount2,
pivot_root, open_tree(OPEN_TREE_CLONE), fsmount, move_mount and
mount_setattr, and for fsopen and fspick in fs/fsopen.c.
mount_capable() in fs/super.c is the second
gate a new filesystem passes, when its superblock is brought into being.
Under KACS both take the operation as an argument, with the filesystem
type where one is being created, and ask pkm_kacs_may_mount_op() in
three rungs, against the caller's effective token and the descriptor of
the caller's current namespace:
- Privilege. A token holding
SeManageVolumePrivilegeorSeTcbPrivilege, enabled, is admitted to every operation in every namespace, and the privilege is marked used. This rung is decided first and alone: a token that holds the privilege never reaches the descriptor, and a token that does not is never asked the privilege question, so an unprivileged caller's routine bind mount records no privilege refusal. - No descriptor. In a namespace with no descriptor — the initial one — nothing else admits anything. This is the whole of the rule that the root table keeps: without the privilege, no process changes it.
- Descriptor. Otherwise, and only for an operation the descriptor
can admit, AccessCheck for
KACS_MNTNS_MOUNTruns against the namespace's descriptor under the caller's PIP context, exactly as it does for a socket or a SysV object.
The operations the descriptor can admit are the ones that put no kernel filesystem parser of an untrusted image in reach of the caller:
mount(2) shape | Gate operation | Admissible by descriptor |
|---|---|---|
MS_BIND, with or without MS_REC | bind | yes |
umount2 without MNT_FORCE | unmount | yes |
pivot_root | pivot_root | yes |
a new tmpfs, proc or stratafs (do_new_mount) | new filesystem | yes |
| any other filesystem type | new filesystem | no |
MS_REMOUNT, including MS_REMOUNT|MS_BIND | other | no |
MS_MOVE; MS_SHARED, MS_PRIVATE, MS_SLAVE, MS_UNBINDABLE | other | no |
open_tree(OPEN_TREE_CLONE), fsopen, fspick, fsmount, move_mount, mount_setattr | other | no |
The filesystem types a descriptor can admit are an allowlist KACS keeps,
and it holds exactly tmpfs, proc and stratafs: all three read
nothing but the caller's own mount options. tmpfs has no backing image,
proc is a view of the kernel's own state, and stratafs is a view of
directories the caller can already traverse, every access through it
decided on the providing object (§4.6.1). A type that parses an image —
ext4, squashfs, iso9660, ntfs3 — is refused before the type is even
looked up. The list is an attack-surface list and not a policy knob:
who may change a table is decided by the table's descriptor alone, and
the same descriptor that admits tmpfs refuses ext4.
A stratafs stack admitted this way is read-only or absent-tolerant.
stratafs's own admission (§4.2.3) refuses a create stratum to any
caller outside the initial user namespace or without CAP_SYS_ADMIN,
which is SeTcbPrivilege, and that test runs after the gate, so an
unprivileged caller that asks for one gets EPERM from stratafs
whatever its table's descriptor grants. Copy-up carries no add-entry
right of its own, which is why a writable stack is authority in itself
and stays with the TCB; a private table's writable paths are tmpfs, or
bind mounts of directories the caller owns. Admitting the type does put
stratafs's option parser and stack validation in reach of unprivileged
input for the first time; both fail closed with EINVAL on a relative
stratum, a duplicate, create combined with ro, an empty stack or
more than sixteen strata, and with ENOENT on a missing stratum not
marked am.
Everything in the no rows needs the privilege whatever the descriptor
grants, and MNT_FORCE additionally keeps its own CAP_SYS_ADMIN test
on the superblock's user namespace. A mount that the gate admits still
goes through every check upstream applies afterwards: the bind source is
looked up through the caller's own table, an unmount needs a mount
point that belongs to it, and pivot_root needs the propagation
conditions it always needed, which the slave copy of an unprivileged
table satisfies.
3.13.1.3.1 An unprivileged tmpfs is stamped for its creator #
A fresh tmpfs is deny-missing under FACS (§3.9.5): nothing on it carries
a descriptor, so nothing on it is reachable until someone sets the
superblock's mount policy, which kacs_set_mount_policy reserves to the
mount privilege. That is right for the system's own overlays, which are
seeded deliberately, and it would leave a tmpfs admitted by a namespace
descriptor permanently empty. So at sb_kern_mount, a tmpfs brought
into being by a token that holds no mount privilege is stamped
synthesize-ephemeral with a template descriptor the kernel mints itself:
owner and group from the mounter's token, one allow ACE granting the
mounter's user SID GENERIC_ALL — the same shape as the namespace
descriptor. The policy generation moves to 1, which retires the
deny-missing default the root inode's creation cached during
fill_super. The mounter cannot choose the template, and a superblock
whose policy kacs_set_mount_policy has already set is left alone. A
privileged mounter's tmpfs is untouched: it keeps the magic default and
the mounter seeds it, as before. proc is unmanaged and needs no
stamp; a second proc mount in the same pid namespace shows the same
processes under the same per-process checks.
So a process whose whole world is a directory of its choosing is:
unshare(CLONE_NEWNS);
mount(dir, dir, NULL, MS_BIND, NULL);
chdir(dir);
pivot_root(".", ".");
umount2(".", MNT_DETACH);
chdir("/");
with no user namespace and no privilege. The bind makes the directory a
mount point, which pivot_root requires; pivoting onto the same path
stacks the old root on the new one, and detaching . drops it.
3.13.1.4 What the object does not change #
The token never changes at exec (§3.10.3), so a program run inside a
private table has exactly the authority of the process that built it,
and a fake /etc fools nobody but its author. Every access to a file
reached through a bind mount is decided on the real object's
descriptor, so a bind mount shows a name and denies at open. The
kernel's own helpers — modprobe and its kin — are executed in the
initial namespace and refused unless TCB-signed (§3.7). A signed
binary's PIP tier is derived from the binary and not from the table it
was found in, and library verification, where a process enables it,
compares signatures at mmap rather than paths.
3.13.1.5 Tracing #
The kacs:kacs_mntns event records a descriptor minted at namespace
creation, each rung of the gate, and a tmpfs stamped for its creator,
with the operation code, the right asked of the descriptor where a rung
reached it, a reason from <pkm/trace.h> — sd-alloc, sd-alloc-fail,
gate-privilege, gate-no-sd, gate-op-not-admitted,
gate-sd-decision, gate-pip-context, gate-fs-not-admitted,
sb-stamp, sb-stamp-fail — and the verdict.
Appendix 3.A KACS ABI Reference
Peios / Peios technical reference / 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/.
The PKM UAPI exports the uppercase constants in <pkm/syscall.h>. Peios
glibc 2.44-5 and later exports the standard lowercase SYS_kacs_*
aliases in <sys/syscall.h>; each alias has the same number as its PKM
constant.
| Number | PKM constant | glibc alias | Signature |
|---|---|---|---|
| 1000 | SYS_KACS_OPEN_SELF_TOKEN | SYS_kacs_open_self_token | kacs_open_self_token(unsigned int flags, u32 access_mask) |
| 1001 | SYS_KACS_OPEN_PROCESS_TOKEN | SYS_kacs_open_process_token | kacs_open_process_token(int pidfd, u32 access_mask) |
| 1002 | SYS_KACS_OPEN_THREAD_TOKEN | SYS_kacs_open_thread_token | kacs_open_thread_token(int pidfd, int tid, u32 access_mask) |
| 1003 | SYS_KACS_CREATE_TOKEN | SYS_kacs_create_token | kacs_create_token(const void __user *spec, size_t spec_len) |
| 1004 | SYS_KACS_CREATE_LOGON_SESSION | SYS_kacs_create_logon_session | kacs_create_logon_session(const void __user *spec, size_t spec_len) |
| 1005 | SYS_KACS_SET_PSB | SYS_kacs_set_psb | kacs_set_psb(int pidfd, u32 mitigations) |
| 1006 | SYS_KACS_DESTROY_EMPTY_LOGON_SESSION | SYS_kacs_destroy_empty_logon_session | kacs_destroy_empty_logon_session(u64 auth_id) |
| 1012 | SYS_KACS_REVERT | SYS_kacs_revert | kacs_revert(void) |
| 1020 | SYS_KACS_OPEN | 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 | 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 | 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 | SYS_kacs_access_check | kacs_access_check(const void __user *uargs) |
| 1024 | SYS_KACS_ACCESS_CHECK_LIST | 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 | 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 | 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 | 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 |
KACS_LOGON_TYPE_REMOTE_INTERACTIVE | 10 |
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 |
| 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).
GUID returns 16 immutable raw bytes in the same order as KMES headers.
| 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) |
KACS_TOKEN_CLASS_GUID | 0x19 (25) |
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) |
3.A.4 socket.h #
From uapi/pkm/socket.h.
KACS socket options — peer identity on local sockets.
KACS captures a connecting client's identity onto the accepted end of an AF_UNIX SOCK_STREAM / SOCK_SEQPACKET connection at connect(). This header is the setsockopt(2)/getsockopt(2) surface through which that identity is bounded and read back. Every option lives under one option level, SOL_KACS, which the kernel dispatches ahead of the protocol's own setsockopt/getsockopt (net/socket.c), so the options behave identically on every socket family that supports them. SOL_KACS is 4096: far above the upstream SOL_* range, which grows by one per new protocol, so it cannot collide with a future Linux level. Errors: -ENOPROTOOPT for an option this level does not define (or a get-only option passed to setsockopt); -EOPNOTSUPP on a socket family KACS does not carry identity on (and, for the register, on a socket type that has none); -EINVAL for a short optlen or an out-of-range value; -EFAULT for an unreadable/unwritable optval.
| Constant | Value |
|---|---|
SOL_KACS | 4096 |
optval: int — a new token fd, carrying fixed TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE access and opened O_CLOEXEC, for this end's conveyed-identity register: the peer identity associated with the data this end has consumed so far. The register is initialised at connect() — on the accepted end with the client's identity, on the connecting end with the listener's identity as captured at listen() (at KACS_IMLEVEL_IDENTIFICATION unless the listener set its own level) — and advanced by each KACS_SCM_TOKEN the reader's position passes (a token still queued but unread is not yet visible). Every call returns a fresh fd to an immutable snapshot; two calls may name different tokens, but no token ever changes. -ENOTCONN if the socket is not connected; -ENODATA if nothing has been conveyed yet.
| Constant | Value |
|---|---|
KACS_SO_PEER_TOKEN | 1 |
optval: _u32 KACS_IMLEVEL* — the maximum impersonation level at which identity leaving this end may be captured: at connect(), and at each send that conveys identity. The default is KACS_IMLEVEL_IMPERSONATION. May be changed at any time; a change bounds captures from then on and never rewrites one already made.
| Constant | Value |
|---|---|
KACS_SO_IMPERSONATION_LEVEL | 2 |
optval: int (0/1) — sender-side automation: while set, every send from this end carries the sender's effective identity as a KACS_SCM_TOKEN, derived at this end's impersonation level. Costs nothing at the trust level: the sender can always attest to what it is. Default 0.
| Constant | Value |
|---|---|
KACS_SO_PASS_TOKEN | 3 |
optval: int, ignored. Self-gated: a process can always attest to what it is. On a listening AF_UNIX socket: replaces the identity the listener conveys to connecting clients — captured when listen() was called — with the caller's current effective identity, at the listener's level. For a process that receives a listener it did not create (from a descriptor store after a restart, or from a broker), so that clients see the process actually accepting. -EINVAL if the socket is not listening. On an AF_INET / AF_INET6 socket, in any state: replaces the identity that governs the socket's traffic for network policy — stamped at creation, bind, listen, connect and accept — with the caller's current effective identity, so a socket handed to another program (socket activation, descriptor passing) is governed as that program's. The identity is read by the network policy engine at the first judgment of each flow and fixed for that flow's life.
| Constant | Value |
|---|---|
KACS_SO_RESTAMP | 4 |
Ancillary message type, at cmsg_level SOL_KACS.
Data: one int. Received: a token fd (TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE, O_CLOEXEC) for the identity the kernel attests sent the accompanying data. Delivered when the receive buffer has room for it and the conveyed identity differs from the reader's register; a receiver that reads no ancillary data still has the register (KACS_SO_PEER_TOKEN). Sent: a token fd the sender wishes to attach to the data. The kernel gates the send as if the sender were impersonating that token: the two-gate model runs with the sender as installer, and an attach that would lower the token's level fails with -EPERM rather than downgrading. A primary token is derived to an impersonation token at this end's level. The fd must carry TOKEN_IMPERSONATE (-EACCES otherwise); at most one per message (-EINVAL). Attaching is exactly equivalent to impersonating the token for the duration of the send.
| Constant | Value |
|---|---|
KACS_SCM_TOKEN | 1 |
3.A.5 ipc.h #
From uapi/pkm/ipc.h.
KACS access rights for System V IPC objects — message queues, shared memory segments and semaphore arrays. Every SysV object carries a security descriptor, created from the creator's token at *get time and enforced through the LSM IPC hooks; the nine-bit ipc_perm mode is never consulted (CAP_IPC_OWNER is in KACS's always-allow set). The key namespace is claim-on-create: the descriptor protects the object, nothing protects the name. Object-specific rights (low 16 bits); the standard rights (DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER — <pkm/access.h>) apply as on every KACS object.
| Constant | Value | Notes |
|---|---|---|
KACS_IPC_READ | 0x00000001 (1) | msgrcv, shmat read-only, semop without alter, GETVAL/GETALL |
KACS_IPC_WRITE | 0x00000002 (2) | msgsnd, shmat read-write, semop with alter, SETVAL/SETALL |
KACS_IPC_QUERY_INFORMATION | 0x00000004 (4) | IPC_STAT and the *_STAT variants |
KACS_IPC_SET_INFORMATION | 0x00000008 (8) | SHM_LOCK / SHM_UNLOCK |
KACS_IPC_ALL_ACCESS | 983055 |
ipcctl gates: IPC_RMID needs DELETE; IPC_SET needs WRITE_DAC and
WRITE_OWNER (one command carries mode, uid and gid); the mode, uid and
gid it writes are informational (ipcs, IPC_STAT) and never decide
access. Addressing a SysV object's descriptor with kacs_get_sd /
kacs_set_sd: pass one of these in flags, the object id in dirfd, and
a NULL path. The object is looked up in the caller's IPC namespace.
| Constant | Value |
|---|---|
KACS_SD_AT_SYSV_SHM | 0x01000000 |
KACS_SD_AT_SYSV_MSG | 0x02000000 |
KACS_SD_AT_SYSV_SEM | 0x04000000 |
KACS_SD_AT_SYSV_MASK | 117440512 |
3.A.6 mntns.h #
From uapi/pkm/mntns.h.
KACS access rights for mount namespaces.
| Constant | Value | Notes |
|---|---|---|
KACS_MNTNS_MOUNT | 0x00000001 (1) | change the mount table |
KACS_MNTNS_ENTER | 0x00000002 (2) | join the namespace with setns(2) |
KACS_MNTNS_ALL_ACCESS | 917507 |
3.A.7 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) |
| 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.8 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) |
| 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.9 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.10 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. See Kernel TRM section
3.3 (Process Security Block). 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.11 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.12 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.13 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_EXEC_PIP_CAPPED_UNSAFE | 17 | exec PIP label capped at the current one: traced or no_new_privs |
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_firmware reason — why a kernel-initiated firmware read was allowed
or refused (kacs/firmware.c, PEI-493). Firmware is code that runs on a
device with DMA into host memory, so it is held to the PeiosTcb tier:
the same bar as a usermodehelper exec. ret is the verdict handed to
the loader; under log mode it is 0 whatever the reason. Emitted by
kacs:kacs_firmware_load. No path, key, or signature bytes are recorded.
| Constant | Value | Notes |
|---|---|---|
KACS_FW_ALLOWED | 0 | verified at PeiosTcb |
KACS_FW_UNSIGNED | 1 | no signature material on the file |
KACS_FW_NO_KEY_MATCH | 2 | signed, but no built-in key verified it |
KACS_FW_BELOW_TCB | 3 | verified, but the key's tier is below PeiosTcb |
KACS_FW_UNVERIFIABLE | 4 | verification could not run (crypto unavailable) |
KACS_FW_PROBE_FAILED | 5 | reading the signature material failed |
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 |
16 was KACS_SOCK_IMPERSONATE (kacs_impersonate_peer); retired, not reused.
| Constant | Value | Notes |
|---|---|---|
KACS_SOCK_ATTACH | 17 | identity attached to a send |
KACS_SOCK_GATE | 18 | explicit KACS_SCM_TOKEN send-gate verdict |
KACS_SOCK_REGISTER | 19 | conveyed-identity register advanced |
KACS_SOCK_DELIVER | 20 | KACS_SCM_TOKEN delivered to a receiver |
KACS_SOCK_LISTEN | 21 | listener identity captured at listen() |
KACS_SOCK_RESTAMP | 22 | listener identity replaced by KACS_SO_RESTAMP |
KACS_SOCK_PORT_BIND | 23 | inet bind: port reservation SD verdict |
KACS_SOCK_PORT_TABLE | 24 | port reservation table load result |
KACS_SOCK_OWNER | 25 | governing identity stamped on an inet socket |
kacs_ipc reason — a System V IPC object SD decision (ipc.c).
| Constant | Value | Notes |
|---|---|---|
KACS_IPC_ALLOC | 0 | default SD stamped at *get creation |
KACS_IPC_PERMISSION | 1 | ipc_permission: read/write access to the object |
KACS_IPC_CTL | 2 | *ctl command gate (RMID/SET/STAT/...) |
KACS_IPC_SD_QUERY | 3 | kacs_get_sd on the object |
KACS_IPC_SD_SET | 4 | kacs_set_sd on the object |
KACS_IPC_NO_SD | 5 | object carries no SD; fail closed |
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 | kacs.audit.handle.used 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 |
kacs_mntns reason — mount-namespace object lifecycle and the mount gate.
SD_ALLOC / SD_ALLOC_FAIL mark a namespace minted with (or without) its
creator descriptor; the gate reasons say which rung admitted or refused
a mount-table change: PRIVILEGE is SeManageVolume / SeTcb, NO_SD is a
namespace with no descriptor (the initial one), OP_NOT_ADMITTED is an
operation the descriptor can never grant, SD_DECISION is the access
check against the namespace descriptor, PIP_CONTEXT a failure to read
the caller's PIP state. Emitted by kacs:kacs_mntns. Verdict is ret.
| Constant | Value | Notes |
|---|---|---|
KACS_MNTNS_SD_ALLOC | 0 | namespace minted with a creator descriptor |
KACS_MNTNS_SD_ALLOC_FAIL | 1 | descriptor could not be built |
KACS_MNTNS_GATE_PRIVILEGE | 2 | admitted by SeManageVolume / SeTcb |
KACS_MNTNS_GATE_NO_SD | 3 | namespace has no descriptor; refused |
KACS_MNTNS_GATE_OP_NOT_ADMITTED | 4 | op needs the privilege whatever the SD says |
KACS_MNTNS_GATE_SD_DECISION | 5 | access check against the namespace SD |
KACS_MNTNS_GATE_PIP_CONTEXT | 6 | caller PIP context unavailable |
KACS_MNTNS_GATE_FS_NOT_ADMITTED | 7 | filesystem type not on the unprivileged allowlist |
KACS_MNTNS_SB_STAMP | 8 | unprivileged tmpfs stamped synth-ephemeral, creator template |
KACS_MNTNS_SB_STAMP_FAIL | 9 | creator template could not be built |
Appendix 3.B Departures from MS-DTYP
Peios / Peios technical reference / 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.
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 / Peios technical reference / PKM / KACS
KACS emits its audit records through KMES with origin class 2 (§2.2).
Each event's payload is a msgpack map whose field paths follow PGSS
§6.4: access.requested is the key requested inside the map under
access, never a key containing a dot, and a value the emitter does
not have is absent rather than nil. The shared subject group — the
effective token's subject.token.*, the PIP state in subject.pip.*,
and the calling process as emitter.process.{pid,name,executable} — is
attached at emission time from the resolved call context rather than by
the evaluation pipeline (§3.8.9). A process with no executable, such as a
kernel thread, has executable left out; the record is still written.
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, and evman <event-type> prints each one.
3.C.1 Event families #
kacs.descriptor.rejected carries object.kind file and
outcome.reason corrupt. It does not name the file by path: the
descriptor is read through internal path anchors whose mount cannot
always be resolved to an absolute path, so object.file.path is absent.
It names the file by object.file.inode and object.file.device (the
filesystem's st_dev) instead, carries the stored attribute's length as
object.sd.length, and carries the subject whose access made KACS read
the descriptor.
KACS also writes kacs.signature.crypto.failed once at boot when the
signature transform is unavailable (§3.6), carrying
signature.crypto-stage boot-probe and the negative outcome.errno.
The privilege.name field of a kacs.audit.privilege.used event carries a canonical
name. A record of an access check (operation.name access-check)
names one of five — SeSecurityPrivilege, SeTakeOwnershipPrivilege,
SeBackupPrivilege, SeRestorePrivilege and SeRelabelPrivilege —
the only privileges an access check consults.
A record of a capability or volume gate (linux-cap, volume-mount)
names whichever privilege the gate spent (§3.4.1). The encoder names
every defined privilege, and a bit with no name fails it closed rather
than emitting an unnamed privilege; a token holding only privileges an
access check does not consult produces no access-check record at all.
kacs.audit.handle.used carries an operation.name 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. It never carries a caller-supplied audit context: its
object.kind is always file, with the handle's absolute path in
object.file.path (absent only if the path cannot be resolved), and it
never sets fields.attestation.userspace.
3.C.2 Descriptor changes #
kacs.audit.descriptor.changed records a change to the descriptor of a
file, a token, a process or a System V IPC object by a caller authorised
to make it, whether or not the change then applied. A change that
includes the SACL is always recorded, whatever either descriptor says —
so removing a file's SACL, which leaves nothing to audit the removal
itself, is still recorded.
Any other change is recorded when the rights it needed overlap the alarm
mask on the handle it was made through. Only a file handle opened
through FACS carries such a mask, so a change made through a path, or to
a token, a process or an IPC object, is recorded only when it includes
the SACL.
The record names the object by object.kind and its identity —
object.file.path where it can be resolved, object.token.id and
.guid, object.process.guid, or object.ipc.type and .id — and
describes the change in object.sd: the parts set as components, and
the length, SHA-256 digest and owner of the descriptor written
(length, digest, owner) and of the one replaced (length-previous,
digest-previous, owner-previous; absent where there was no stored
descriptor). access.requested is the rights the change needed, with the
handle's access.granted, access.audit-mask and the overlap as
access.matched — zero when only the SACL made the record exist — for a
change made through one.
Registry keys have the same record as lcs.audit.key.descriptor.changed.
3.C.3 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:
kacs.session.destroyedwhere the authentication package name is not valid UTF-8.- The two StrataFS events on an over-long operation string or path.
- Any self-emitted payload that would exceed its encoding buffer.
A StrataFS record is not lost for want of memory. When the allocation
for the full record fails, a reduced record is written from a fixed
buffer instead: the same type with the stratum indices, the operation
and the outcome, and without the paths — object.file.path-relative and
each stratum.path — whose absence marks it as reduced.
The transport itself — ring buffer delivery, buffering and drop accounting — is KMES's (§2.5, §2.7).
Appendix 3.D KACS ABI Notes
Peios / Peios technical reference / 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 Querying a token handle #
The ioctl is straightforward in shape:
ioctl(token_fd, KACS_IOC_QUERY, &args)
Where args is a kacs_query_args struct:
| Field | Meaning |
|---|---|
token_class | The numeric class identifying what to return (1–24 in v0.20). |
buf_len | Input: the size of the output buffer in bytes. Output: the actual number of bytes the query needed. |
buf_ptr | Userspace pointer to the output buffer. |
The kernel:
- Validates the class against the catalog. Unknown classes return
-EINVAL. - Checks that the fd grants
TOKEN_QUERY. If not, returns-EACCES. - Computes the size the response needs.
- If
buf_ptris zero orbuf_lenis zero — this is a size query — writes the required size tobuf_lenand returns 0. - If
buf_ptris non-zero butbuf_lenis smaller than required, returns-ERANGEwith the required size still written tobuf_len. - Otherwise writes the response to the buffer and returns 0.
The "two-call pattern" — size query then fetch — is the standard way to handle variable-length output:
- Call once with
buf_ptr = NULL(orbuf_len = 0). The kernel writes the required size intobuf_lenand returns 0. - Allocate a buffer of the indicated size.
- Call again with
buf_ptrset to the buffer andbuf_lenset to its size. The kernel writes the response.
For classes with a fixed-size response, a single call with a buffer of the known size works in one go. The two-call pattern is needed only for classes whose response size depends on the token's contents (the groups class, the restricted-SIDs class, the default-DACL class, the claims classes).
The ioctl is idempotent — multiple queries for the same class produce the same result as long as the token has not been modified. Tokens carry a modified_id counter that increments on adjustment; if a query is part of a pipeline that depends on consistency across multiple queries, the modified_id can be queried first to detect mid-pipeline changes.
3.D.3 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.
KACS_TOKEN_CLASS_GUID (0x19) uses the standard query contract: a size probe reports 16, a non-probe buffer smaller than 16 returns ERANGE without a partial payload, and every query requires TOKEN_QUERY. TOKEN_QUERY_SOURCE alone does not grant it.
Adjusting a token preserves its GUID; duplicating or restricting it creates a distinct token with a new GUID. Reopening the same token returns the same GUID. The STATISTICS payload remains 40 bytes and its token id is a LUID, not this GUID.
Querying the effective token returns the bytes stamped as KMES effective_token_guid; querying the real primary token returns true_token_guid. Impersonation changes which token the effective open selects, not either token's identity.
Several token fields have no query class at all: created_at,
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.4 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.5 Socket options and retired syscall numbers #
Peer identity on AF_UNIX sockets is read and bounded through socket
options rather than syscalls. uapi/pkm/socket.h defines the option
level SOL_KACS (4096), its options KACS_SO_PEER_TOKEN,
KACS_SO_IMPERSONATION_LEVEL, KACS_SO_PASS_TOKEN and
KACS_SO_RESTAMP, and the ancillary message type KACS_SCM_TOKEN,
which travels at
cmsg_level SOL_KACS (§3.A). The kernel dispatches the option level
in net/socket.c ahead of the protocol's own handlers (patch
net/socket-sol-kacs-dispatch.patch), so the options reach KACS on
every socket family and KACS decides which it supports. The ancillary
message is carried per skb on AF_UNIX: net/scm-kacs-token.patch
gives struct scm_cookie a counted token reference and parses
SOL_KACS control messages; net/af_unix-kacs-token.patch carries
the reference on struct unix_skb_parms, stops a stream read at an
identity boundary, and advances the register at the point of
consumption. Everything KACS-specific behind those hooks lives in
kacs/socket.c. The semantics and error codes are in §3.5.3.
System V IPC objects carry descriptors of their own (§3.11); their
rights are uapi/pkm/ipc.h, and the same header defines the
KACS_SD_AT_SYSV_* flags that let kacs_get_sd and kacs_set_sd
address such an object by kind and id (dirfd carries the id, the
path is NULL). The lookup is ipc_lsm_with_object, exported from
ipc/util.c by ipc/util-lsm-with-object.patch; the hooks themselves
are the LSM's own IPC hooks and need no patch.
Three syscall numbers are retired and left as permanent holes:
1010 (kacs_open_peer_token), 1011 (kacs_impersonate_peer) and
1013 (kacs_set_impersonation_level). A binary built against them
gets ENOSYS. The first became getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN), the third setsockopt(SOL_KACS, KACS_SO_IMPERSONATION_LEVEL), and the second was a fusion of the
first with KACS_IOC_IMPERSONATE that now lives in libpeios as
peios_token_impersonate_peer.
3.D.6 Open-interface documentation discrepancies #
The comparison below records historical documentation differences. The pinned source findings resolve specific creator-SD, create-option and no-follow questions; they do not reconcile every group below. No runtime behavior or historical release boundary was tested or established. The generated ABI supplies layouts and constants; it does not settle these behavioral questions.
Source labels and line numbers refer to the learn snapshot
4b119864d7f51c569ae1ac1932e4430f06731cf2:
- O: the former Security Fundamentals Opening files source, retained in that revision's history.
- N: KACS-Native Open, TRM §3.9.2.
- L: Legacy Open Compatibility, TRM §3.9.3.
- D: Opening a file, SDK reference.
- G: Securing files, SDK guide.
The links reach the current references; the line numbers identify the comparison snapshot above.
-
Maximum allowed. O:116–127 says
MAXIMUM_ALLOWEDreturns the maximum mask without checking the other requested bits, which it calls hints; N:14–19 says the concrete data/execute bits must be granted. Both require a concrete bit and rejectMAXIMUM_ALLOWEDalone. The general DACL walk describes a separate AccessCheck layer and cannot settle open validity by analogy. -
Legacy masks. O:135–145 maps
O_RDONLYtoFILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE,O_WRONLYtoFILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EA | READ_CONTROL | SYNCHRONIZE, andO_RDWRto their union. O:154–158 makes onlyFILE_READ_DATAcore for read-only opens and allowsFILE_READ_ATTRIBUTESto be dropped. L:16–37 instead makesFILE_READ_ATTRIBUTEScore with read data, write data, or both according to the flag; L:39–49 lists broader compat rights. O:138 saysO_APPENDaddsFILE_APPEND_DATA; L:30–33 replaces coreFILE_WRITE_DATAwith it, then re-adds write data forO_TRUNC. L:44–45 also requests write data as optional compat access on append opens. These are different mappings, not interchangeable summaries. -
Creator descriptors. O:96–97 groups open-existing branches under
EINVALand listsSUPERSEDEonly for an absent target. N:133–144 distinguishesFILE_OPEN(EOPNOTSUPP) from existingFILE_OPEN_IF,FILE_OVERWRITEandFILE_OVERWRITE_IF(EINVAL); N:82–89 also permits a caller-supplied SD on replacement byFILE_SUPERSEDE. D:36–46 and G:19–36 showOPEN_IFwith a non-null creator SD and imply that the existing-file branch succeeds, despite O and N rejecting it. The SDK examples have now been changed to create-only. The pinned source findings below settle these existing-object rejections; this pass does not settle the separateSUPERSEDEcomparison. -
Replacement and status. O:71 says
SUPERSEDEremoves the inode and recommends it for atomic-replace patterns. N:82–89 replaces the pathname while preserving old hardlinks and already-open references. O:112 claims nonconditional dispositions predict the status; N:176–179 says an absent-targetFILE_SUPERSEDEreportsCREATED, withSUPERSEDEDonly for actual replacement. No atomicity guarantee follows from this comparison. -
Delete-on-close. O:80 describes deletion on the last fd referencing the file. N:112–129 instead specifies final close of one file-description lineage, preserved by
dup,forkandSCM_RIGHTS; later opens fail closed, and only regular files are supported. Generic inode last-reference semantics and this no-share lineage boundary differ. -
Unverified raw details. O:88 claims native
AT_EMPTY_PATHopens an empty path against the directory referenced bydirfd; N:66–69 discusses that flag for get/set-security, not native open. O:140–143 mapsO_CREATtoOPEN_IFwith parentFILE_ADD_FILE,O_CREAT | O_EXCLtoCREATE, andO_NOFOLLOWtoAT_SYMLINK_NOFOLLOW. Their exact legacy translation and parent-right timing are not established by N/L/D. O:170 attributes legacy creation to umask-based defaults plus parent inheritable ACEs; Inheritance describes parent, creator and token SD sources without establishing that umask contribution. O:183,185 also lists path-componentENOTDIRand invalid-dispositionEINVAL. These remain earlier, unverified claims; omission from another reference does not prove rejection or support. O:180 distinguishes a failed open access check from an unreachable path component; directory traversal is a separate authorization check. -
SDK destination caveats. D:3 mentions share mode; D:28 describes no-follow, write-through and the rest of the
NtCreateFileoption set. N:111–120 instead lists two supported create-option bits, reserves all others, and says the ABI has no share-mode field. D:26 calls the native result a “granted subset,” while N:6–19 distinguishes strict requests from maximum-allowed requests. The SDK reference is a programming entry point, not evidence that every difference has been reconciled. The current SDK page no longer advertises share mode or unsupported create options; the pinned source below verifies the accepted option bits and their SDK field mapping. The granted-subset wording versus strict/maximum semantics remains part of the unresolved comparison.
3.D.6.1 Pinned source findings #
A bounded source check used kernel
8e0e22de3a59cad506bbbf8873de456e16ad272d and libpeios 0.5.8
de0018bdcaed14abb796aeccec9c8387fd30e452. These are implementation findings
for those commits, not proof about a deployed kernel or the release where a
behavior began. Referenced KUnit tests are definitions; they were not executed.
- Creator SDs (part of group 3). Argument validation
rejects
OPENplus creator SD withEOPNOTSUPPandOVERWRITEplus creator SD withEINVAL. Existing-path handling rejectsOPEN_IFandOVERWRITE_IFplus creator SD withEINVALwhen the object exists. The OPEN_IF test records that expectation. SDK marshalling forwards the disposition and SD, rather than stripping the SD on an existing path. Use create-only with an explicit SD or open-existing withNULLSD; anEEXISTretry is a separate operation, not an atomic transaction. - Create options (part of group 7). The accepted-bit mask
contains only
DIRECTORYandDELETE_ON_CLOSE; unsupported bits returnEINVAL. The same SDK marshalling copiesoptionstocreate_optionsandflagstoflags, soAT_SYMLINK_NOFOLLOWbelongs in the latter. - No-follow is operation-specific. Native open
rejects a terminal symlink with
ELOOP. The by-path SD query instead selects the link itself through its lookup flags and path resolver, as recorded by a link-query test. The SDK query path forwards the flags; its buffer-size adaptation does not impose a terminal-link rejection. This query finding does not verify a successful link-SD write or remove the set-security component and storage checks.
Groups 1, 2, 4, 5 and 6 remain unresolved by this check, as do the other behavioral claims in groups 3 and 7. Do not treat the TRM, SDK or generated ABI as a blanket resolution of those differences.
3.D.7 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.8 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,
and the absence of every other MAC LSM and of CONFIG_BPF_LSM, are
enforced at build time by BUILD_BUG_ON in pkm_init (§3.7): a kernel
configured without them does not compile. They were once checked at
initialisation instead, which is no refusal at all -- the LSM framework
answers a failed init with a warning and boots with the hooks
uninstalled, the opposite of what the gate intends (PEI-487).
CONFIG_SECURITY_SELINUX, _APPARMOR, _SMACK and _TOMOYO are
additionally refused by Kconfig dependency. CONFIG_LSM is never
parsed.
Two further symbols gate large bodies of code:
Appendix 3.E LSM integration
Peios / Peios technical reference / PKM / KACS
KACS registers as the pkm Linux Security Module. This appendix is an
implementation inventory for kernel developers: it describes the storage
and call sites behind the contracts in this chapter. These C structures
and callbacks are kernel-private, not userspace layouts or extension APIs.
The exported ABI remains in §3.A.
The inventory comes from pkm/kacs/lsm.c (pkm_blob_sizes and
pkm_hooks[]), the structures in kacs/lsm_internal.h, and the callback
implementations listed below. When changing those declarations, update
this inventory with the same change. The hook matrix preserves registration
order; each row names an actual LSM_HOOK_INIT entry.
3.E.1 Security-blob inventory #
pkm_blob_sizes requests eight sized blob families. Each lbs_* entry
is initialized with sizeof the corresponding structure; Linux's LSM
infrastructure assigns its offset in the object's security storage. Byte
sizes and offsets depend on the build and are not fixed by this manual.
| LSM size entry | KACS structure | State carried |
|---|---|---|
lbs_cred | pkm_kacs_cred_security | Token reference, shared process_state, projected UID/GID, and the pending descriptor used while overlayfs creates or copies up an inode. |
lbs_task | pkm_kacs_task_security | Shared process_state, usermode-helper and impersonation state, the applied primary-installation epoch and thread-enrollment callback, deferred privilege-audit work, native-open/create requests, write/metadata decisions, and narrowly scoped StrataFS creation, supersede, cleanup and delete-on-close context. |
lbs_sock | pkm_kacs_socket_security | Socket descriptor, conveyed peer/listener identities, token-conveyance cache and impersonation limits, binder identity/reuse decision, and the committed socket-owner identity used by network policy. |
lbs_ipc | pkm_kacs_ipc_security | Refcounted descriptor and lock for a System V message queue, shared-memory segment or semaphore array. |
lbs_file | pkm_kacs_file_security | Cached granted_access and continuous_audit_mask, copy-up context and phase generation, managed, delete_on_close, and the referenced token that armed deferred deletion. |
lbs_backing_file | pkm_kacs_backing_file_security | Cached granted_access and continuous_audit_mask, plus managed and inherited, for the explicit outer-to-backing-file snapshot transfer. |
lbs_inode | pkm_kacs_inode_security | Lock and RCU descriptor-cache pointer, delete-on-close lineage and signed-exec pin counters, and the deferred descriptor-persistence state. Test-only members exist under CONFIG_SECURITY_PKM_KUNIT. |
lbs_superblock | pkm_kacs_superblock_security | Mount-policy lock, template descriptor bytes/length, policy generation and mount policy. |
The additional lbs_xattr_count = 1 reserves one xattr-initialization
slot. It is not a ninth object blob or a byte-size declaration.
The file blob uses separate u8 managed and u8 delete_on_close
fields. There is no flags member or KACS_FILE_FACS_MANAGED constant.
The backing-file blob is distinct from the ordinary file blob: its
inherited marker records explicit adoption of the outer description's
cached grants and continuous-audit mask (§3.9.7).
The task blob reaches process-wide state through its process_state
pointer. The refcounted pkm_kacs_process_state contains process_guid,
pip_type, pip_trust, one u32 mitigation_bits, the KMES rate bucket,
the process_sd reference and process-scoped privilege-audit bookkeeping.
It also holds primary_lock, desired_primary_token and primary_epoch,
which order primary-token installation across the process's threads
(§3.2.3).
PIP identity and the process descriptor are not inline task-blob fields;
the process mitigations are not nine independent booleans. This shared
state is separate from the effective token used during impersonation.
The credential blob's pending_create_sd has the lifetime of an override
credential. Overlayfs uses it to stamp the descriptor computed for the
original object or caller/parent onto the actual created inode; credential
preparation does not inherit it into a derived credential. The task blob's
internal SD read/write depth and operation contexts similarly identify
specific kernel work; they are not caller-settable authorization flags.
3.E.2 Registered hook matrix #
The current pkm_hooks[] table contains 99 registrations. Paths in the
last column are relative to the pkm source repository. A callback's
presence here identifies where KACS is called; the operation's access,
privilege and lifetime rules remain specified in the relevant chapter.
| Linux LSM hook | KACS callback | Implementation |
|---|---|---|
cred_prepare | pkm_kacs_cred_prepare | kacs/cred_lifecycle.c |
cred_transfer | pkm_kacs_cred_transfer | kacs/cred_lifecycle.c |
cred_alloc_blank | pkm_kacs_cred_alloc_blank | kacs/cred_lifecycle.c |
cred_free | pkm_kacs_cred_free | kacs/cred_lifecycle.c |
sb_alloc_security | pkm_kacs_sb_alloc_security | kacs/object_lifecycle.c |
sb_free_security | pkm_kacs_sb_free_security | kacs/object_lifecycle.c |
sb_kern_mount | pkm_kacs_sb_kern_mount | kacs/mnt_namespace.c |
inode_alloc_security | pkm_kacs_inode_alloc_security | kacs/object_lifecycle.c |
inode_free_security_rcu | pkm_kacs_inode_free_security_rcu | kacs/object_lifecycle.c |
inode_getattr | pkm_kacs_inode_getattr | kacs/file_metadata.c |
path_notify | pkm_kacs_path_notify | kacs/namespace.c |
inode_setattr | pkm_kacs_inode_setattr | kacs/file_metadata.c |
inode_file_getattr | pkm_kacs_inode_file_getattr | kacs/file_metadata.c |
inode_file_setattr | pkm_kacs_inode_file_setattr | kacs/file_metadata.c |
inode_xattr_skipcap | pkm_kacs_inode_xattr_skipcap | kacs/file_metadata.c |
inode_getxattr | pkm_kacs_inode_getxattr | kacs/file_metadata.c |
inode_setxattr | pkm_kacs_inode_setxattr | kacs/file_metadata.c |
inode_post_setxattr | pkm_kacs_inode_post_setxattr | kacs/file_metadata.c |
inode_removexattr | pkm_kacs_inode_removexattr | kacs/file_metadata.c |
inode_listxattr | pkm_kacs_inode_listxattr | kacs/file_metadata.c |
inode_copy_up | pkm_kacs_inode_copy_up | kacs/file_metadata.c |
inode_copy_up_xattr | pkm_kacs_inode_copy_up_xattr | kacs/file_metadata.c |
inode_follow_link | pkm_kacs_inode_follow_link | kacs/file_metadata.c |
inode_set_acl | pkm_kacs_inode_set_acl | kacs/file_metadata.c |
inode_remove_acl | pkm_kacs_inode_remove_acl | kacs/file_metadata.c |
inode_getsecurity | pkm_kacs_inode_getsecurity | kacs/file_metadata.c |
inode_permission | pkm_kacs_inode_permission | kacs/namespace.c |
inode_create | pkm_kacs_inode_create | kacs/namespace.c |
inode_link | pkm_kacs_inode_link | kacs/namespace.c |
inode_unlink | pkm_kacs_inode_unlink | kacs/namespace.c |
inode_symlink | pkm_kacs_inode_symlink | kacs/namespace.c |
inode_mkdir | pkm_kacs_inode_mkdir | kacs/namespace.c |
inode_rmdir | pkm_kacs_inode_rmdir | kacs/namespace.c |
inode_mknod | pkm_kacs_inode_mknod | kacs/namespace.c |
inode_rename | pkm_kacs_inode_rename | kacs/namespace.c |
inode_readlink | pkm_kacs_inode_readlink | kacs/namespace.c |
inode_init_security | pkm_kacs_inode_init_security | kacs/namespace.c |
inode_post_create_tmpfile | pkm_kacs_copy_up_post_create_tmpfile | kacs/copy_up.c |
dentry_create_files_as | pkm_kacs_dentry_create_files_as | kacs/namespace.c |
file_alloc_security | pkm_kacs_file_alloc_security | kacs/object_lifecycle.c |
backing_file_alloc | pkm_kacs_backing_file_alloc | kacs/object_lifecycle.c |
file_release | pkm_kacs_file_release | kacs/object_lifecycle.c |
file_open | pkm_kacs_file_open | kacs/file_access.c |
file_receive | pkm_kacs_file_receive | kacs/object_lifecycle.c |
file_permission | pkm_kacs_file_permission | kacs/file_access.c |
file_ioctl | pkm_kacs_file_ioctl | kacs/file_access.c |
file_ioctl_compat | pkm_kacs_file_ioctl_compat | kacs/file_access.c |
file_lock | pkm_kacs_file_lock | kacs/file_access.c |
file_fcntl | pkm_kacs_file_fcntl | kacs/file_access.c |
file_truncate | pkm_kacs_file_truncate | kacs/file_access.c |
kernel_read_file | pkm_kacs_kernel_read_file | kacs/firmware.c |
kernel_post_read_file | pkm_kacs_kernel_post_read_file | kacs/firmware.c |
task_alloc | pkm_kacs_task_alloc | kacs/task_lifecycle.c |
task_free | pkm_kacs_task_free | kacs/task_lifecycle.c |
sk_alloc_security | pkm_kacs_sk_alloc_security | kacs/socket.c |
sk_free_security | pkm_kacs_sk_free_security | kacs/socket.c |
sk_clone_security | pkm_kacs_sk_clone_security | kacs/socket.c |
socket_post_create | pkm_kacs_socket_post_create | kacs/socket.c |
socket_bind | pkm_kacs_socket_bind | kacs/socket.c |
socket_connect | pkm_kacs_socket_connect | kacs/socket.c |
unix_stream_connect | pkm_kacs_unix_stream_connect | kacs/socket.c |
unix_may_send | pkm_kacs_unix_may_send | kacs/socket.c |
socket_listen | pkm_kacs_socket_listen | kacs/socket.c |
ipc_permission | pkm_kacs_ipc_permission | kacs/ipc.c |
msg_queue_alloc_security | pkm_kacs_ipc_alloc_security | kacs/ipc.c |
msg_queue_free_security | pkm_kacs_ipc_free_security | kacs/ipc.c |
msg_queue_msgctl | pkm_kacs_msg_queue_msgctl | kacs/ipc.c |
shm_alloc_security | pkm_kacs_ipc_alloc_security | kacs/ipc.c |
shm_free_security | pkm_kacs_ipc_free_security | kacs/ipc.c |
shm_shmctl | pkm_kacs_shm_shmctl | kacs/ipc.c |
sem_alloc_security | pkm_kacs_ipc_alloc_security | kacs/ipc.c |
sem_free_security | pkm_kacs_ipc_free_security | kacs/ipc.c |
sem_semctl | pkm_kacs_sem_semctl | kacs/ipc.c |
task_kill | pkm_kacs_task_kill | kacs/process_access.c |
ptrace_access_check | pkm_kacs_ptrace_access_check | kacs/process_access.c |
ptrace_traceme | pkm_kacs_ptrace_traceme | kacs/process_access.c |
task_setnice | pkm_kacs_task_setnice | kacs/process_access.c |
task_setscheduler | pkm_kacs_task_setscheduler | kacs/process_access.c |
task_setioprio | pkm_kacs_task_setioprio | kacs/process_access.c |
task_setpgid | pkm_kacs_task_setpgid | kacs/process_access.c |
task_getpgid | pkm_kacs_task_getpgid | kacs/process_access.c |
task_getsid | pkm_kacs_task_getsid | kacs/process_access.c |
task_getscheduler | pkm_kacs_task_getscheduler | kacs/process_access.c |
task_getioprio | pkm_kacs_task_getioprio | kacs/process_access.c |
task_movememory | pkm_kacs_task_movememory | kacs/process_access.c |
task_fix_setuid | pkm_kacs_task_fix_setuid | kacs/cred_projection.c |
task_fix_setgid | pkm_kacs_task_fix_setgid | kacs/cred_projection.c |
task_fix_setgroups | pkm_kacs_task_fix_setgroups | kacs/cred_projection.c |
task_prlimit | pkm_kacs_task_prlimit | kacs/process_access.c |
capable | pkm_kacs_capable | kacs/capability.c |
capset | pkm_kacs_capset | kacs/capability.c |
task_prctl | pkm_kacs_task_prctl | kacs/psb.c |
mmap_file | pkm_kacs_mmap_file | kacs/psb.c |
mmap_backing_file | pkm_kacs_mmap_backing_file | kacs/object_lifecycle.c |
file_mprotect | pkm_kacs_file_mprotect | kacs/psb.c |
bprm_check_security | pkm_kacs_bprm_check_security | kacs/exec.c |
bprm_creds_from_file | pkm_kacs_bprm_creds_from_file | kacs/exec.c |
bprm_committing_creds | pkm_kacs_bprm_committing_creds | kacs/exec.c |
bprm_committed_creds | pkm_kacs_bprm_committed_creds | kacs/exec.c |
3.E.3 Direct file-metadata entry points #
An LSM hook receives the arguments supplied by the Linux hook API. Some
file-descriptor operations also need the particular open file's cached
FACS grant. PKM's patch series therefore calls KACS directly at the
file-descriptor call site, before the later inode-level work. These
functions are declared and called directly under CONFIG_SECURITY_PKM;
they are not additional entries in pkm_hooks[] and are not dispatched
through another LSM's hook table.
| File-descriptor operation | Direct KACS entry point | Patch in kernel/patches/fs/ |
|---|---|---|
| Change mode | pkm_kacs_file_chmod | open-fchmod-fchown-gate.patch |
| Change owner/group | pkm_kacs_file_chown | open-fchmod-fchown-gate.patch |
| Change timestamps through a file descriptor | pkm_kacs_file_utimens | utimes-gate.patch |
| Read file attributes through a file descriptor | pkm_kacs_file_getattr | stat-getattr-gate.patch |
| Read an xattr through a file descriptor | pkm_kacs_file_sd_xattr_get | xattr-raw-sd-guard.patch |
| Set an xattr through a file descriptor | pkm_kacs_file_sd_xattr_set | xattr-raw-sd-guard.patch |
| Remove an xattr through a file descriptor | pkm_kacs_file_sd_xattr_remove | xattr-raw-sd-guard.patch |
All seven implementations are in kacs/file_metadata.c. The xattr
functions enforce the file's metadata grant as well as the separate
rules for protected SD attributes; their names do not make raw SD-xattr
access a supported security-descriptor API.
The names security_file_setattr, security_file_getattr,
security_file_getxattr and security_file_setxattr do not name hooks
provided by this kernel. In particular, do not confuse them with the
real inode_file_getattr and inode_file_setattr registrations in the
matrix: those receive a dentry and struct file_kattr, not the open file
whose cached grant the direct entry points consult. The metadata decision
record connects the file-level check to the later inode-level operation.
This table covers those file-metadata entry points, not every direct
KACS call in PKM's patch series.
3.E.4 Reserved access-mask bits #
RESERVED_ACCESS_MASK_BITS in
crates/kacs-core/src/access_mask.rs is 0x0ce0_0000 (bits 21, 22, 23,
26 and 27, counting from zero). It remains a private evaluator constant,
not a new UAPI definition. §3.8.10 already specifies the public behavior:
reserved bits in a desired mask or any ACE mask reject the check, rather
than being ignored. This inventory records the implementation value
without adding another exported spelling.
4.1 Overview
Peios / Peios technical reference / 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 / Peios technical reference / 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 / Peios technical reference / 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.
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.
4.2.2.3.1 Locking and synchronising a read-only mount #
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: a
remount supplying a strata= whose value differs from the mounted
one's is refused with EINVAL.
The test is on the value, not on the presence of the parameter, so a
remount that replays the current stack byte-for-byte is accepted — it
adds, removes, reorders and re-flags nothing. That is what makes
option-string replay work: a tool reconstructing the whole option
string from the mount table and handing it back is doing exactly this,
and flipping a stratafs mount read-only would otherwise fail on a
strata= that changed nothing.
The comparison is of the stored option string (§4.2.2, below), which is why that string being reported without a second escaping layer is what lets a replayed stack compare equal at all.
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 / Peios technical reference / 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, and stratafs is on
the allowlist of types a mount namespace's descriptor may admit
(§3.13), so a caller may establish one in a mount namespace it created
without the mount privilege; what the caller needs beyond that is only
the access that resolving the stratum paths already requires, which
falls out of the resolution itself. A user namespace by itself confers
nothing here, as everywhere under KACS.
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 #
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.
The path walk and the attribute read both run under the caller's
credentials, so a path the caller cannot resolve returns EACCES from
the walk itself, and the EACCES is propagated unchanged.
The check is stack-wide because it runs in two passes. The first
resolves and stats every stratum; an EACCES from any of them is the
answer, whatever an earlier stratum reported. Only when every stratum
has passed entitlement does the second pass apply the type, duplicate
and depth conditions. A caller who names a readable non-directory first
and an unreadable path second is told EACCES, not ENOTDIR.
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).
4.2.3.6 An all-absent stack #
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.
Such a root still carries a security descriptor, since an object with none would refuse every access check made on the mount root and the provider-less root could never be observed. stratafs synthesises it: owned by the mounter, with read and traverse for everyone, and nothing more — there is nothing beneath such a root to write. The moment a stratum root appears the root's descriptor is that root's own, and when it goes again the synthesised one returns; KACS never caches a StrataFS root's descriptor, so no access is decided against a root that has since gained or lost its provider.
4.2.4 Absent Strata
Peios / Peios technical reference / 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.
Provider identity does survive the gap, though it is not resolution memory. The derived inode number (§4.4.3) identifies the provider's filesystem and inode number. If the same underlying object is reached again — because the directory was renamed away and back rather than replaced — it receives the same number it had before, without a mount-lifetime pin on ordinary provider inodes. Only identities that cannot be derived use the pinned fallback map. That is exactly what the identity rule requires: equal numbers for one provider object, whatever path reached it.
4.3.1 Lookup
Peios / Peios technical reference / 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.
4.3.1.5.1 Recovery triggered by lookup #
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 / Peios technical reference / 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.
4.3.2.1.1 Participation at the mount root #
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. Every site that builds the participant set resolves the same way —
the set itself, the emptiness scan, the foreign-entry scan, the
permission check and directory fsync.
A symlink is a non-directory, so a stratum holding a name as one is masked entirely, exactly as §4.3.3 has it: it does not participate, contributes no entries to the merged listing or to emptiness tests, and masks whatever the strata below hold at that name. A dangling symlink is the same, and for the same reason.
The two views agreeing is what closes a confused-deputy shape. Were the participant set to follow the final component while lookup did not, a stratum owner who replaced a directory with a symlink would change which real directory contributed entries to another stratum's merged view, and the permission check would run against a target the mounter never named.
4.3.3 Type Conflicts
Peios / Peios technical reference / 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.
4.3.3.1.1 Masking leaves the strata unchanged #
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 / Peios technical reference / 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 deriving
its identity, with allocation only as a fallback (§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.
The implementation uses a temporary, per-open name hash while capturing entries, then an immutable position index for enumeration. Growing the hash does not reorder entries or change which provider wins a duplicate. The hash buckets are released before the open succeeds. Capture remains eager under the opener's authority; the index does not defer any part of that authorisation to a later reader or descriptor recipient.
Deduplication takes expected linear work in the number of names offered by the participants. Resuming enumeration or seeking to an ordinal does not scan earlier entries again. Provider enumeration, child lookup and access checks still contribute their own costs. Each independent open retains its own names and position index, so memory use still grows with the number of distinct captured names and simultaneously open file descriptions. These are implementation properties, not time or memory limits promised to callers.
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 / Peios technical reference / 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 / Peios technical reference / 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 internal filesystem-domain, fallback-inode and staging-name counters do not validate cached resolutions.
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.
The refusal, invisible to the caller by design, is visible at two
tracepoints. stratafs:stratafs_rcu_walk_refused marks the directory
permission check turning a walk back under MAY_NOT_BLOCK — in
practice the first stratafs directory a walk must traverse does this,
so it is the point that actually takes every walk out of RCU mode.
stratafs:stratafs_d_revalidate records each revalidation verdict —
mount cookie, merged inode number (zero for a negative entry), whether
the call was in RCU mode, and the return value — and because the
permission check fires first, every recorded call arrives already in
ref-walk mode; its -ECHILD branch is the backstop. Like every PKM
tracepoint, neither records a pathname.
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 / Peios technical reference / 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.
The inode number is derived from the provider filesystem's reported inode number and a small mount-local identifier for its reported device. The device identifier distinguishes filesystem domains, not stratum positions: a stratum can cross mount points. The reported device also distinguishes Btrfs subvolumes, which can share a kernel superblock and inode numbers. Two paths to the same provider identity, including hard links and bind-mounted aliases, therefore report the same number.
Derivation reserves the high bit for allocated fallback numbers. A self-delimiting tag in the low bits identifies the filesystem domain; the provider inode number occupies the remaining bits. Small provider numbers stay small enough to derive again through nested stratafs views. Neither the bit layout nor the mount-local domain assignment is a persistent identifier or an ABI that callers should decode. Numbers may differ between mounts; within a mount, reloading the same provider identity after cache eviction produces the same number.
Only identities that cannot fit, or providers that do not report a
usable inode number, use the fallback map. It allocates a distinct
number and holds a reference on the provider inode until unmount to
prevent address reuse. Repeated encounters with that provider reuse its
allocation. Synthetic provider-less roots use the same allocated number
space without a provider reference. Exhaustion returns EOVERFLOW
rather than wrapping, including on 32-bit builds. An attribute lookup
error is returned to the caller instead of silently assigning a new
identity.
4.4.3.1.1 The mount root #
The root inode is created once, when the superblock is filled, and the
root dentry is never replaced — d_revalidate returns 1 for it. Its
provider is nevertheless re-resolved live on every use, and the number
follows: resolving the root's provider derives or looks up its identity
and writes the result to the root inode's number.
Mode, owner, timestamps and number are therefore all current.
That the number follows is what keeps §4.4.3's identity rule true at the root. Identity belongs to the provider, so the number the root reports is the number every other merged path to that same provider reports; without it, a root whose provider is also reachable elsewhere would report a different number from that other path — two paths naming one object, unequal. That is the false-inequality direction, the one that breaks hard-link detection.
Where no stratum root exists at mount time, the root receives a stable synthetic number, distinct from derived provider numbers. If a provider later appears, the root's number follows that provider.
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.
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.2.1 The synthetic link count #
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.
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 assigned to the
pre-copy-up provider, while a fresh resolution of the path derives or
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. Ordinary derived identities add no provider reference and no per-inode entry to the identity map. Walking a persistent-backed view and dropping caches can therefore reclaim the provider inodes while the stratafs mount remains live. The per-mount device-domain table stays until unmount; its size tracks encountered filesystem domains rather than the number of files walked.
The fallback alone adds a provider reference held until unmount. That cost applies only to identities that could not be derived.
Renaming a provider out of the view and back, or evicting and reloading its cached inode, preserves its derived identity. Ordinary inode-number reuse by the provider after deletion remains possible, just as it is when accessing that filesystem directly.
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 / Peios technical reference / 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.
4.5.1.1.1 Independence from the caller’s rights #
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
selects in-place modification, copy-up, or read-only through the following
ordered checks.
- 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.
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 / Peios technical reference / 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 #
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 every type, symbolic links included. The mode is carried across too, except for a symbolic link, whose mode is fixed by the VFS and not the caller's to set. Access and change times are not preserved for any type.
Ownership is preserved. The staged object is created with the
calling task's credentials, so it begins owned by the caller; where
that differs from the source's uid or gid, a second notify_change
sets both to the source's. The KACS security descriptor, including
its owner SID, is preserved as well (§4.6.3), so both notions of owner
name the object's owner rather than whoever provoked the copy.
The chown runs under the mount's own credential — the same resolution context stratafs uses to reach providers the caller cannot (§4.2.1) — because setting another uid needs authority the caller does not have. It is a separate change from the mode and timestamp above, which are the caller's to set and stay that way.
Because disk quota keys on the POSIX owner, a copy is accounted to the owner of the object it was copied from and not to the caller. That is what makes §4.6.2's exemption sound: a caller who may 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 it, gaining neither access nor space.
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 before it scans the create stratum's participating directory, after the caller's traverse/list access has been checked.
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 block rmdir: a dead mount's stages are not
in the current mount's in-flight list and would be counted as ordinary
entries. Recovery before that scan reaches the affected directory
without recursively visiting every directory at mount time. It also
means the create directory is scanned for recovery when its merged
emptiness is tested, even if it ultimately proves nonempty.
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.
4.5.2.5 Failed copy-up and rollback #
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.6 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.
4.5.2.6.1 Independence after publication #
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 / Peios technical reference / 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 requires both removal and creation authority. This
does not prescribe their publication order. 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.
4.5.3.2.1 Implementation restrictions #
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.
-
Across distinct strata the two halves are not atomic. The staged file is renamed into place in the create stratum before the old provider is unlinked. Between those operations both backing names exist: ordinary precedence selects the replacement if the create stratum is higher, or retains the original until its removal if it is higher. A failure to publish leaves the original untouched, rather than exposing a still-lower provider.
Write access to the original provider is probed before publication, then released while the replacement is published. It is reacquired for removal; a provider remounted read-only in between therefore fails with
EROFSafter publication. The two backing mounts never have simultaneous freeze-protection holds, even when they share a superblock.A later removal failure is returned to the caller and recorded as
supersede-removal. Native-open cleanup then attempts to remove the newly published object, following its final name rather than its old temporary name. If the original still has its name, successful cleanup restores its visibility; concurrent provider changes are preserved. Cleanup is itself a filesystem operation and can fail; in that case both entries can remain, with ordinary precedence deciding which is visible. A crash between publication and removal can likewise leave both. This ordering narrows the destructive failure window; it does not promise a transaction or crash recovery.When both objects are in the create stratum, replacement uses one backing-filesystem rename and needs no separate provider unlink.
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.
4.5.3.3.1 An entry already gone #
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.
The merged parent can disappear or become a non-directory before the
VFS reaches stratafs’s unlink handler. An ENOENT or ENOTDIR from
that earlier check is also a successful no-op, but only after a lookup
in the descriptor’s original providing stratum confirms that its
recorded name is absent or identifies a different inode. A higher
stratum masking a still-live backing target does not satisfy that
condition: the deletion still fails and is audited. Other errors keep
their ordinary failure and audit behavior.
4.5.3.3.2 The original stratum must accept removal #
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.
4.5.3.3.3 Failures are audited #
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.
4.5.3.3.4 Authority at deletion time #
The right to delete the entry is checked against the directory of the stratum where the entry lives, not against the merged parent, and it is checked at deletion time rather than only at arm.
Arm time could not do it. The authorizer there holds a struct file,
and which stratum provides the entry is stratafs-internal — the
provider index and the parent's relative path are not visible to KACS.
stratafs computes that directory before removing anything, so the check
happens there, where the answer exists.
4.5.3.3.5 The requesting token outlives the arm #
The token is the one that requested the deferred deletion, not the
credentials of whoever closes the descriptor. That requires it to
outlive the arm: the file's blob takes a reference when armed and
releases it in ->file_release, on both the armed and unarmed exits.
The file is the thing that was armed, so its lifetime is the right
one — arm and close may be far apart and in different tasks.
4.5.3.3.6 Supported object types #
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 / Peios technical reference / 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.
4.5.4.2.1 Access checks precede the emptiness scan #
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 this mount's in-flight staging entries, as
enumeration does. That does not authorise removing a live stage: the
provider can still refuse the physical removal with ENOTEMPTY.
4.5.4.2.2 Orphan recovery before inspection #
After the traverse/list checks complete, the emptiness test runs orphan recovery on the create stratum's participating directory before enumerating its contents (§4.5.2). A dead mount's entries are not in the current mount's in-flight list, so waiting until that list has filtered them would reject the directory before recovery could run. A directory containing only recoverable orphan files or empty orphan directories can therefore be removed without first opening or listing it.
Recovery requires the staging prefix, a valid marker, and an owner that
is no longer live. Missing or malformed markers, ordinary published
names, and stages belonging to a live mount remain untouched; they can
still make removal fail with ENOTEMPTY. The scan is local to the
participating create directory, not a recursive walk of the stratum.
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 / Peios technical reference / 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.
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.
That refusal is per attempt, and the VFS heals it: ESTALE from a
rename makes the syscall retry once with a fresh walk, whose rebuilt
dentry carries the current provider identity, so the caller usually
observes the retry's outcome — a rename of the object now at the name —
rather than the errno. The refusal itself is recorded at the
stratafs:stratafs_rename_stale tracepoint, with the walk-time and
found inode numbers.
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 / Peios technical reference / 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.
4.5.6.2.1 Unnamed files cannot cross mounts #
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 / Peios technical reference / 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.
4.5.7.1.1 Locking does not copy up #
Taking a lock does not modify an object, so it does not route (§4.5.1)
and cannot itself cause a copy-up. FACS still checks the descriptor's
granted rights (§3.9.4): a shared lock requires FILE_READ_DATA, and an
exclusive lock requires FILE_WRITE_DATA or FILE_APPEND_DATA. A
read-only descriptor can therefore carry a shared lock, but not an
exclusive lock without a write grant.
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.
Backing-filesystem freeze protection is held for one mutation stage at a time. Regular-file copy-up releases its creation-stage mount write hold while copying contents: reads and writes keep their ordinary permission and pre-content notification checks, and each write acquires its own freeze protection. It reacquires the mount write hold before copying metadata or publishing the anonymous stage; failure to reacquire aborts the copy without publishing it. This permits a pending filesystem freeze to complete between stages without a writer waiting on itself.
Supersede releases the create mount's write hold before acquiring the original provider's hold for removal. Rename's merged-destination emptiness scan, which can recover orphan staging objects, runs without a backing write hold retained by rename; recovery acquires its own. An early write-access probe preserves the read-only refusal, and the mutation reacquires write access before taking its backing directory locks.
4.5.8 Durability and Accounting
Peios / Peios technical reference / 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 Storage 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.
4.5.8.4.1 The principal charged by quota #
Disk quota keys on the POSIX owner, and copy-up preserves it (§4.5.2): the staged object is created with the calling task's credentials, and the ownership is then set to the source's under the mount's own credential. A copy is therefore accounted to the owner of the object it was copied from, not to the caller who caused it.
The KACS security descriptor, including its owner SID (§4.6.3), is preserved alongside it. The two notions of owner agree, and both name the object rather than whoever provoked the copy — which is what §4.6.2 relies on when it permits a caller to cause a copy into a directory they hold no rights over.
Ownership is what accounting follows; the caller who caused the copy is recorded in the audit record of §4.6.5 instead.
4.6.1 Access Check Delegation
Peios / Peios technical reference / 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.
For merged-directory checks, stratafs reads each participant's descriptor attribute on every check. KACS also refreshes an outer stratafs inode's parsed descriptor whenever a check or descriptor query needs it. A writer using the stratum directly therefore needs no invalidation protocol with the merged view. Missing and corrupt descriptors are re-read too, so a subsequent repair can take effect.
This refresh applies to retained handles as well as new pathname operations.
An O_PATH handle performs its live access check against the refreshed
descriptor. An ordinary file handle retains its granted access, but a
permitted descriptor query returns the provider's current descriptor, not
the one present when the file opened.
When copy-up rebinds a file's inode to the copy (§4.4.3), stratafs explicitly invalidates the parsed descriptor. Subsequent descriptor queries follow the new provider. The file's frozen grant remains unchanged; invalidating the inode cache does not authorise or revoke access on an already-open file.
4.6.2 Checks on Merged Directories
Peios / Peios technical reference / 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.
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 neither access nor space by it: §4.5.8 records that the copy is accounted to the preserved owner rather than to them.
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.
4.6.2.2.1 Recovery retains the mounter’s file credentials #
The stale-staging recovery scan passes the mounter's credentials to the internal directory open, which retains them as the file’s credentials. That does not switch the calling task’s current credentials or select a different KACS token. The scan runs in a source-read copy-up context bound to that exact directory, so the context admits its read before an ordinary token access check.
Consequently, neither the caller nor the mounter needs a directory-list grant for that scoped recovery read. An ordinary open requesting list access remains subject to the directory’s descriptor. Path resolution separately uses the captured mounter credentials; the file-credential argument is not itself an authority switch.
4.6.3 Descriptors on Copy-Up
Peios / Peios technical reference / 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.
4.6.3.1.1 Descriptors of materialised parents #
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.
4.6.3.2.1 Protection precedes publication #
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 preserved separately and for a different reason — §4.5.2 covers the chown and §4.5.8 what it means for accounting.
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 / Peios technical reference / 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.
4.6.4.1 Policy changes are refused before validation #
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.2 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.
4.6.4.2.1 Corrupt is not missing #
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.3 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 / Peios technical reference / 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 stratafs.file.copied-up record, successful or
not. Its payload carries these fields, each a path of nested maps:
| Field | |
|---|---|
object.file.path-relative | The relative path within the mount, /-prefixed |
source.stratum.index | The stratum the object was copied from |
source.stratum.path | That stratum's path |
destination.stratum.index | The stratum it was copied into |
destination.stratum.path | That stratum's path |
outcome.success | Whether the copy-up succeeded |
outcome.errno | The failure, as a negative errno; absent on success |
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 fields.
The ENOTDIR of parent materialisation is reported through this event
in its outcome.errno rather than through the refusal event below;
the required fields are all present, under a different event type.
4.6.5.2 Refused mutation #
A mutation refused because of how the mount is arranged emits a
stratafs.mutation.refused record carrying the relative path
(object.file.path-relative), the operation (operation.name), the
providing stratum (source.stratum.index and source.stratum.path),
the error (outcome.errno, negative) and whether the refusal was
deferred (outcome.deferred). The source.stratum fields are present
only where a provider is known; 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. Its record has no
source map at all: source.stratum.index and source.stratum.path
are both absent, never -1, nil or an empty string, so a reader can
tell "no provider was involved" from "the provider's path is empty".
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.
The record is stratafs's whether or not stratafs was entered. The
delete-child check on the merged parent runs before ->unlink, so a
directory whose descriptor tightened between the arm and the close
refuses the deletion before the filesystem sees it; KACS then raises
the stratafs record itself, with no source.stratum fields, since no
stratum was consulted.
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 / Peios technical reference / 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 / Peios technical reference / 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 |
A remount's strata= differs from the mounted stack (§4.2.2) | 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 / Peios technical reference / 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_FS_TEST_HOOKS compiles the test rendezvous and fail points (§4.A.2),
inert unless the kernel is booted with stratafs.test_hooks=1. CONFIG_STRATAFS_KUNIT_TEST builds the in-kernel unit tests.
The translation units are:
super.olookup.oinode.ofile.odir.odir_cache.oxattr.ocopy_up.o
Appendix 4.B Test hooks
Peios / Peios technical reference / PKM / stratafs
Several of this chapter's promises are about windows that no
arrangement of real filesystems can hold open. A copy-up is never
observable in a partial state (§4.5.2) — but a real copy-up publishes
within microseconds. A failed install rolls the lower link back
(§4.5.6) — but the install fails only under allocation pressure between
two steps of one syscall. A dentry whose provider identity no longer
matches yields ESTALE (§4.5.5) — but every walk rebuilds the dentry
moments before the operation, so the mismatch needs the provider to
change inside a single syscall.
The test hooks exist so conformance tests can steer exactly those windows and nothing else. They are not conformance surface: no behaviour in this chapter depends on them, and they carry no named citations.
4.B.1 Gating #
The hooks are doubly gated:
CONFIG_STRATAFS_FS_TEST_HOOKScompiles them in. Without it every hook site is an inline constant zero.stratafs.test_hooks=1on the kernel command line registers them. Without it nothing appears in securityfs and each hook site reduces to reading one never-true flag.
Production images build the option but never pass the parameter; only the provium test profiles do. Arming a hook additionally requires reaching a mounted securityfs, which on a managed system means a caller able to establish the mount and satisfy its policy class.
4.B.2 Interface #
Each hook point is one securityfs file under stratafs/hooks/,
alongside KACS's own kacs/ endpoints. securityfs rather than debugfs
is forced by kernel lockdown: the Peios kernel builds
LOCK_DOWN_KERNEL_FORCE_INTEGRITY, and the integrity set refuses every
debugfs open that is not a read of a mode-0444 file — a debugfs hook
would be visible and permanently untouchable. Lockdown does not gate
securityfs. Writing a hook's file arms or disarms it; reading reports
its state:
| Write | Effect |
|---|---|
hold | Tasks reaching the point block until the hook is cleared |
fail N | The next task to reach the point fails with errno N, once |
clear | Disarm; every held task is released |
A read returns <mode> waiting=<W> hits=<H>, where <mode> is none,
hold or fail, W is the number of tasks currently blocked — poll
it to know the victim has arrived — and H is the number of arrivals
the hook has affected. A held task waits killably, so a fatal signal
always removes it. A fail arms exactly one failure and disarms
itself; hold persists until cleared.
4.B.3 Points #
| File | Moment |
|---|---|
copy-up-begin | Before the copy-up context is created — before any staging exists. A failure here is a source descriptor that could not be read (§4.6.3). |
copy-up-publish | After the staged object is complete — content, attributes, metadata — and before publication. A hold here is an in-flight copy-up, staged but unpublished (§4.5.2). |
rename-provider | In rename, after the walk-time provider is captured and before the locked re-lookup that verifies it (§4.5.5). |
link-install | In link, between the lower link succeeding and the outer inode installing. A failure here exercises the rollback of §4.5.6. |
supersede-publish | After the staged source and destination are checked, immediately before the backing rename publishes a supersede replacement. Failure leaves the original provider untouched. |
supersede-remove | For a supersede across distinct strata, after publication and release of the create-stratum directory lock and write hold, before reacquiring write access to the original provider and re-looking-up its name. Failure exercises cleanup of the published replacement. |
staging-recover | After a staging marker is validated as an orphan, before acquiring backing-mount write access for its removal. A hold lets a test remount the provider read-only while rename is scanning an overwritten directory; failure leaves the orphan in place. |
copy-up-begin and copy-up-publish sit on both copy-up shapes — the
anonymous regular-file stage and the named directory/symlink stage —
so a hold at copy-up-publish pins whichever kind is in flight.
5.1 Overview
Peios / Peios technical reference / 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 / Peios technical reference / 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.
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 / Peios technical reference / 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; a token above the cap fails every registry operation with E2BIG
(§5.3.5). 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, and:
- rejects a nil scope GUID;
- rejects duplicate scope GUIDs;
- 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 — the full list is §5.8.2, and the rules that exist for private hives are these:
- A hive's root GUID may not be nil.
- 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. - A hive with the flag may not carry a nil one. No token
can carry a nil scope GUID, so such a hive would be registrable and
then permanently unroutable — holding its name against other sources
in that scope while every lookup returned
ENOENT. - Unknown flag bits are rejected.
- Opening
/dev/pkm_registryat all requires an enabledSeTcbPrivilege, checked in the device'sopen()handler (§5.8.2).
5.2.3 Keys
Peios / Peios technical reference / 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).
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 version-4 GUID generator: random bytes
from the kernel CSPRNG with the version and variant bits set in the
PCDS layout — the version nibble in the high nibble of byte 7, because
Data3 is little-endian in bytes 6–7, and the variant bits in byte 8.
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 / Peios technical reference / 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.
Resolution has 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.
Writing one is different. REG_IOC_SET_VALUE of a REG_LINK value
routes the target's first component at write time, with the writer's
scope GUIDs, and answers EINVAL if it names no registered hive. That
is what stops a dangling target being captured later by whoever
registers a private hive of that name: a target that cannot be written
cannot be captured, and the failure lands on whoever authored it. The
cost is that a symlink cannot forward-reference a hive registered after
it. A target that arrives in source data or a backup stream is not
gated, which is why resolution still has to treat the shape as a hive
request.
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.
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.
5.2.5 Path Entries
Peios / Peios technical reference / 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.
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: the key record's parent must
be the parent the key was reached through, and its name must fold to the
component the key was reached by. A record that says otherwise is
malformed key metadata — REG_IOC_QUERY_KEY_INFO answers
EIO and the source-data validation failure is audited.
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 / Peios technical reference / PKM / LCS / The Data Model
A value is a named, typed datum inside a key. Values hold the configuration data; keys hold values.
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.
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.6.6 Tool value encodings #
reg, Registry Editor and the peios Rust crate use these representations:
REG_SZandREG_EXPAND_SZ: UTF-8 ending in one null byte, rather than Windows' UTF-16 representation.REG_LINK: the absolute target path in UTF-8, with no null byte. Its length determines its end; a target containing a null is not followed.REG_MULTI_SZ: each string followed by a null byte, with one additional null byte ending the list.- Integers: little-endian, except for
REG_DWORD_BIG_ENDIAN.
These are data conventions shared by tools. LCS still treats ordinary
value payloads as opaque bytes; its special handling of a link key's
REG_LINK default value is described in §5.2.4. For choosing and entering
a type as an operator, see
Keys, values, and types.
5.2.7 Tombstones
Peios / Peios technical reference / 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.6).
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 / Peios technical reference / 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 rejection is EINVAL where the string is length-delimited — ioctl
and RSI strings — because there the null byte sits inside the counted
length. A syscall argument (a path or reg_create_key's layer name) is
a C string, and a null byte there is its terminator: the name the
kernel validates ends at it, and nothing after it is read.
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.
Every comparison folds, on both sides of the kernel boundary. The
Kernel Access Control Subsystem (KACS) parses private layer names into
a token and rejects a duplicate with EINVAL; that duplicate check
uses the same folding routine and the same table as LCS, so a pair
that LCS treats as one layer cannot occupy two of a token's slots.
There were two ASCII-only comparators, the base check on two of its
call sites and this KACS check, and both are gone (§5.3.2 for
base).
5.2.9 Deletion and Orphans
Peios / Peios technical reference / 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, under the Registry Source Interface specification in PSPK, to purge records with no path entries before it becomes Active.
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 / Peios technical reference / 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.
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, the
base layer included: a table holding that many entries admits no more.
Creating a layer when it is full returns ENOSPC. The refusal happens
at the creation of the metadata key under Layers\, before anything
reaches the source, so a full table never leaves a metadata key behind
that the table cannot publish.
The table is sized to MaxTotalLayers rather than fixed at build. It
is allocated on the first publication and grows, under the table lock,
whenever a publication finds every slot taken while the configured
bound still admits one more; it never shrinks.
So a value anywhere in the configurable range, 16 to 65536, binds.
Raising MaxTotalLayers above the default admits layers past 1024;
lowering it below the current occupancy admits no new layer until
enough have been deleted, and discards nothing.
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 / Peios technical reference / 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.
It 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, though nothing in a
Peios system creates it, and where it does 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, for system initialisation, and for what a
person keeps under their own Users\<SID>\.
While the base layer's metadata key does not exist, LCS uses a
compiled-in default descriptor granting SYSTEM and Administrators
KEY_ALL_ACCESS and Authenticated Users KEY_SET_VALUE, so writes into
the base layer are possible from the very beginning, by anyone who has
authenticated, to whatever the target key's own descriptor lets them
write. The compiled-in default is replaced by the key's descriptor as
soon as the key is created (§5.3.4).
5.3.2.3 base is matched like every other layer name #
The reserved name is recognised with Unicode Simple Case Folding, the same algorithm and the same table used for every other layer-name comparison.
There was a second, ASCII case-insensitive comparator beside it, used on
two call sites. For the literal string base the two agreed — a length
pre-check made a non-ASCII case pair fail before folding could matter —
so the divergence was latent rather than absent, and would have become
live the moment anything about the reserved name changed. It is gone;
both call sites now take the folding comparator and propagate its error
rather than collapsing it into a bool.
5.3.3 Layer Metadata
Peios / Peios technical reference / 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 |
Three things are malformed metadata, and are 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.
The same isolation holds at bootstrap. The refresh that runs on source
registration visits every layer under Layers\ in turn, and one whose
metadata will not parse — the descriptor, or any of the three values —
is skipped, unpublished and counted, while its siblings publish as they
would have without it. Only a failure of the source itself, rather than
of one layer's data, fails the refresh.
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 / Peios technical reference / 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, where the key has
been created, is the one inheritance computes from the Machine hive
root, like any other key's.
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 base layer is the one layer meant to be written by everybody. It
is where a write goes that names no layer, a person's own settings
under Users\<SID>\ included, so its descriptor lets in anyone who has
authenticated and leaves what they may write to the descriptor of the
key they are writing. A layer check that refused them would make every
key read-only to everyone but SYSTEM and Administrators, whatever the
key's own descriptor said.
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 #
While Layers\base\ does not exist, LCS falls back to a compiled-in
default descriptor, so base-layer writes work from the start. It is
owned by SYSTEM and grants KEY_ALL_ACCESS to SYSTEM and
Administrators and KEY_SET_VALUE, the one right this check asks for,
to Authenticated Users. Nobody else is granted anything: a principal
that has not authenticated cannot write into the base layer, whatever
the target key allows.
Nothing in a Peios system creates Layers\base\, so on a system as
shipped the compiled-in default is the descriptor in force for as long
as the system runs.
It is replaced by the persisted descriptor the moment the key is created.
5.3.4.2 Layer lifecycle #
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 is a layer metadata key,
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. A key
is a layer metadata key by its path — a direct child of
Machine\System\Registry\Layers — or by already being in the set of
published layer metadata keys. The path test is what covers the
recommended creation flow (§5.3.3), where Precedence is written inside
the transaction that creates the metadata key, before the layer has
been published at all.
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 / Peios technical reference / 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.
After private-attachment authorization succeeds, 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. 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 deduplicates the private layer names it parses with the same Unicode Simple Case Folding LCS matches them by, so a name that LCS treats as one layer is one layer here too.
It did not always: an ASCII case-insensitive comparison let two names
differing only in a non-ASCII case relation — ROLEs and roles, since
U+017F folds to s — both sit on one token, spending two of the token's
MaxPrivateLayersPerToken slots on a single layer. Resolution still
folded correctly on the LCS side, so the layer activated once, but the
two sides of one identity contract disagreed.
5.3.5.4 Attachment authority #
Private-layer names can enter a token only at creation, which requires
SeCreateTokenPrivilege.
Attaching a known layer above precedence 0 additionally requires enabled
SeTcbPrivilege on the creator. Without it, creation fails with EPERM,
even when the requested child token itself includes TCB privilege. A present
but disabled privilege does not authorize attachment.
The kernel retains the creator's authorization as immutable token state. A trusted creator can delegate a private high-precedence view to an ordinary token without giving the consumer TCB privilege. Duplication, restriction, process propagation and impersonation preserve that attachment authority; they do not obtain new authority from the duplicating caller.
Layer names need not exist when attached, and precedence can change later.
For each operation acquiring private credentials, LCS checks the retained
authority against the same layer snapshot used for resolution. If an
attachment made without TCB authorization now names a layer above precedence
0, the operation fails with EPERM. It does not silently choose a lower
layer. This applies to queries through already-open key handles as well as
new opens; a later promotion or creation cannot upgrade the attachment.
Returning the layer to precedence 0 makes the original attachment usable
again. Ordinary key and layer access checks still apply.
KACS invokes an LCS-supplied validator before exposing a newly created token. The callback is installed during kernel initialization and then read-only; a private extension cannot be published without its validator. LCS owns the layer lookup, so KACS does not interpret the layer table. Use-time validation closes the race between the creation check and a concurrent metadata change. Successful TCB authorization marks the privilege used on the creator.
This is the PEI-268 behavior; older kernels lack the attachment check.
5.3.6 Resolution
Peios / Peios technical reference / 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.response.rejected audit event
(§5.4.4).
5.3.7 The Sequence Counter
Peios / Peios technical reference / 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 / Peios technical reference / 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. -
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 / Peios technical reference / 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.response.rejected 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 #
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 / Peios technical reference / 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. The child copy clears OBJECT_INHERIT_ACE,
CONTAINER_INHERIT_ACE and NO_PROPAGATE_INHERIT_ACE 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.1.1 Creator SIDs and generic rights survive to the next generation #
An inheritable ACE naming CREATOR OWNER or CREATOR GROUP, or carrying
generic rights, is resolved for each new key. When it also propagates to
further subkeys, the child receives two ACEs:
- An effective copy with the child's owner/group substituted and generic
rights mapped to registry rights. It has
INHERITED_ACEand no propagation flags. - An inherit-only copy retaining the original SID and generic mask, with
INHERITED_ACEand its propagation flags. This copy resolves afresh when the next subkey is created.
This applies to both DACL and SACL entries. An ordinary concrete ACE remains
one copy. NO_PROPAGATE_INHERIT_ACE ends the rule at the immediate child,
so it receives only the effective copy. Existing stored descriptors are not
rewritten by upgrading the kernel; an explicit propagation operation is
needed to repair an already-created subtree.
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.
| Principal | Rights | Inheritance |
|---|---|---|
| SYSTEM | KEY_ALL_ACCESS | Container-inherit |
| Administrators | KEY_ALL_ACCESS | Container-inherit |
| Authenticated Users | KEY_READ | Container-inherit |
| 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, or mandatory integrity label (LABEL_SECURITY_INFORMATION).
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, the DACL or the label needs READ_CONTROL; reading the SACL needs
ACCESS_SYSTEM_SECURITY. Setting owner or group needs
WRITE_OWNER, as does setting the label; 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.
Component selection includes its control flags even when the component
has no bytes. A null ACL remains PRESENT clear, but its defaulted,
inheritance and protection flags survive a query or an update to another
component. Replacing a selected DACL or SACL takes that ACL's flags from
the input descriptor, including for a null ACL; unselected component
flags are preserved. An empty ACL remains present with zero ACEs and is
not converted to null. The same preservation rule applies to a defaulted
null group.
LABEL_SECURITY_INFORMATION reads only the first effective mandatory-label
ACE from the SACL, omitting inherit-only labels and every non-label ACE.
If there is no effective label, the returned subset has no SACL. Reading
this subset needs READ_CONTROL; writing it needs WRITE_OWNER. Neither
operation needs ACCESS_SYSTEM_SECURITY or SeSecurityPrivilege.
A label update removes the existing mandatory-label ACEs and inserts the
supplied label before the remaining SACL entries. An input descriptor
with no SACL clears the explicit label. Non-label SACL entries, SACL
inheritance and protection flags, and all unselected descriptor components
are preserved, including flags on a null DACL. Clearing the last SACL
entry clears SE_SACL_PRESENT while retaining the other SACL flags.
For a label-only subset, a supplied SACL must contain exactly one
mandatory-label ACE with an S-1-16-<level> SID and without
INHERIT_ONLY_ACE; an empty ACL, an audit ACE, or multiple labels is
EINVAL. SACL_SECURITY_INFORMATION and LABEL_SECURITY_INFORMATION
cannot be combined. These input errors are rejected before source contact
or transaction enlistment.
An explicit label above the current caller's integrity level requires
enabled SeRelabelPrivilege, checked before source contact or transaction
enlistment; otherwise the update fails with EACCES. Labels at or below
the caller's level do not require that privilege. The same rule applies
to an explicit label inside a whole-SACL write: SeSecurityPrivilege
does not substitute for SeRelabelPrivilege. Existing fd rights remain
cached, but this privilege check uses the caller performing the update.
The result must still have an owner; a
merge that would leave the descriptor ownerless is EINVAL.
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 / Peios technical reference / PKM / LCS / Security
LCS emits audit events through KMES. Fifteen events exist.
A registry open that a privilege contributed to is also recorded, as KACS's
kacs.audit.privilege.used; see Privilege use on opens.
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.
Each field path is written as nested maps, one per segment, so
object.key.guid is {object: {key: {guid: …}}}. A value LCS does not
have is left out rather than written as nil, and every map counts only
the keys actually written (PGSS §6.4).
The key GUID, object.key.guid, is a 16-byte binary value, and the
caller's SID is a binary KACS encoding. The token and process GUIDs
are not payload fields at all: they ride in the KMES event header, so
the payload never repeats them.
5.4.4.1 The caller summary #
Every event except lcs.source.response.rejected and
lcs.config.value.rejected describes the effective token used for the
operation under subject.token. It has six fields and no more: sid,
integrity, id (the token's LUID), auth-id (its logon session's
LUID), type (primary or impersonation) and impersonation, the
impersonation 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. lcs.source.response.rejected and
lcs.config.value.rejected carry no caller: neither is a principal
acting.
A primary token reports an impersonation
level of 0. An Anonymous impersonation token also
reports 0, which is why subject.token.type is recorded beside it.
5.4.4.2 Key opens #
lcs.audit.key.opened carries the caller summary, then
object.kind (always key, which names the table the masks decode
against), object.key.guid, the requested and granted masks as
access.requested and access.granted, outcome.success (true
when the open was allowed), and trigger.sacl-match — bit 0 for a
success-audit match, bit 1 for a failure-audit match, no other
bits.
access.granted 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.
access.requested 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 Registry writes #
Writes are audited through the key handle, as file operations are
through a file handle. When a key is opened, the access check evaluates
the key's SYSTEM_ALARM* ACEs against the caller, and the union of the
masks of the ones that match becomes the handle's continuous-audit
mask. Success and failure
SYSTEM_AUDIT ACEs do not contribute; they govern the open itself. This
is the continuous auditing of §3.8.9 with LCS as the enforcement point,
except in two respects: LCS writes records named for the operation
rather than kacs.audit.handle.used, and a record it cannot write does
not fail the operation (see below).
The mask is fixed for the life of the handle. A later change to the key's SACL neither adds to it nor takes from it, so a handle that removes the alarm ACE is still audited for the change it makes, and handles opened before an alarm ACE was added are not.
A write through the handle is recorded when the right it needs overlaps the mask, and not otherwise. The right is the one the handle's own gate checks:
| Event | Operation | Right |
|---|---|---|
lcs.audit.value.set | REG_IOC_SET_VALUE | KEY_SET_VALUE |
lcs.audit.value.deleted | REG_IOC_DELETE_VALUE | KEY_SET_VALUE |
lcs.audit.key.tombstoned | REG_IOC_BLANKET_TOMBSTONE | KEY_SET_VALUE |
lcs.audit.key.deleted | REG_IOC_DELETE_KEY | DELETE |
lcs.audit.key.hidden | REG_IOC_HIDE_KEY | DELETE |
A write that fails is recorded too, with outcome.success false and
outcome.errno the negative errno the caller received. That includes a
write the handle's own gate refuses because the handle lacks the right:
the mask comes from the SACL, not the grant, so it can cover rights the
handle was not given.
Every write record carries the caller summary; object.kind (key),
object.key.guid and object.key.path, the handle's key and its
resolved path; object.key.layer.name, the layer written; the masks
access.requested (the right above), access.granted (the handle's
grant), access.matched and access.audit-mask; transaction.id when
the write was staged in a transaction; and outcome.success, with
outcome.errno on a failure. A failure also carries request.timed-out.
Value records add object.key.value.name; a value write adds the value's
type and length and a digest of its data, and mutation.sequence, with
mutation.sequence-expected on a compare-and-swap; a tombstone change
adds operation.name (set or clear); a hide adds mutation.sequence.
A field the operation had not reached when it failed is left out.
A value's data is never recorded. In its place a record carries the data's type, its length and its SHA-256 digest, so two records can be compared without either holding the data. Every digest LCS records, of a value or of a security descriptor, is SHA-256.
A value write outside a transaction also records the value it replaced, as
object.key.value.type-previous, .length-previous and
.digest-previous: the effective value before the write, across every
layer. A value delete records the value it removed the same way.
A write whose source does not answer before the request timeout fails
with ETIMEDOUT, and its record carries request.timed-out true. The
source is not told to cancel, so the change may still land; for a value
write or descriptor change outside a transaction, LCS also applies the
write's kernel-side effects when the late reply arrives, and nothing
records who made the write then. Read a timed-out record as "may have
been applied later".
A write made in a transaction is recorded when it is staged, with
transaction.id. Its outcome.success then says the source accepted it
into the transaction, not that it took effect.
5.4.4.3.1 Transaction ends #
A transaction that staged at least one recorded write — a write or a key
creation whose record was emitted — writes one
lcs.audit.transaction.committed when it ends, and only one: at a
REG_IOC_COMMIT that leaves it committed or otherwise finished, at its
timeout, or when its fd is closed without a commit. A commit that fails
and leaves the transaction open writes nothing yet.
It carries the caller summary, transaction.id and transaction.state,
the state it ended in, and outcome.success, true only for committed.
A failure adds transaction.commit-outstanding and outcome.reason:
aborted (closed without a commit, or a layer it wrote was deleted),
timed-out or source-error; and outcome.errno when a
REG_IOC_COMMIT call failed.
On REG_IOC_COMMIT the subject is the caller who committed, not the one
who staged the writes. A transaction that ended by timeout or by its fd
closing has nobody acting, and names the subject that staged its first
recorded write. The timer that ends a transaction runs in softirq context,
which cannot build a record; the record is written from the work item
that aborts the transaction at the source.
A commit whose reply does not arrive in time reports timed-out with
transaction.commit-outstanding true: the source may have committed it
before the reply was lost.
5.4.4.4 Key creation #
lcs.audit.key.created records a key a create made. Creating a key
checks only KEY_CREATE_SUB_KEY on its parent, so the parent's SACL
decides, by the rule that decides lcs.audit.key.opened: a success audit
ACE on the parent that matches the caller.
Only a key that was made is recorded. A create that found the key already present and opened it instead writes nothing here. The record is written once the key exists, or is staged in a transaction, so a create whose handle then fails to reach the caller is still recorded.
It carries the caller summary; object.kind; the new key's
object.key.guid, object.key.path and object.key.layer.name;
object.key.created (true), object.key.volatile,
object.key.volatile-requested and object.key.symlink; the new key's
descriptor as object.sd.length and object.sd.owner; access.requested
(KEY_CREATE_SUB_KEY) and access.granted, from the parent check;
transaction.id when staged; and outcome.success.
5.4.4.5 Descriptor changes #
lcs.audit.key.descriptor.changed records REG_IOC_SET_SECURITY through
a key handle.
A change that includes the SACL is recorded whatever the handle's mask
says. It is recorded once the handle's own gate has admitted it, which
takes ACCESS_SYSTEM_SECURITY: a handle without that right is refused
first, and asking for a SACL change writes no record.
Any other change is recorded when the right it needs — WRITE_OWNER for
the owner or group, WRITE_DAC for the DACL — overlaps the handle's
mask, and then failures are recorded as writes' are.
The record carries what a write record does, without a layer, and the
descriptors: object.sd.components, the security_info bits asked for;
and for the descriptor written and the one it replaced, object.sd.length
and object.sd.length-previous, their SHA-256 digests object.sd.digest
and object.sd.digest-previous, and their owners object.sd.owner and
object.sd.owner-previous. access.requested is every right the change
needs, and access.matched is zero when only the SACL made the record
exist.
5.4.4.6 Privilege use on opens #
A registry open whose access check used a privilege — SeSecurityPrivilege
for ACCESS_SYSTEM_SECURITY, SeTakeOwnershipPrivilege for
WRITE_OWNER — writes KACS's kacs.audit.privilege.used when the
caller's token audit policy asks for privilege use, in the shape KACS
uses for its own access checks, with object.kind key. The key's
identity is not carried. The checks LCS makes for layer writes and on a
create's parent record none.
5.4.4.7 Backup and restore #
lcs.audit.backup.started and lcs.audit.restore.started carry the
caller summary, object.key.guid for the subtree root, and
operation.fd, the descriptor the stream is written to or read from.
lcs.audit.backup.ended and lcs.audit.restore.ended carry the
caller summary, object.key.guid and outcome.success. When the
operation failed they also carry outcome.errno, the error as a
negative errno; on success it is absent.
5.4.4.8 Source validation failures #
lcs.source.response.rejected carries the source slot as
source.rsi.slot and then, where each is known, the hive name as
source.rsi.hive, the RSI request id and operation code as
request.id and request.op-code, and the key GUID as
object.key.guid. outcome.reason 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.9 Configuration #
lcs.config.value.rejected has the same shape as KMES's
kmes.config.value.rejected, with every field under one config
map. It carries the offending parameter's key and name as
config.key.path and config.name; the expected type and numeric
range as config.expected.type, config.expected.min and
config.expected.max; what was actually received as
config.received.kind — one of missing, wrong-type or
out-of-range — with config.received.type for a value of the wrong
type or config.received.value for one out of range; and, as
config.value, 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.10 What happens when emission fails #
The policy differs per event, and the differences are the point.
lcs.audit.key.opened. 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.audit.backup.started,lcs.audit.restore.started. Emission failure returnsEIOand the operation does not start. Nothing is read and nothing is written.lcs.audit.backup.ended,lcs.audit.restore.ended. The operation has already finished. Emission is attempted; failure does not change the result.lcs.source.response.rejected. The triggering operation is already failing withEIO. Emission is attempted; failure does not change that.lcs.config.value.rejected. 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 write, creation, descriptor-change and transaction records.
Each is written after the source has applied or refused the operation,
or after the transaction's state is final. Emission is attempted; a
record that cannot be built or retained leaves the caller's result as
it was.
These records fire no
lcs:lcs_audit_emitorlcs:lcs_audit_emit_failedtracepoint; the tracepoints' event-type codes cover the other seven.
The rule underneath them all: 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 / Peios technical reference / 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 / Peios technical reference / 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.
- 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.
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.
If the key exists, this behaves as reg_open_key and the
disposition is REG_OPENED_EXISTING.
What "exists" means depends on the layer named. For the base layer (a
null layer_ptr) it is the path resolving through any enabled layer.
For a named layer it is that layer holding an entry at the path:
presence is per layer (§5.2.5), so another layer's key at the same path
does not count, and the create makes the named layer's own entry, with
its own GUID and REG_CREATED_NEW — which is how hide-and-replace is
authored.
The named-layer presence check is read-only and compares layer names without
case sensitivity. If that layer already names a key, LCS opens the effective
key using normal layer resolution and checks the requested rights on that
key. It does not require KEY_CREATE_SUB_KEY on the parent, perform layer
write authorization, or send CREATE_ENTRY/CREATE_KEY for an existing key.
An unbound transaction remains unbound by this open; a bound transaction
uses its own view, including entries it has created but not yet committed.
The checks below still apply when the named layer has no key entry there.
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).
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 / Peios technical reference / 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 #
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.
For a non-root key, the name is the stored key record's informational name,
with its original spelling, rather than the spelling used to open the fd.
The required buffer length is that stored name's UTF-8 byte length, including
when the opening spelling folds to it but has a different byte length.
The source name must fold to the leaf the fd resolved; an unrelated name
is malformed key metadata, audited and refused with EIO, including on
a buffer-length probe. Hive roots have no child name and instead report
the preserved hive name from source registration, with its own byte length.
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. LCS reads the key's entries before every write, for watch dispatch,
but there is no kernel-side query-then-write for the condition: 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:
EINVALfor an unknown type or a tombstone with data.EAGAINfor a failed conditional write.ENOSPCfor the layer cap or oversized data.ENAMETOOLONGfor the value name.EPERMfor aPrecedenceabove 0 withoutSeTcbPrivilege(§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 / Peios technical reference / 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:
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 / Peios technical reference / 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 / Peios technical reference / 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 hiding entry that was concealing a lower-precedence key
produces SUBKEY_CREATED. Removing a whole layer is recovery dispatch
rather than a diff, and reaches a watcher as OVERFLOW (§5.6.3). 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.3).
5.6.1.2 Event types #
VALUE_SET fires when a value is written, and when a tombstone or
blanket tombstone is removed and a lower-precedence value surfaces.
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.
A subtree flag other than 0 or 1, or a non-zero padding byte in the argument structure, is rejected the same way.
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 / Peios technical reference / 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 / Peios technical reference / 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 or failed transaction dispatches nothing and its log is
released. A transaction that timed out after its commit was dispatched
keeps its log, and a late RSI_OK still emits the batch (§5.7.3).
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 generation number of every
hive the affected source backs and then queues a no-name OVERFLOW to
every armed watch on that 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 an ordinary write
happened in between; after a recovery it cannot, because the bump is
deliberately conservative. The kernel does not know which hives a layer
operation touched — the source's delete-layer answer names only the
keys it orphaned, and a layer holding hiding or masked entries changes
effective state without orphaning anything — so it bumps every hive
rather than risk a watcher skipping a re-read its hive needed. The
consequence is always a spurious re-read, never a missed change.
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, and bumps all of their generation
counters.
The generation counters are maintained per hive for ordinary writes;
recovery treats the source as the unit.
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 / Peios technical reference / 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 / Peios technical reference / 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.
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.
The timer updates transaction state from softirq context. Task and workqueue callers disable bottom halves while holding the shared state spinlock, so the timer cannot interrupt a holder on the same CPU and wait for that holder to release it. Synchronous timer cancellation runs outside the state lock.
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 / Peios technical reference / 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 / Peios technical reference / 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 / Peios technical reference / 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 / Peios technical reference / 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.
- The root GUIDs within one request are distinct from each other.
- A hive without
RSI_HIVE_PRIVATEcarries no scope GUID. - No unknown flag bits are set.
- The hive count is within
MaxHivesPerSource(64), and the source count is withinMaxRegisteredSources(32). Either limit isENOSPC; a zero hive count isEINVAL. - 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 against
every existing slot, Active or Down: a request whose root GUID is
already the root of a differently-named hive is refused EEXIST. A
same-named hive is decided by the identity rules above instead. The
already-registered state is also checked for consistency, and a table
that ever held two hives with one root would fail that check on every
later registration and path walk, which is why admission refuses it.
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.
LCS 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 / Peios technical reference / 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.
A signal that interrupts the wait after dispatch detaches the caller
the same way, and the call fails with EINTR or is restarted. As after
a timeout, the source may still apply the request when it answers. A
response that completed before the caller could be detached wins: the
caller gets that response's result, with the signal still pending.
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.3.5 Negotiated lifetime control #
Sources may negotiate lifetime extension version 1 with REG_SRC_NEGOTIATE
before REG_SRC_REGISTER. Pass version 1, zero flags and zero session; the
kernel returns a nonzero session identifier unique to the open source file.
The RSI request and response frame layouts are unchanged. Sources that do not
negotiate retain the legacy protocol; current loregd requires this extension.
After reading a request and before responding, REG_SRC_REQUEST_CONTEXT takes
that session and request id and returns three absolute CLOCK_MONOTONIC
nanosecond timestamps: the request deadline, transaction lifetime deadline and
cleanup lease deadline. Zero lifetime means an operation-owned transaction
with no separate lifetime ceiling. Zero lease means there is no live transaction
owner. Request metadata remains queryable after caller detachment until the
response is accepted, so cleanup and late-response handling still work.
REG_SRC_RENEW takes the session and transaction id and returns the original
lifetime deadline plus a renewed cleanup deadline, at most five seconds from
now and never beyond the lifetime deadline. It succeeds only for an existing,
nonterminal transaction owned by a live file object or kernel operation on
this source session. Unknown, released, expired and foreign-session identities
return ESTALE. Once a lease expires, renewal cannot resurrect it. These
control ioctls consume no ordinary request slots, so request-queue saturation
does not starve renewal.
Final owner release and abort prevent renewal before abort-message delivery. Client lifetime expiration also prevents renewal. Sources must reject new operations after expiry and serialize rollback with operations and commit; an already-admitted commit must resolve its actual outcome. Lease cleanup does not change the kernel's treatment of late commit responses (§5.8.5).
5.8.4 Validation
Peios / Peios technical reference / 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.response.rejectedaudit 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 / Peios technical reference / 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.
The watch dispatch takes the recovery form: a watcher receives an
OVERFLOW ahead of the event itself (§5.6.3).
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 / Peios technical reference / 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 / Peios technical reference / PKM / LCS / Backup and Restore
REG_IOC_BACKUP exports the key on the fd and its entire subtree to
another fd, preserving parent/child relationships even when traversal
must grow its internal storage.
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.audit.backup.started is emitted before any subtree data is read, and
if it cannot be emitted the backup returns EIO and does not start.
After privilege, output-fd and key validation, START precedes the request for the read-only snapshot. A refused or failed snapshot therefore emits both START and END, with END carrying the failure errno; no subtree is exported and no unopened snapshot is released. A START emission failure prevents the snapshot request itself.
lcs.audit.backup.ended 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.
This includes definitions for unused layers in a whole-hive export. Their metadata remains ordinary registry data; an unused definition does not by itself require an entry in the layer manifest.
5.9.3 Restore
Peios / Peios technical reference / 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. 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. 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).
5.10.1 The Bootstrap Problem
Peios / Peios technical reference / 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 / Peios technical reference / 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 / Peios technical reference / 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.
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 #
One of the nineteen does not do everything its range suggests.
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.
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.config.value.rejectedaudit 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 — provided Machine\System\Registry
itself exists. If the key is absent there is nothing to read and
nothing is reported; the fallback watch of §5.10.4 waits for it.
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 / Peios technical reference / 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. Within
one delivery — a transaction's commit, say, whose batch creates a
layer and then writes its three values — each dirty layer is refreshed
once, however many of the batch's events named it.
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.
The same arming covers every key the kernel reads for itself, not only
LCS's own: KMES configuration, the port reservation table
(Machine\System\Network\TcpIp\PortReservations\, see the KACS
chapter on network objects), the network policy key
(Machine\System\Network\, whose rules and inventory the packet
engine reads — see the network policy chapter) and the event emission
policy (Machine\Generic\Events\, see the KMES chapter) are
discovered in the same refresh and get the same targeted-or-fallback
treatment.
The two policy watches are the depth-unbounded, every-mutation ones.
For the network policy, rules are keys and exceptions are subkeys, so
anything written anywhere beneath the key may be policy.
The emission policy has a key per event-type segment at any depth, and
its watch passes on only changes on Events itself, beneath the
kernel's own roots, and to Enabled values (KMES §2.8).
A refresh that fails still arms the fallback. A stage can fail
transiently — a source answering a lookup while a concurrent write has
half-created a key was the case that found this — and if the refresh
simply returned, no watch would exist and no kernel-read key would load
for the life of the boot. So when any stage before arming fails, LCS
arms the Machine\ root fallback alone: the next subkey creation under
the hive re-enters the refresh, which then arms the targeted watches as
above.
The failure and the fallback arm are both traced by
lcs:lcs_bootstrap_refresh.
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 / Peios technical reference / 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/.
The PKM UAPI exports the uppercase constants in <pkm/syscall.h>. Peios
glibc 2.44-5 and later exports the standard lowercase SYS_reg_*
aliases in <sys/syscall.h>; each alias has the same number as its PKM
constant.
| Number | PKM constant | glibc alias | Signature |
|---|---|---|---|
| 1100 | SYS_REG_OPEN_KEY | SYS_reg_open_key | reg_open_key(int parent_fd, const char __user *path, u32 desired_access, u32 flags) |
| 1101 | SYS_REG_CREATE_KEY | SYS_reg_create_key | reg_create_key(const struct reg_create_key_args __user *args) |
| 1102 | SYS_REG_BEGIN_TRANSACTION | 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 |
REG_SRC_NEGOTIATE | 1 | _IOWR | struct reg_src_negotiate_args | 16 | 0xC0105201 |
REG_SRC_REQUEST_CONTEXT | 2 | _IOWR | struct reg_src_request_context_args | 40 | 0xC0285202 |
REG_SRC_RENEW | 3 | _IOWR | struct reg_src_renew_args | 32 | 0xC0205203 |
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_negotiate_args #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | version |
| 4 | 4 | __u32 | flags |
| 8 | 8 | __u64 | session |
5.A.3.20 struct reg_src_request_context_args #
Total size 40 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | session |
| 8 | 8 | __u64 | request_id |
| 16 | 8 | __u64 | request_deadline_ns |
| 24 | 8 | __u64 | lifetime_deadline_ns |
| 32 | 8 | __u64 | lease_deadline_ns |
5.A.3.21 struct reg_src_renew_args #
Total size 32 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | session |
| 8 | 8 | __u64 | transaction_id |
| 16 | 8 | __u64 | lifetime_deadline_ns |
| 24 | 8 | __u64 | lease_deadline_ns |
5.A.3.22 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.
Optional RSI lifetime extension, negotiated before REG_SRC_REGISTER. All timestamps use CLOCK_MONOTONIC nanoseconds. Zero lifetime means an operation-owned transaction with no independent lifetime limit. The session is unique to this open source fd, including across restarts.
| Constant | Value |
|---|---|
REG_SRC_LIFETIME_VERSION | 1 |
Syscall and ioctl argument sizes (Kernel TRM section 5.A, LCS ABI).
| 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_NEGOTIATE_ARGS_SIZE | 16 |
REG_SRC_REQUEST_CONTEXT_ARGS_SIZE | 40 |
REG_SRC_RENEW_ARGS_SIZE | 32 |
REG_SRC_REGISTER_ARGS_SIZE | 24 |
REG_SRC_HIVE_ENTRY_SIZE | 56 |
| 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 |
| 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 |
LABEL_SECURITY_INFORMATION | 0x00000010 |
REG_VALID_SECURITY_INFORMATION | 0x0000001F |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 / Peios technical reference / 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.5, the error vocabulary in §5.5.5, 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 the same row serves the x32 ABI
wherever that ABI is built in; the Peios kernel does not build it.
6.1 Overview
Peios / Peios technical reference / PKM / NTFE
NTFE — the Network Traffic Filtering Engine — is the kernel's packet
filter. It executes the packet layers of PNP, the Peios Network Policy:
PNP is the policy language and its registry layout under
Machine\System\Network, and NTFE is one of the components that carry
it out (netd is another, for the interface layer). It stands at
the netfilter seats, judges every traversal against the policy it reads
from the LCS registry itself, and applies the result: a verdict on the
packet, side effects on the flow and machine, and an event for whoever
is watching. No userspace process is in the enforcement path; the
viewer daemon (pnpd) observes and authors, it never decides.
6.1.1 What it replaced #
Peios ships a clean slate below NTFE. The netfilter hook framework
and conntrack (built in, with events, zones and timeouts; helpers
off) are kept, as are nf_defrag and the nf_reject machinery NTFE uses
to phrase refusals. With helpers off nothing assigns a
helper to a flow (only a conntrack entry a privileged process creates
over ctnetlink, naming one, could carry it), so no flow is expected
by another: ct->master stays NULL, and the Flow layer's Related
fact (§6.3) is false on every flow the stack tracks. Everything that was a policy frontend is
configured out: nf_tables, the xtables family (iptables, ip6tables,
ebtables, arptables), ipset, NFQUEUE, NFLOG, the flow table offload,
bridge netfilter, the netfilter BPF link and IPVS. NF_NAT is built but
dormant. The kernel config gate (kernel/verify-kernel-config.sh)
asserts both halves so a merge cannot quietly bring a frontend back.
One consequence is worth knowing: conntrack's hooks are demand-activated,
historically by a ct-using iptables rule. With no frontend left, NTFE pins
them itself at init (nf_ct_netns_get(&init_net, NFPROTO_INET)); without
that pin nf_ct_get() is NULL on every packet and the FlowState fact
reads untracked forever.
6.1.2 Two halves #
The engine is split along the line that kernels are good at and pure code is good at.
net/ntfe/ (C) owns everything that touches the kernel: the hook
registrations and the dispatch law (seats.c), building the fact
snapshot from an sk_buff (snapshot.c), RCU publication of policy
generations (policy.c), walking the registry into the builder
(ingest.c), the three machinery stores — flow tags on a conntrack
extension (tags.c), counter tables (counters.c), report emission into
KMES (report.c) — the Flow layer's sentence cache and dispatch
(flow.c), the refusals a REJECT sends (refuse.c), and the verdict
event ring behind /dev/peios-ntfe (events.c).
pnp-core (Rust, pkm/crates/pnp-core) owns everything the policy
means: the action language, the fact vocabulary and operators,
registry-shaped ingestion and validation, the forest, and the evaluation
algorithm. It is no_std, allocates only through the fallible PKM
wrappers, and has no I/O. The same source compiles twice: under cargo,
where a test suite encodes every ratified law by name, and into the
kernel, staged by kernel/stage-rust-core.sh as modules of the
security/pkm Rust crate and reached over a C ABI (kacs/ntfe_runtime.rs,
the bridge). Nothing about a rule's semantics exists in C.
The bridge is the only place the two meet. C hands it a snapshot and a
forest; it resolves the forest's machinery facts against the packet by
calling back into the stores, evaluates, and applies the effects — after
collation, so a COUNT lands after this packet's own reads and a
REPORT can carry the verdict.
6.1.3 Terms #
- Traversal — one packet passing one direction through the machine.
- Seat — a netfilter hook NTFE stands at: the device ingress and
egress seats (per interface) and the two IP seats, inbound
(
LOCAL_IN) and outbound (LOCAL_OUT). - Layer — one of the three rule forests,
RawPacket,PacketandFlow, each a registry key underMachine\System\Network\Rules. - Flow — what conntrack tracks: a tuple-identified, bidirectional exchange. The unit the Flow layer judges.
- Sentence — the Flow layer's cached verdict on a flow: verdict, generation, expiry, on the flow's conntrack extension.
- Snapshot — the immutable set of facts one traversal is judged against at its seat.
- Generation — one published policy. Advances on every successful ingestion; 0 means nothing was ever ingested and every layer is permissive, loudly.
- Forest — a layer's rule trees plus the machinery names it mentions: the tags it reads or writes, the counter streams it writes, and the counter views it reads.
- Effect — a side effect an evaluation yields (
TAG,COUNT,REPORT, a prompt), applied by the glue. - Confession — a counter in the engine status for something the engine refused or could not do. Nothing in NTFE fails silently.
- Owner — the identity KACS stamps on an inet socket (§3.12.2): the
effective token and process facts at the last act that committed the
socket to a role. What the Flow layer's
Local.*facts are read from. - Endpoint kind — what stands at a local end of a flow:
program,kernel,sharedornone(§6.9).
The chapter follows a packet: the seats it meets (§6.2), the snapshot
taken of it (§6.3), how the forest judges it (§6.4), where that forest
came from (§6.5), the stores its effects land in (§6.6), the event that
records it (§6.7), what happens when it is the first packet of a flow
(§6.8), and who stands at its local end (§6.9). §6.A is the generated
ABI of /dev/peios-ntfe; §6.B is what the ABI tables cannot say.
6.2 Seats and dispatch
Peios / Peios technical reference / PKM / NTFE
6.2.1 The seats #
NTFE registers at four kinds of netfilter hook.
- Device ingress (
NF_NETDEV_INGRESS) and device egress (NF_NETDEV_EGRESS), per interface. A netdevice notifier registers both on everyNETDEV_REGISTERininit_netand unregisters them onNETDEV_UNREGISTER; becauseregister_netdevice_notifier()replaysNETDEV_REGISTERfor devices that already exist, boot-time interfaces get their seats too. These seats see frames — the Ethernet header is present, VLAN tags are visible, and on the inbound side conntrack has not yet run. - IP inbound (
NF_INET_LOCAL_IN, IPv4 and IPv6) at priorityNF_IP_PRI_FILTER. Conntrack and defragmentation have run; the packet is whole and its flow is classified — and still unconfirmed, since conntrack confirms at the end of this hook. - IP outbound (
NF_INET_LOCAL_OUT, IPv4 and IPv6) at filter priority. The first point after conntrack has classified a locally generated packet's flow; the sending socket is attached; the entry is unconfirmed untilPOST_ROUTING.
Only init_net is instrumented: Peios has no container or network
namespace story yet, and the notifier ignores devices in other
namespaces. Of netfilter's other hooks, FORWARD is empty on a host that
does not forward, and PRE_ROUTING and POST_ROUTING see nothing the IP
seats do not, save NAT.
6.2.2 The dispatch law #
The ratified law, with the Flow layer's clause:
RawPacket matches all traffic at the device seats, unconditionally. The Packet layer judges every traversal exactly once, at its proper seat — inbound IP at the IP hook, everything outbound at egress — falling back to the ingress seat iff the traversal will never reach its proper seat. The Flow layer judges every tracked flow once per local endpoint, at the IP seats, and its sentence answers for every later packet of the flow.
"Will never reach its proper seat" is decidable at ingress from two
facts, and peios_ntfe_traversal_reaches_ip_seat() decides it: the
ethertype (only ETH_P_IP and ETH_P_IPV6 cross the IP hooks) and the
device's disposition (netif_is_bridge_port() — a frame on a
bridge-enslaved port is switched at L2 and never enters this device's IP
stack; a copy delivered to the bridge device itself is a separate
traversal on that device, judged there).
So at each seat the hook builds one snapshot and judges an ordered list of layers:
Traversal order is wire order: RawPacket is wire-proximate, so it is
first in and last out; Flow is innermost, last in and first out. Each
per-packet layer is evaluated once per traversal, and the first non-PASS
verdict ends the traversal. A layer with no published forest is
permissive and counted as such — at generation 0 that is every layer.
At the IP seats, a packet goes on to the flow dispatch (§6.8) — at
LOCAL_IN once the Packet layer has passed it, at LOCAL_OUT before
any Packet judgment, since the Packet layer judges outbound traffic at
egress, after it: an untracked packet has no flow to judge and is
accepted, so inbound the Packet verdict stands and outbound egress
judges it next; a tracked packet with a current sentence gets that
sentence, and one without is evaluated by the Flow forest and sentenced.
6.2.3 Applying a verdict #
PASS continues to the next layer, then NF_ACCEPT. DROP is
NF_DROP. REJECT is NF_DROP plus a refusal, phrased by the kind the
rule chose and the packet's protocol:
Every seat can refuse IP traffic — the ingress seat only on an Ethernet
device. refuse.c builds the answer with the
kernel's frame-less reject builders (nf_reject_skb_v4_tcp_reset(),
nf_reject_skb_v4_unreach() and the v6 pair — the ones nftables' netdev
reject uses), attaches the flow to it (nf_ct_attach(), so conntrack
files it as the reply it claims to be), marks it, and delivers it:
- from the ingress seat, to the peer on the wire —
dev_hard_header()with the offending frame's MACs swapped, thendev_queue_xmit(); that needs an Ethernet device (ARPHRD_ETHER) and a link header to swap, so at the ingress seat of any other device — the loopback device, a tunnel — aREJECTalways degrades (below); - from every other seat, to ourselves —
skb_dst_set_noref()from the offending packet's route,ip_route_me_harder()(or the v6 helper), thenip_local_out(). Inbound, that routes the answer out to the peer with our address as its source; to a link-local address it is routed on the device the offending packet arrived on, without the helper, which would scope the lookup to the loopback device the local delivery route names; outbound, the answer is the peer's, addressed to us, so the route lands on the loopback device and the stack's own RST and ICMP-error handlers fail the local socket at once, with the error the kernel maps the answer to:ECONNREFUSEDfor a reset or a port unreachable,EHOSTUNREACHfor IPv4's admin-filtered,EACCESfor IPv6's admin-prohibited. TCP takes an ICMP error that reaches a socket its owner is holding as a soft error, and a refused SYN is refused insideconnect(), which holds it: so an ICMP answer to a held TCP socket is sent a tick later (one jiffy, on the system workqueue), once the call has let go. A reset needs no wait: TCP queues it on the socket's backlog. The loopback device retains the route the packet was sent with, so the foreign source address never meets source validation.
The builders read the offending packet from skb->data as though it
pointed at the network header, as it does at the IP hooks; at the
egress seat it points at the link header the device has pushed, so
refuse.c pulls the packet to its network header for the build and
pushes it back after.
When the refused packet belongs to an established TCP flow, the far
end is torn down too: peios_ntfe_teardown_build() turns the refused
packet itself into a reset — same addresses, ports, sequence and
acknowledgement numbers, RST set, no data — marks it, and sends it
where the packet was going by the same routed path (outbound, to the
peer on the wire; inbound, to our own socket over loopback). The refused
end gets the kind's story; the far end gets the only story TCP listens
to mid-connection. Counted in teardowns_emitted. New flows and UDP
have no far end; a reset is never answered with a reset; the ingress
seat has no flow facts and never tears down.
The answer is not sent, and the REJECT degrades to DROP (counted
in reject_degraded, flagged REJECT_DEGRADED in the event, the kind
still named), when there is nothing to send: a non-IP frame, a broadcast
or multicast destination, an ingress frame on a non-Ethernet device, a
builder that declines (a non-first fragment, which has no transport
header to answer from; a failed checksum; a refusal of a refusal), a
packet with no route to reason from, or an allocation failure. A
first fragment passes the builders' fragment check and is declined by
their checksum check instead — its transport checksum covers a payload
it carries only part of — so one whose checksum the builder does not
verify (one the stack has already marked as checked, or a protocol the builders
skip: UDP with a zero checksum, SCTP, ESP, AH, GRE, UDP-Lite) is
answered. Fragments meet the builders only at the device seats:
LOCAL_IN sees reassembled packets. Refusals sent are counted in
refusals_emitted.
6.2.4 The refusal law #
NTFE does not judge its own refusals. The answer refuse.c builds
carries skb->ntfe_refusal, a bit the ntfe-refusal-bit patch adds to
struct sk_buff inside its headers group (so clones and copies keep
it); every hook checks it first and returns NF_ACCEPT without a
snapshot, an evaluation or an event, counting refusals_bypassed.
Refusals are verdict machinery, not traffic: without the bit, an inbound
REJECT's RST would cross the egress seat and could be dropped — and
attributed — by an outbound rule, and an outbound REJECT's forged peer
answer would be judged as inbound traffic on its way to the socket.
6.2.5 Failing closed #
Evaluation can fail: the core allocates GFP_ATOMIC during a walk and
an allocation can be refused. The hook then drops the packet, counts
fail_closed, and emits an event attributed to fail-closed with the
FAIL_CLOSED flag. Totality does not take "no answer" for an answer.
A snapshot that cannot be built — a frame too mangled to describe — is
counted (parse_errors) and judged on its seat facts alone; the
absent-fact law does the rest.
6.3 The snapshot
Peios / Peios technical reference / PKM / NTFE
Every judgment reads a snapshot: a fixed-size, stack-allocated
struct peios_ntfe_snapshot built once per seat by
peios_ntfe_snapshot_from_skb() and never mutated during evaluation.
That immutability is a ratified law, not an implementation detail —
nothing a rule writes is visible to the same evaluation's matching, so
temporal feedback (the next packet sees it) is the only feedback there
is.
6.3.1 Validity bits #
Many facts have meaningful zero values (port 0, TTL 0, VLAN 0), so
presence is carried separately in has, a bitmask of PEIOS_NTFE_HAS_*:
ethertype, MACs, source MAC alone, VLAN, TTL, DSCP, fragment, ports, TCP
flags, ICMP, time, the flow's start time, and the network context. Address facts use
addr_family (0, 4 or 6) as their validity; the protocol is valid iff a
family is. Flow state uses its own ABSENT value; flow_related is
valid iff flow is. The bridge turns each clear bit into None on the
Rust side, and the absent-fact law makes every condition over a None
false.
6.3.2 Extraction #
Seat facts first: seat, direction, interface (ifindex and name), the
network context of that interface (below), whether the device is the
loopback (IFF_LOOPBACK — the flow dispatch's "two endpoints" test),
the packet length as the stack sees it
(skb->len — not the wire length), and the wall clock
(ktime_get_real_seconds() through time64_to_tm(), UTC, with
tm_wday re-based so the DayOfWeek fact is ISO: 1 = Monday .. 7 =
Sunday), kept alongside as epoch seconds (t_secs) for the time-flip
arithmetic (§6.4) and the sentence expiry check (§6.8).
Then the frame: the ethertype from skb->protocol. The VLAN id is the
frame's tag if skb_vlan_tag_present(), else the device's if
is_vlan_dev() — the VLAN is a fact of the frame at the device seats
and of the device at the IP seats, where inbound the VLAN code has
already stripped the tag and re-parented the packet onto the VLAN
device, and outbound the tag is pushed only when that device transmits.
(Before the Flow slice the IP seats read the stripped tag and Vlan was
absent on VLAN interfaces there.) The MAC pair if the MAC header is set
and the device is ARPHRD_ETHER; failing that, at an IP seat on an
Ethernet device — a locally generated packet with no link header yet —
the source alone, from the device's own address (HAS_SRC_MAC): present
so every flow carries the same fact set, and not useful. The destination
is unknown until neighbour resolution, after the seat: absent.
Then IP, from skb_network_offset():
- IPv4 — addresses, TTL, DSCP (
tos >> 2), the fragment flag (IP_MFset or a non-zero fragment offset), the protocol, and the L4 facts fromihl * 4on. A non-first fragment (non-zero offset) has no L4 facts — its payload begins mid-datagram — though it still reads the protocol its header names. - IPv6 — addresses, hop limit, DSCP from the traffic class, and a
bounded walk (eight hops) of the extension-header chain: hop-by-hop,
routing and destination options are skipped by
(hdrlen + 1) * 8; a fragment header sets the fragment fact, and a non-first fragment ends the walk with no L4 facts (there are none to read). The protocol fact is the header the walk stops at — so an MLD report behind a hop-by-hop header readsProtocol = icmpv6, as a rule would expect. Every way out of the walk names one: a non-first fragment reads the protocol its fragment header names, as a later IPv4 fragment reads its own; an extension header too short to read is itself the protocol; and after eight hops the next header is the protocol, with L4 facts if it is not another extension header.
Then L4, by protocol: ports for TCP, UDP and SCTP; the flag byte for TCP
(TcpFlags is the TCP header's byte at offset 13, FIN in bit 0
through CWR in bit 7, the same encoding tcp_flag_byte() uses); type and code for ICMP and
ICMPv6. Every header read goes through skb_header_pointer(), so a
packet whose headers are paged or truncated yields absent facts rather
than a fault.
6.3.3 Flow state and the flow #
At the ingress seat conntrack has not run and the flow state is
ABSENT; at LOCAL_IN, LOCAL_OUT and egress, nf_ct_get() gives the
entry and ctinfo maps to the fact: IP_CT_ESTABLISHED and its reply →
established; IP_CT_RELATED and reply → related; IP_CT_NEW →
new; anything else, or no entry at all, → untracked. The invalid
value is reserved: distinguishing an incoherent packet needs conntrack's
own verdict, which this seat does not receive.
The snapshot also carries flow: the struct nf_conn * itself (never a
template), or NULL. This is the tag store's and the sentence's scope —
TAG writes land on that entry's extension, tag reads come from it, and
the Flow layer's verdict is cached there. It is the one pointer in the
snapshot that outlives the extraction, and it is valid because the skb
holds a reference to the entry for the duration of the hook.
With the flow come two facts that exist only on a flow: flow_related
(ct->master != NULL — the flow was expected by another) and the
flow's start time, read from the extension's start_secs (stamped when
conntrack created the entry) through the same time64_to_tm() lowering
as the clock, into the s_* fields (HAS_START). These are the Flow
layer's Related and Start.* facts. flow_reply says whether this
packet travels in the flow's reply direction — the Flow layer's view
builder (§6.8) uses it to turn the packet's tuple back into the flow's.
6.3.4 What the core sees #
NtfeSnapshotC in kacs/ntfe_runtime.rs mirrors the C struct field for
field (#[repr(C)]; keep them in lockstep). snapshot_from_c() lifts
it into the core's Snapshot — an Option per fact — and the bridge
then fills in the two machinery fact tables: tags, as (name hash, value) pairs for every tag name the forest can read that the flow
carries, and counter_views, as (view index, value) for every view the
forest reads that the store can answer for this packet. Both are
resolved before evaluation, against the forest being evaluated, which
is why the forest carries its name sets (§6.5).
Three visibility laws are enforced here, and again in the core, which
builds a condition over a fact its layer never has never to hold
(§6.5): a
RawPacket forest is given no tags whatever the flow says (tags flow
upward only, and RawPacket is the lowest layer; Packet and Flow
forests read them); an ingress snapshot has no flow, so no tags exist to
give; and the flow-only facts (Related, Start.*, and the identity
facts) are given to a Flow forest alone — everywhere else they are
absent by law, exactly as ingestion's lint says (§6.5). The clock is
given to every layer, and so is the trace of consulted time conditions
(§6.4); only the Flow seat acts on it.
6.3.5 The network context #
Three string fields are not read from the packet either:
network_id, network_name and network_trust, the Network.* facts.
peios_ntfe_context_fill() (context.c) looks the device's name up in
the active context table — the kernel's reading of netd's inventory,
one entry per interface standing on an identified network, built by
ingestion (§6.5) and published under RCU — and copies the entry's three
strings in, setting HAS_NETWORK. The bridge lifts the id whenever the
bit is set, and the name and trust only when non-empty (a record the
operator has not labelled). No entry, no bit: an interface no network
has been identified on carries no context, and every condition over the
three facts is false there. The strings are bounded (40, 64 and 32
bytes with their NUL, PEIOS_NTFE_NETWORK_*_LEN); a longer registry value — a
record's Name or Trust, or the Network an interface's Status
names — is truncated at ingestion and the truncation logged once.
The id of a record is its key name under Networks\, not a value,
and is not truncated: a name of 40 bytes or more is ignored, record and
all (§6.5).
The fields are the three Network.* facts the interface layer — the
fourth layer, which netd builds and judges in userspace — conditions on
beside its own (see the interface layer):
netd fills them from the interface record when it judges an interface,
the kernel from the table when it judges a packet, so
Network.Trust.Equal reads the same record in either layer.
Flow refreshes these facts on a cache miss after capturing its cache invalidation generation (§6.8), because this packet snapshot can predate a context publication. Packet and RawPacket continue to use their traversal snapshot; their verdicts are not retained as Flow sentences.
6.3.6 The identity fields #
The snapshot's last fields are not extracted from the packet at all.
The Flow layer's view builder (§6.8) sets them from the flow's
extension: for the local end, and on loopback for the other end, the
kind (program, kernel, shared, none), the process GUID, pid and
comm, and a borrowed pointer to the KACS token the socket was stamped
with — the extension holds the reference for the flow's life, so the
pointer is valid for the hook. The bridge lifts a program end into a
Principal the core's Local.* and Remote.* conditions question
(§6.9); every other snapshot leaves the fields zero, which lifts to
absent.
6.4 Evaluation
Peios / Peios technical reference / PKM / NTFE
Evaluation is pnp_core::eval::evaluate(forest, snapshot, ctx): pure,
allocation-fallible, and the same code under cargo and in the kernel.
Its steps are the ratified design's six, in order.
6.4.1 1. Matching #
A rule matches iff it is enabled and every one of its conditions holds against the snapshot. Conditions are a conjunction; a value written as a list is a disjunction within that one field. A disabled rule never matches, so its subtree is unreachable.
Conditions are evaluated in the order ingestion stored them, which puts
live-time conditions (Time.*) last, and the conjunction stops at
the first false one. Every live-time condition actually reached — true
or false — is consulted, and Rule::matches_traced() records into a
MatchTrace the moment it would next flip (Condition::next_flip()):
for hour, minute, second and day-of-week, the value sequence is scanned
one cycle ahead with the condition's own operator, so Time.Hour.Equal 9-17 at 10:30 flips at 18:00 and Time.Hour.LessThan 24 never does;
day-of-month, month and year answer "next midnight", since exact flips
need calendar arithmetic nobody has asked for. A false condition is
consulted too: a higher-priority rule that missed only on the clock may
match later. A rule whose address or port conditions fail first never
consults its clock and contributes nothing. The earliest consulted flip
is the evaluation's expires_at — the Flow layer's sentence expiry
(§6.8); the per-packet layers compute it and ignore it.
6.4.2 2. The trigger set #
The walk descends each tree from its root. A node triggers iff it matches and none of its children match: a matching child shadows its parent. Shadowing exists only within a lineage — two trees never shadow each other, and their order in the forest carries no meaning. The walk returns "matched" to the parent so the parent knows it was shadowed.
6.4.3 3. Resolving a triggered rule #
Every triggered rule's action list is resolved once. Verdict actions fold
to the strictest listed; TAG and COUNT push effects; REPORT folds
to the highest level listed and pushes one effect if that level is at
or above CurrentReportingLevel (level >= CurrentReportingLevel; one
report per rule per evaluation); PROMPT
pushes a prompt-issued effect and, with no handler transport in this
release, resolves its fallback in place (nesting bounded by
MAX_PROMPT_CHAIN, 4). Side effects always execute — they are pushed
before anyone knows whether the rule's verdict will win.
6.4.4 4. Abstention #
A triggered rule that yields no verdict abstains. The walk then goes up
the rule's own parentage — the chain it descended by — to the nearest
ancestor whose actions contain a direct verdict (a top-level PASS,
DROP or REJECT; a verdict inside a PROMPT fallback does not count),
and that ancestor speaks for the region: its full action list
resolves, once. A speaker reached by several abstaining descendants in
one evaluation executes only the first time (spoken tracks resolved
speakers by identity). Intermediate abstaining ancestors execute
nothing. If no ancestor bears a verdict, the branch contributes nothing
and other trees or the backstop answer.
The attribution of a speaker's verdict is the speaker's path, not the abstaining descendant's.
6.4.5 5. Collation #
Every yielded verdict is a candidate (verdict, priority, path). The
winner has the highest priority; at equal priority the strictest
verdict: DROP (3) > REJECT(Refused) (2) > REJECT(Prohibited) (1) >
PASS (0). The two reject ranks are a deterministic tie-break — the
quieter story wins — that exists only because candidate order is
meaningless. Priority is the rule's effective priority, resolved at
ingestion: a rule's own Priority value, else its parent's, else 0.
6.4.6 6. The backstop #
No candidate at all: the verdict is DROP, attributed to backstop,
and the evaluation is flagged so the event can say so. The backstop is
compiled in and un-deletable; every permissive statement in a policy is
therefore a visible rule.
6.4.7 The evaluation as data #
Evaluation carries the winning verdict, its attribution, the backstop
flag, every effect to apply, every candidate (winners and losers alike)
for observability, and expires_at. Effects reference their stores by
name and hash: Effect::Tag { hash, op }, Effect::Count { hash, amount } with the amount already resolved (Length becomes the packet
length, or 0 when the packet has none), Effect::Report { rule, level },
Effect::PromptIssued { rule, handler }.
6.4.8 The bridge #
ntfe_rust_evaluate(forest, snap, layer, reporting_level, out) is what
policy.c calls under rcu_read_lock(). It:
- lifts the C snapshot (§6.3) and resolves the forest's machinery facts
through the stores —
peios_ntfe_tag_lookup()for every tag name the forest mentions (PacketandFlowlayers;RawPacketreads none),peios_ntfe_counter_read()for every view — and gives aFlowforest its own facts,RelatedandStart.*; - evaluates with
EvalContext { reporting_level }; - writes the verdict, the reject kind, the backstop flag, the
attribution (cut to 95 bytes), the FNV-1a-64 of the whole
attributing path (
attributed_hash) andexpires_at(0 = never) intostruct peios_ntfe_outcome; - then applies the effects —
peios_ntfe_tag_apply(),peios_ntfe_counter_add(),peios_ntfe_report_emit()with the verdict it just computed — counting each species into the outcome.
Ordering 4 after 2 is what the snapshot-immutability law requires: a
COUNT is applied after this packet's own view reads, so a rule cannot
trip its own threshold, and a REPORT can name the verdict. Every
allocation in 1–2 is GFP_ATOMIC (pnp-core's pkm_alloc in kernel
mode); a failure returns -ENOMEM and the hook fails closed (§6.2). The
store calls never sleep.
6.5 Ingestion and generations
Peios / Peios technical reference / PKM / NTFE
The kernel reads its own policy. There is no daemon that compiles rules
and pushes them down; ingest.c walks the registry through the LCS
source interface, feeds what it finds to the pnp-core builder over the
bridge, and publishes the result as a generation — or keeps the previous
one, loudly. The same walk reads netd's inventory beside the rules into
the network context table (§6.3), because to a flow the two change
together: a network being recognised on an interface is as much a
policy change as a rule being written.
6.5.1 Discovery and change notification #
At LCS bootstrap, NTFE's key is discovered fifth alongside the other
kernel-owned subtrees: pkm_lcs_walk_absolute_components() resolves
Machine\System\Network (the leading Machine hive component is
resolved locally against the hive root, not round-tripped), and its
absence is not an error — no key, no policy and no context, generation
stays where it is. When the key exists one internal watch is armed on
it, depth-unbounded, for every change of content at any depth — LCS
delivers it a value set, a value deleted, a subkey created, a subkey
deleted and a key deleted; a security-descriptor change is not
delivered: Rules\, Interfaces\ and
Networks\ beneath it are what NTFE reads; Profiles\ and Dns\ are
netd's and resolvd's, and a write there fires the watch and costs a
walk that publishes nothing.
Watch events arrive per key and uncoalesced.
peios_ntfe_network_registry_changed() records the source and key, sets
a pending flag, and mod_delayed_work()s a re-walk with a 50 ms
debounce: a burst of writes (a transaction touching a rule and its
values, an autoapply seeding a whole policy, netd syncing every
interface's Status after a link event) yields one re-walk after the
burst goes quiet. The walk itself runs in process context on
system_wq, one at a time under a mutex, since the bootstrap refresh
and the deferred work may overlap.
6.5.2 The walk #
peios_ntfe_network_refresh_from_key() snapshots the LCS runtime limits,
sequence and layer view, enumerates the Network key's children for
Rules, Interfaces and Networks, and runs two independent stages —
the rules, then the context; a failure in the first does not skip the
second, and the walk reports the first error. No Rules key leaves the
previous generation standing, exactly as a walk that refused would.
The rules stage:
- reads the Rules key's own values for
CurrentReportingLevel(REG_DWORD,REG_DWORD_BIG_ENDIANorREG_QWORD; 1..6; absent = 1; anything else refuses); - enumerates its children for the layer keys
Packet,RawPacketandFlow(Interfaceis netd's — the interface layer is built and judged in userspace with the samepnp-core, and the kernel never reads it; any other name is ignored); - for each present layer, opens a builder and walks the layer key's
subkeys as rule roots. Each rule is one
RSI_QUERY_VALUESround trip (its effective, layering-resolved values, delivered in the batch record formatu32 name_len | name | u32 type | u32 data_len | data) followed by oneRSI_ENUM_CHILDRENround trip for its exceptions, recursively, bounded by depth 12 and 4096 rules per layer: a root rule is depth 0, so a chain of 13 keys is the deepest read, and a rule deeper than that or a 4097th rule refuses the walk.
Registry types are lowered as the builder ABI expects: REG_SZ and
REG_EXPAND_SZ to strings (NUL termination stripped), REG_DWORD and
REG_DWORD_BIG_ENDIAN to integers, REG_QWORD to a signed 64-bit
integer, REG_MULTI_SZ to a list of strings. Any other type in a rule
refuses the walk: atomic transitions prefer a loud rejection over a
silently half-read rule. So does an integer of the wrong length — a
REG_DWORD or REG_DWORD_BIG_ENDIAN whose data is not 4 bytes, a
REG_QWORD not 8 — in a rule or in CurrentReportingLevel.
Everything the stage feeds the builder — each layer and whether its key exists, rule name and depth, value name, type and bytes — also goes through an FNV-1a digest, and so does the reporting level's value (not its bytes: the same level re-written as another integer type digests the same). When the digest equals the one of the last walk that published (none before the first), the stage publishes nothing: the built forests are freed and the generation does not move. The active generation already is this policy — even when that policy is no forests at all — and republishing would only make every sentence stale for nothing. A walk fired by an inventory write therefore publishes no rules; it moves the generation only if the context stage finds the table changed (below). The writes of a transaction reach the watch together at its commit and, like any burst, coalesce into one walk.
6.5.2.1 The context stage #
The inventory is read into one struct peios_ntfe_context_table, at
most 64 entries of interface name, network id, name and trust:
- every child of
Networks\is a record; its key name is the id (a UUID — a name of 40 bytes or more, which no id field can hold, is ignored with its record, once loudly), and oneRSI_QUERY_VALUESround trip reads itsNameandTrust(REG_SZ; absent or unreadable is empty, and the id is still a fact), up to 256 records; - every child of
Interfaces\is an interface; itsStatussubkey is found by oneRSI_ENUM_CHILDRENand read by oneRSI_QUERY_VALUESforName(the kernel interface name) andNetwork(the id netd identified on it). Both present make an entry, joined to the record of that id for its name and trust; either absent — no link, no offer yet, anIGNOREd orDOWNinterface — makes none.
Nothing in the stage refuses a generation. A record that cannot be read is
logged and skipped — an interface key whose subkeys cannot be listed,
or whose Status cannot be read, carries no context; a network record
whose values cannot be read keeps its id with no name and no trust;
an interface beyond the 64th carries no context, a value
longer than its field is truncated with one warning. If the list of
Networks\ or Interfaces\ itself cannot be read, the stage publishes
nothing and the previous table stands: a table built from half a list
would strip every network after the failure point of its name and
trust, and with them every rule that names them. That failure is the
walk's error — it shows in last_ingest_error — and the rules stage's
outcome is untouched by it. Otherwise the table goes to peios_ntfe_context_publish() (§6.3): if it equals the active
one entry for entry it is freed and nothing happens; otherwise it is
rcu_assign_pointer()ed into place, the generation counter advances,
the old table is freed after grace, and the kernel log says how many
interfaces carry a context. The generation advance is the whole
mechanism by which a context change reaches running flows: every
sentence is now stale and is re-judged on its flow's next packet
(§6.8), the same path a rule change takes.
6.5.3 Building and validating #
The builder (ntfe_rust_builder_*) accumulates RuleInput trees; build
runs pnp_core::ingest::build_forest, which parses every condition key
and action expression, orders each rule's conditions with the live-time
ones last (§6.4), resolves priority inheritance, lints layer-impossible
facts (a FlowState or tag condition in a RawPacket forest; a
per-packet fact — Length, TcpFlags, Fragment, Ttl, Dscp,
EtherType, DstMac, FlowState — in a Flow forest; Related or
Start.* anywhere but Flow — all legal, and built never to hold; the kernel drops
lints, the authoring surface shows them), and collects the forest's
name sets: every tag name mentioned in a TAG action, a PROMPT
fallback, or a Tag.<n> condition, split into the names the forest
writes and the first rule reading each; every stream a COUNT writes;
every distinct counter view a Counter.<n>(...) condition reads, with
the first rule and value that mentioned it. Views are deduplicated and
conditions refer to them by index — that index is what the bridge uses
when it fills counter_views (§6.3).
Refusals, each carrying the offending rule's path (BuildError):
unknown fact, unsupported operator, unparsable pattern, a counter view
that does not parse (bad duration, unknown key fact, duplicate
arguments, a window over the one-day horizon), a non-list Actions, an
unparsable action, a REJECT kind that is not Refused or
Prohibited, a PROMPT chain nested deeper than MAX_PROMPT_CHAIN
(4), refused as an unparsable action, an action the layer does not
speak (JOIN, IGNORE or DOWN, the interface layer's verdicts, in
a kernel layer, directly or as a PROMPT fallback), a Present
condition on a fact that never exists at the rule's layer (every other
operator over such a fact is only linted, below; Present looks
through the absent-fact law, so X.Present = 0 there would always
hold), a Priority that is not an integer, an Enabled that is
not 0 or 1, a rule name that is empty or contains a path separator
(a backstop: LCS refuses such a key name before the walk could read
it) — and, over the name sets, two distinct tag names (or stream names)
whose 64-bit hashes collide.
After all three layers build, ntfe_rust_forests_check() runs the checks
that span forests, because the stores are machine-wide: tag and stream
hashes must be distinct across every forest; every counter view must
have a writer in some forest — a view over a stream no rule writes is
statically dead (it can only ever read absent) and is refused with the
rule and key that read it; and no forest may read a tag a higher
forest writes (RawPacket < Packet < Flow) — tags flow strictly
upward, and since rules are the only source of tag names the downward
read is refused statically, with the reading rule and the name.
6.5.4 Publication #
peios_ntfe_policy_publish(packet, raw, flow, reporting_level) is
process context under a mutex. It runs the cross-forest check, then
materializes the counter store for the union of every forest's views
(§6.6) — so a store that cannot be built (allocation, more than eight
windows on one table) refuses the generation before anything is
swapped. Only then does it advance the generation counter, allocate the
new struct peios_ntfe_policy (three opaque forest pointers plus the
reporting level) and rcu_assign_pointer() it into place. Hook-path
readers dereference it under rcu_read_lock() and never block; they see
the old generation or the new one, never a mix. The old policy is
released by call_rcu(), and its forests by ntfe_rust_forest_free() in
the callback after grace.
Every walk records its outcome: last_ingest_error (0, or the positive
errno of the last failed walk) and last_ingest_t_ns, both in the
status. A refusal leaves the previous generation active and says so in
the kernel log. contexts in the status is the number of interfaces in
the active context table.
6.5.4.1 In the event stream #
NTFE writes its own lifecycle to KMES, origin class KMES_ORIGIN_NTFE
(4), beside the reports of §6.6. Every publication writes one
ntfe.policy.published, after the swap; a refusal writes none.
Its payload is one policy map: generation, the generation now in
force; generation-previous, the generation of the policy it replaced,
0 for the first since boot; layers, the layers that have a forest, in
the order raw-packet, packet, flow, and an empty list for a policy
of no forests; and report-threshold and report-threshold-previous,
the new and the replaced CurrentReportingLevel, the latter 1 when
nothing was replaced. A walk
whose digest is unchanged publishes nothing, and so writes nothing.
A context-table change advances the generation without a policy and
writes no ntfe.policy.published either, so the generations between
two of these events are the earlier policy under a changed context.
A refused rules walk writes one ntfe.policy.rejected.
Its policy map holds the generation still in force and
previous-retained, always true. Its outcome map holds errno, the
walk's negative errno, and reason. When the refusal is in, or names,
one rule, a rule map follows. It holds layer when the refusal arose
building one layer's forest, and action-error for a bad-action. It
holds name, the rule's path relative to its layer key; a path longer
than 255 bytes is cut at a character boundary, with name-truncated.
The errno is the walk's, unchanged. The reason is new: the bridge's
ntfe_rust_builder_build_why() and ntfe_rust_forests_check_why()
fill a struct peios_ntfe_build_why from pnp-core's BuildError. A
builder refusal keeps its own name and its rule's path, such as
unknown-fact, bad-action with its action error, bad-priority, or
tag-downward-read from the cross-forest checks, which name the rule
but no layer. The walk names the refusals it makes itself:
rule-too-deepandtoo-many-rules, with-E2BIG;bad-value-type,bad-value-lengthandnot-utf8, in the rule that holds the value;bad-reporting-level,counter-store-refusedandout-of-memory;registry-read-failedfor a source round trip that failed.
A walk that finds no Rules key writes the event too, with reason
no-rules-key, no errno and no rule.
Every walk re-reads the policy, including the walks netd's inventory
writes cause, so a broken policy is refused again and again until it
is fixed. The event is written once per distinct refusal: a walk whose
digest, errno and reason equal those of the last refusal written writes
none. A walk the rules stage accepts, published or unchanged, clears
that memory, so the next refusal is written whatever it is. A missing
Rules key is therefore written once each time the key goes missing.
The kernel log line is written for every refusal, as before, and now
names the reason. Both events are built on the stack in process context,
under the publication mutex or the walk mutex; neither is ever written
from the packet path.
6.5.4.2 In force #
A registry write that has returned is delivered, not enforced: the walk
that reads it runs after the debounce. Two status counters say which a
writer is looking at. changes_noted is incremented by
peios_ntfe_network_registry_changed() for every watch event, and LCS
delivers its internal watches inside the write that caused them, after
the commit and before the syscall returns, so a writer that reads the
status after its write sees its own change counted. The deferred work
reads changes_noted under the same lock that clears the pending flag,
before it walks, and stores that value in changes_walked when the walk
returns, whether it published or was refused.
So a writer is in force once changes_walked reaches the changes_noted
it read after writing: the walk that satisfied it started after the
write committed. A change that lands mid-walk raises changes_noted
again and re-arms the work, so the pair stays unequal until a later walk
covers it. A refused walk still advances changes_walked; the writer
then reads last_ingest_error to learn that what it wrote is not what
is enforced. The walk records its error before it stores
changes_walked, and the status reads them in the other order, so a
status that shows a change walked shows that walk's error or a later
one's, never an older one. The bootstrap walk is not a noted change and moves neither
counter. net policy wait is this loop.
6.5.5 Generation 0 #
Until the first successful ingestion there is no policy at all, and
every layer is permissive: judged stays 0, permissive counts
traversals, no events are emitted (there is no decision to attribute),
and the status reports enforcing = 0. The init log line says it
plainly. This is the ratified loud default — the alternative, a
compiled-in policy the registry cannot see, was rejected.
A new generation does not touch the flows: every sentence written under the old one is stale by generation and is re-judged on its flow's next packet (§6.8). That is how a policy change — and a context change, which advances the same counter — reaches running connections without a walk of the conntrack table.
6.6 The stores
Peios / Peios technical reference / PKM / NTFE
Effects need somewhere to land. Three stores, each with a small C surface the bridge calls during evaluation, each confessing what it refuses into the engine status.
6.6.1 Identities #
Tag and stream names cross into the stores as 64-bit FNV-1a hashes
(pnp_core::hash::name_hash). The stores never see a string on the
packet path, and never a generation: a flow's tag table holds (hash, value) pairs, a counter table is keyed by its stream's hash. Two
consequences: tags survive policy reloads by construction (the table
knows nothing to invalidate), and a hash must be a deterministic
identity, not a probabilistic one — which ingestion guarantees by
refusing any generation whose distinct names collide (§6.5). Within a
running policy a collision is impossible; across generations the
residual is a 64-bit birthday bound over a handful of names, documented
and not defended.
6.6.2 Flow tags #
A tag is a named unsigned integer on a conntrack entry. The store is the
tags pointer of NTFE's conntrack extension — NF_CT_EXT_NTFE,
struct peios_ntfe_ct (include/linux/peios_ntfe.h), added to every flow
at creation (init_conntrack(), by the ntfe-conntrack-ext.patch), with
the pointer NULL until the flow's first TAG. The extension block of
a confirmed conntrack entry is immutable (upstream removed post-confirm
resizing as an RCU-reader race), and NTFE's egress seat runs after
confirmation, so the extension must exist before it is needed; a fixed
extension on every flow is the cheapest way to guarantee that. Since the
Flow slice the extension also holds the flow's start time and its two
sentences (§6.8) — 88 bytes per flow, most of it the sentences — while
untagged flows, nearly all of them, still pay nothing for a table.
The first TAG allocates (GFP_ATOMIC) a table of eight (hash, value, present) entries; a full table is replaced by one twice the size —
copy, rcu_assign_pointer(), kfree_rcu() the old — up to the tripwire
of 64 distinct tags per flow. Readers walk the table under RCU with no
lock (the hook path already holds the read lock); writers serialize on
the flow's own ct->lock. Clear tombstones an entry (present = 0)
rather than compacting, so a concurrent reader never sees the table
shift under it, and a later Set reuses the slot. Add saturates at
U64_MAX. The entry's identity is published before the length that
exposes it (smp_wmb() then WRITE_ONCE(len)), so a reader that sees
the new length sees a complete entry.
When the flow dies, nf_conntrack_free() calls peios_ntfe_ct_destroy()
(the same patch), which kfree_rcu()s the table — a reader that found
the entry under RCU may still be walking it.
Confessions: tag_writes (ops applied), tag_untracked (a TAG on a
packet with no flow — untracked traffic, or the ingress seat — is a
no-op), tag_refused (the tripwire, an allocation failure, or a flow
whose extension could not be allocated at creation). Clearing an absent
tag is a no-op, not a refusal.
6.6.3 Counters #
COUNT(Name[, amount]) emits into a stream; every
Counter.Name([window][, key]) a rule reads is a view. Nothing is
declared: views are compile-time constants (rules are their only
source), so at publication (§6.5) the store receives the complete view
set of all three forests — Packet, RawPacket and Flow — and materializes exactly that — one table per
distinct (stream hash, key-spec), each answering every window any
view of that pair asks for, plus the cumulative total. A COUNT
increments every table of its stream; the amplification is bounded by
what policy authors wrote, the trusted side of the trust asymmetry.
A table is a 1024-bucket hash of cells (jhash over the cell key)
under a per-table spinlock (_bh, since the publisher runs in process
context). A cell key is built from the packet by the table's key-spec:
address family, source and destination address (16 bytes each, v4 in
the first four), interface index — only the facts the spec names, the
rest zero. A packet lacking a keyed fact (an ARP frame for a
SrcAddr-keyed table) has no cell: its COUNT no-ops into that table
(count_key_absent) and its view reads absent — the absent-fact law on
both sides.
A cell holds a cumulative total, the seconds of its last write, and
one ring per table window: eight buckets, each stamped with the
period it belongs to (now / bucket_secs, where bucket_secs = max(1, window / 8)). A write zeroes a bucket whose stamp is stale
before adding; a read sums the buckets whose stamp is within the last
eight periods. Advancement is lazy — there are no timers — and the
window is an approximation up to one bucket wide, by design.
The keyspace of a keyed table is chosen by whoever sends packets, so
tables are hard-capped at 4096 cells. At the cap the store first
reaps cells idle for longer than the table's longest window (floor
60 s); if none are idle the new key is refused and confessed
(count_refused). Never silent eviction.
The store outlives generations. Re-publication keeps tables whose window
set did not change, creates new ones, migrates tables whose windows
changed (each cell is re-allocated in the new ring layout; the total and
any window both sets share carry over, new windows start empty and
converge), and retires tables no forest views any more — list_del_rcu()
then free after grace, cells included. Reads on the packet path walk the
table list and the cell lists under RCU; the publisher holds a mutex.
PEIOS_NTFE_IOC_COUNTERS dumps every cell of every table for the viewer:
stream name, key-spec, the key, the total, the last-write time, and the
current value of each window. It is a best-effort snapshot, which is fine
for counters that are approximate by design: the cells are gathered
into a kernel batch under one RCU read section and copied out after
it, and a store with more cells than a batch holds (4096) takes
further passes, each resuming after the cells already written, so a
cell created or retired between passes may be missed or seen twice.
6.6.4 Reports #
REPORT(level) at or above CurrentReportingLevel becomes one KMES event:
origin class KMES_ORIGIN_NTFE (4), event type ntfe.verdict.reported, and a
msgpack payload laid out as the event catalogue's ntfe fragment
says: nested string-keyed maps, one per path segment. rule carries
the attribution (name, the path, and hash, the FNV-1a-64 of the
whole path), the report-level, and where the judgment stood
(layer: raw-packet, packet or flow; seat). outcome carries
what it said (verdict: pass, reject or drop; and reason,
prohibited or refused, when it was a reject). The packet is
network (direction, interface with its name and index,
ether-type, family as the AF_* number, protocol, length),
source and destination (each an address as text and a port),
and flow (state). policy carries the generation. The time is
the KMES header's; the payload does not repeat it. A packet key whose fact can be
absent is present only when the packet has it: network.protocol,
source and destination when it has an address family, their ports
when it has ports, flow when the flow-state fact is present.
network.direction, network.interface.index, network.ether-type,
network.family and network.length are always emitted — an
interface index of 0 and a family of AF_UNSPEC (0) are the catalogue's
own values for none. network.interface.name is left out when no
device was there to name; no key is ever written as an empty string.
The payload is built on the stack (512 bytes) because the packet path runs in softirq and
pkm_kmes_emit_kernel() is a preempt-disabled per-CPU ring write with
no allocation of its own. Every map's size is known before it is
written, save rule's, whose one-byte header is settled once the
name's fate is. Every value but rule.name is bounded, so rule goes
in last and name last within it: a path too long for the room left
is cut at a character boundary to fit, and rule.name-truncated
(true, absent when the name is whole) says so. The event is always
emitted, and rule.hash still names the whole path, so a reader
resolves the rule against the policy. Flood control is the author's by design — the
level gate — with KMES's own ring accounting as the backstop.
reports_emitted counts what reached the ring.
6.7 The event stream
Peios / Peios technical reference / PKM / NTFE
Every real evaluation — a published forest judged the traversal, or a
fail-closed drop — appends one struct peios_ntfe_event to a bounded
ring. Permissive traversals emit nothing: there is no decision to
attribute, and the status tells that story instead. A packet answered by
its flow's cached sentence emits nothing either: there was no
evaluation, and flow_cached counts it. The layouts are in §6.A; this
page is what they mean.
6.7.1 One event #
An event is the decision and enough of the snapshot to find the packet
it was about: sequence number, CLOCK_REALTIME nanoseconds, the seat
and layer, the verdict and — when it was a reject — its kind, flags
(BACKSTOP, FAIL_CLOSED, REJECT_DEGRADED, REJUDGED for a Flow
evaluation that replaced a stale sentence, IDENTITY_UNRESOLVED for
one whose endpoint could not be attributed), direction, address family,
protocol, flow state, interface index, ports, ethertype, both addresses,
the stack-view length, the effect counts the evaluation yielded packed
eight bits each (tags, counts, reports, prompts, saturating), and the
attributing rule's path — the winning rule, or backstop, or
fail-closed — in a 96-byte field (PEIOS_NTFE_EV_ATTR_LEN), so a
longer path is cut to 95 bytes, at a byte rather than a character
boundary, and NUL-terminated. A Flow event also carries both
endpoints' identities as the judgment read them (§6.9): the kind, the
process GUID, pid and comm, the user SID and the service SID — binary
SIDs, so the viewer, not the kernel, turns them into names.
The path is relative to the layer key: no-inbound/ssh, not
Machine\System\Network\Rules\Packet\no-inbound\ssh.
6.7.2 The ring and its reader #
The ring holds 4096 events under a spinlock (irqsave: emission happens
in softirq). A full ring overwrites the oldest event and counts the
loss in events_dropped — the honesty rule: a slow reader loses data
and is told so, in the status and by the gap in sequence numbers.
/dev/peios-ntfe is a misc device, mode 0600. Any number of files may
be open on it, because the status and dump ioctls are asked by tools
while a viewer holds the stream, but the ring has one drain: the first
file to read() claims it until it closes, and another file's read()
returns -EBUSY meanwhile. read() returns whole
records only, up to 64 per call, and blocks on an empty ring unless
O_NONBLOCK; poll() raises POLLIN when events wait. A reader that
reconnects resumes from whatever the ring still holds, and catches the
gap from the sequence numbers.
The viewer daemon reads it with a small wrinkle worth knowing about: its
own HTTP responses to a viewer are judged traffic too, so every event it
sends produces another event — a feedback loop. pnpd hides events about
its own TCP port from what it serves and counts them (own_verdicts_hidden),
which is the same treatment the wire tap gives its own frames.
6.7.3 Status #
PEIOS_NTFE_IOC_STATUS fills struct peios_ntfe_status: the ABI version
(check it before trusting the rest — the ABI is experimental and
versioned, currently 5), the generation, whether any layer is enforcing,
the ring's confessed drops, and the engine counters. The counters are
plain 64-bit atomics rather than per-CPU — legibility over throughput
while the engine is young, to be revisited with a compiled evaluator.
Two invariants a reader can check: judged equals the number of
evaluations against a live forest at any layer (Flow evaluations
included, so judged − flow_judged is the per-packet count), and
permissive counts layer evaluations that found no forest — at
generation 0, every one.
PEIOS_NTFE_IOC_COUNTERS is the counter dump described in §6.6: the
caller supplies a buffer of struct peios_ntfe_counter_rec, the kernel
fills as many as fit and reports both how many it wrote and how many
cells exist, so a short buffer is visible. PEIOS_NTFE_IOC_FLOWS is the
flows dump described in §6.8, with the same short-buffer contract over
struct peios_ntfe_flow_rec.
6.7.4 Confessions, collected #
Everything NTFE declines to do is counted somewhere in the status, and the viewer shows every one of them. A reader should never have to infer a refusal from a missing effect:
- a
REJECTit could not send →reject_degraded, and the event's flag; - an evaluation it could not finish →
fail_closed, and an event; - a frame it could not describe →
parse_errors; - a tag it could not write →
tag_untrackedortag_refused; - a count it could not land →
count_key_absentorcount_refused; - a sentence it could not keep →
flow_uncached; - an endpoint it could not attribute →
identity_unresolved, and the event's flag; - a packet it did not judge because a sentence answered →
flow_cached; - a refusal it did not judge because it was its own →
refusals_bypassed; - a generation it could not accept →
last_ingest_error, and the log; - an event it could not keep →
events_dropped.
6.8 The Flow layer
Peios / Peios technical reference / PKM / NTFE
The Flow layer (Rules\Flow, the rung-2 design on PEI-598) is the same
rule atom, verbs and evaluation as the per-packet layers, applied to a
different unit: the flow, what conntrack tracks. It exists because
the per-packet evaluator has no early exit — collation and
side-effects-always mean every packet walks the whole forest, so an
Established → PASS rule saves nothing — and because flows carry facts
(a start time, relatedness, one day an owner) and effect units (a
connection, not a packet) that packets do not. The Packet layer becomes
the cheap filter in front; the decisions move here and run once per
connection.
6.8.1 One judgment per local endpoint #
peios_ntfe_flow_dispatch() (flow.c) is called at the IP seats:
inbound, at LOCAL_IN, for every packet the Packet layer passed;
outbound, at LOCAL_OUT, for every packet, before any Packet judgment
— the Packet layer judges outbound traffic at egress, after it (§6.2).
An untracked packet (snap->flow == NULL) has no flow to judge and is
accepted, NF_ACCEPT: inbound the Packet verdict stands, outbound the
Packet layer judges it next at egress. A
tracked packet reads its flow's sentence:
- a current sentence — its policy object and policy/context invalidation
generation are both current, and its expiry (if any) has not passed — is applied
without evaluation (
flow_cached); - otherwise the Flow forest is evaluated against the snapshot
(
peios_ntfe_policy_snapshot_eval(), counted injudgedandflow_judged), the outcome is written as the new sentence, an event is emitted (withREJUDGEDwhen a stale sentence was replaced,flow_rejudgedorflow_expiredsaying why), and the verdict is applied. A refusal goes out before the event, so the event can confess a degradation.
No Flow forest at all (generation 0, or no Flow key) is permissive,
counted, and caches nothing.
Flow dispatch holds one RCU policy snapshot while checking layer presence, preparing identity facts and evaluating. The sentence retains that policy object's generation as an internal stamp alongside the existing invalidation generation, which also changes when network context changes. Both must match before either endpoint's cached verdict can be reused. A packet already evaluating an old policy may finish; its old verdict cannot remain current after a new policy is published. The extra policy stamp does not change the flow-dump ABI.
On a cache miss, Flow refreshes Network.* from the active context table
for the packet's traversal interface after capturing the invalidation
generation. It clears the old fields first, so removing a context makes
those facts absent. Context publication swaps the table before advancing
the generation; the reader's acquire of that generation precedes its
context lookup. Thus a packet snapshot taken before publication cannot
leave an old network verdict cached under the new generation. A context
published after the captured generation makes the resulting sentence
stale on the next traversal, even if that evaluation already saw the new
facts. A current cached sentence needs no extra context lookup.
What the Flow forest judges is the flow view, not the packet's
snapshot: a Flow fact is one identical for every packet of the flow, so
ntfe_flow_view() builds it from the flow. A reply-direction packet's
addresses and ports are swapped back to the original tuple (and its
ICMP type replaced by the tuple's); the direction is the originator's,
recorded at the first judgment along with the interface, the VLAN, the
peer's MAC and the endpoints' identities (§6.9), so a re-judgment on a
reply sees exactly the facts the first judgment saw. (Found live before the fix: an inbound viewer flow
re-judged on its reply packet as out, and matched outbound-ok.) A
loopback flow's view takes the slot's direction. The refusal, when the
verdict is one, answers the packet in hand; the event describes the flow
as judged.
A normal flow has one local endpoint and one sentence, slot 0, written
at the originator's seat on the first packet; the reply direction, and
every later packet, reads it. Direction in the judgment is the
originator's side. A loopback flow has two local endpoints and two
sentences: the outbound one (slot 0) at LOCAL_OUT and the inbound one
(slot 1) at LOCAL_IN, both on the same first packet, and every packet
of it answers to the stricter of the two (DROP > REJECT(Refused) >
REJECT(Prohibited) > PASS). The comparison is made before any
refusal is sent: a REJECT overruled by the other end's DROP sends
nothing, and one overruled by the other end's stricter REJECT sends
that one's kind. Loopback-ness is the seat's device
(IFF_LOOPBACK, snap->loopback); a stale other-endpoint sentence is
not applied — it is that seat's to refresh when it next sees the flow.
6.8.2 The sentence #
struct peios_ntfe_sentence lives in NTFE's conntrack extension
(include/linux/peios_ntfe.h), two per flow: the invalidation generation
that judged (0 = empty), the generation of the policy object that judged,
expires_at (epoch seconds, 0 = never), the FNV-1a-64 hash
of the attributing rule's whole path (the outcome's attributed_hash:
the event's attributed text is cut short, the hash never is; the same
identity the tag and counter stores use for names, so the viewer
resolves it against the policy), the verdict and the reject kind. Alongside: start_secs, stamped when
conntrack created the entry (peios_ntfe_ct_ext_add()) — the Start.*
facts — and, from the first judgment, the interface, the direction and
whether the flow is loopback, for the dump.
NTFE shares conntrack extension storage with accounting and other kernel features. Its allocation sizes and offsets use 16-bit fields; the build-time capacity guard includes the NTFE payload and alignment padding for every allocation order. Adding another extension must not wrap the total length or overwrite an existing extension. These fields are kernel-internal and do not change the flow-dump ABI.
Writes take the flow's lock (ct->lock, _bh), zero the generation
first, write the fields, and publish the generation last with a release
store. Each slot also has a modification
sequence, advanced at the start and end of every write, including a
refresh under the same policy generation. The policy generation
identifies the policy; it cannot detect every cache update.
Reads are lock-free on the hook path and in the flow dump. They make one attempt: an active writer or a changed modification sequence makes the sentence read as absent. Readers neither spin nor acquire the flow's lock. On a cache miss the flow is evaluated as usual. Two packets of a new flow racing on two CPUs may both evaluate; the second sentence write wins (both judged the identities the first resolution recorded, §6.9), and the effects ran twice — the only place NTFE tolerates that, because the alternative is a lock on the fast path for a race that needs a flow's first two packets to arrive concurrently.
The cache holds the verdict only. Effects run at every evaluation of the
flow and never per packet. DROP and REJECT sentences persist for the
life of a flow conntrack has confirmed: a cached REJECT refuses every
subsequent packet of it. A DROP or REJECT on a flow's first packet
at a seat that stands before confirmation — LOCAL_IN for an inbound
flow, LOCAL_OUT for an outbound one — leaves nothing to persist:
nothing kills the entry, but its packet is dropped before conntrack
confirms it (the refusal is filed as the entry's reply, which confirms
nothing), so the unconfirmed entry dies with the packet, sentence and
all. A retransmitted SYN is then a fresh flow, judged again — effects
included — and refused again. So the cached refusal answers only on a
confirmed flow: one re-judged mid-life, or a loopback flow's inbound
end, judged at LOCAL_IN after POST_ROUTING confirmed the entry. A
Refused reset to a confirmed TCP flow also moves it to conntrack's
CLOSE state (nf_ct_set_closing()), whose short timeout ends it. A flow whose extension could not be allocated has nowhere to hold
a sentence and is evaluated on every packet (flow_uncached).
6.8.3 Staleness #
A sentence is stale when its invalidation generation is not the current
one (a policy published, or a network context published — §6.5 — both
advance the one counter), when the policy object that judged it is not
the current policy object, or when t_secs >= expires_at. Both are checked lazily, on the flow's next
packet — an idle flow past a policy change is killed when it next
speaks, or conntrack times it out; there are no timers and no walk of
the table at publication. Grandfathering was rejected: the registry must
not lie about what is enforced, and a REJECT rule must be able to
reject something already running. A refused packet of an
established TCP flow tears down both ends at once (§6.2): the end that
sent it is refused with the kind's story, and the packet, turned into a
reset, is sent on to the other end. Before the teardown existed, a
killed viewer stream froze at the local end while the silent host peer
waited for its own next packet to meet the cached REJECT sentence —
correct under the lazy law, and half a kill.
The expiry is the evaluation's expires_at (§6.4): the earliest moment
any live-time condition the judgment consulted would flip. A forest
with no time conditions never expires a sentence; Start.* conditions
never contribute, which is the point of them.
6.8.4 The flows dump #
PEIOS_NTFE_IOC_FLOWS walks conntrack's table the way ctnetlink does —
local_bh_disable(), each bucket under its nf_conntrack_locks lock,
original-direction entries of init_net that are neither expired nor
dying — and fills struct peios_ntfe_flow_rec per flow: conntrack's id,
family, protocol, the original tuple (ports, or ICMP id and type/code),
seen_reply/assured/related, the remaining lifetime, packet and
byte counts (NTFE turns conntrack accounting on at init — the
net.netfilter.nf_conntrack_acct sysctl, init_net.ct.sysctl_acct —
since it is conntrack's consumer now), and the extension: start time, first-judgment interface
and direction, loopback, both sentences, both ends' identities, and up
to eight tags by hash with the flow's total tag count.
Records are batched in kernel memory (32 to a batch) and copied to user
between passes over the buckets, never under a lock; a bucket holding
more flows than the batch has room for is walked again under its lock
from where the batch filled, rather than losing the rest. The walk counts every live flow it saw so
a short buffer is visible, and is best-effort against a table that
changes under it.
6.8.5 What was decided against #
- Backstop inheritance (the flow's verdict as the Packet layer's compiled-in default): unnecessary once Packet is the filter in front and passes tracked traffic by shipped configuration.
- Two layers, Inbound and Outbound: direction-blind rules are common and shadowing gives the direction-specific exceptions; the posture is a visible root rule; per-seat direction pruning is a future evaluator optimisation, invisible to authors.
- Effects on first judgment only: a kill by
REJECT, REPORT(n)must report; theCOUNTnoise is per generation, not per packet. - Identity in the sentence. The sentence caches the verdict; the identities the judgment read are recorded beside it (§6.9), not re-derived per packet.
6.9 The identity facts
Peios / Peios technical reference / PKM / NTFE
The identity facts (rung 3 on PEI-598) answer the question the address
facts cannot: not where a flow goes but who on this machine is
speaking or listening. They are Flow-layer facts by the same law as
Related and Start.* — a flow has one answer for its whole life — and
they are the first facts NTFE reads from somewhere other than the packet.
KACS provides them; the engine resolves them once per flow.
6.9.1 What KACS stamps on a socket #
Every inet socket carries a governing identity, recorded by KACS in
the socket's security state (§3.12.2): the caller's effective token —
so a service thread impersonating a client attributes the socket to the
client, as audit does — and the process facts of that moment: the
process GUID, the thread-group id and the comm of the thread that
made the stamp (a thread's own name, which need not be its process's). The stamp is
taken at every act that commits the socket to a role: creation, bind,
listen, connect, inheritance at accept, and KACS_SO_RESTAMP. The
last stamp governs, which is how a listener handed to another program
(socket activation, descriptor passing) is governed as that program's.
A kernel socket is stamped as the kernel's, with no token.
The engine reads the stamp through one accessor,
pkm_kacs_socket_owner() (<linux/peios_ntfe.h>), which hands it a
counted reference to the token and a copy of the process facts. The
facts outlive the process; the reference outlives the socket.
6.9.2 What stands at an end #
identity.c classifies each local end of a flow by whether anyone
answers, not by whether a socket structure exists. The result is the
Local fact (program, kernel, shared, none) and, for a program,
the principal behind Local.*.
Outbound, at LOCAL_OUT: the sending socket's stamp. A stamped
program socket is program; a kernel socket, or no socket at all
(resets, ICMP errors, IGMP) is kernel.
Inbound, at LOCAL_IN, a transport lookup for the receiver of this
very packet:
- UDP to a multicast or broadcast destination is
sharedbefore any lookup: the stack delivers it to every socket bound to the port — one flow, many endpoints — and the per-program question belongs to the join, not to the packet.Local.*is absent. - TCP, UDP and UDP-Lite are looked up by tuple, the way the netfilter
socket match does — listeners included, and through the same hash a
SO_REUSEPORTgroup steers by, so the sentence names the socket that will actually receive. Early demux may already have found it, and its socket is used in place of the lookup — for UDP only once the datagram has passed thesharedtest above, since early demux also attaches the one socket a multicast datagram happens to match. An early result is used only if its socket still has a live reference that NTFE can pin. UDP early demux keeps the pointer valid under RCU but does not itself hold a reference; if the socket's last reference has gone, NTFE falls back to the ordinary tuple lookup without reviving the socket. A missing receiver then gives the samenoneresult as an ordinary lookup miss. The temporary socket reference is released after reading the identity. Any other protocol is looked up among raw sockets bound to it. - A socket found is
program(a request minisock stands for its listener; aTIME_WAITminisock is nobody's:kernel). - Nothing found:
kernelif the stack has a handler registered for the protocol — ICMP and ICMPv6 (neighbour discovery, router advertisements, MLD), IGMP, the tunnel and IPsec outers that are decapsulated and re-enter the ingress seat as their inner packet — elsenone, which the stack will answer with a reset or an unreachable.
The classification is a rule, not a list of protocols. Two consequences
are documented rather than special-cased: SCTP, a socket transport whose
table lives inside its module, reads kernel while the module is
loaded; and a ping socket's echo reply pairs with the outbound request in
conntrack and inherits the outbound program sentence, so the direct
lookup is deferred.
The lookup runs once per flow, on the first judgment, and only when
a Flow forest is published: a permissive machine pays nothing.
6.9.3 Loopback: both ends #
A loopback flow has two local ends and two sentences (§6.8). The
outbound seat resolves both on the first packet — its own end from the
socket, the other by running the receiver lookup early on the
loopback-destined packet — and records them on the flow's extension.
The inbound seat's judgment, on the same packet, reads both from there:
Local.* is its own end and Remote.* the sender's. The inbound seat
cannot see a loopback packet's sender itself (loopback transmission
orphans the buffer), so a flow whose extension could not be allocated
reads Remote as absent there, confessed. Off loopback Remote is
always absent: nothing is provable about a peer yet.
6.9.4 Fixed for the flow's life #
The identities are recorded on the conntrack extension at the first
judgment, per slot, beside the direction and the interface, and never
replaced: a re-judgment after a policy change or a time edge sees the
same principal, and a later restamp of the socket, a fork after
accept, or a SO_REUSEPORT sibling taking over the port changes
nothing for flows already judged. The extension holds one counted token
reference per slot and releases it when conntrack frees the flow. Two
CPUs racing on a new flow's first packets may both resolve; the first
record stands — unlike the sentence, where the second write wins
(§6.8) — and the loser releases its reference and judges the recorded
identity instead of its own.
6.9.5 Across the bridge #
The flow view carries, per end, the kind, the process facts and a
borrowed token pointer. The bridge (kacs/ntfe_runtime.rs) implements
pnp-core's Principal trait over the token — user SID, enabled-group
membership (deny-only groups are invisible to policy), integrity level,
confinement SID and capabilities, the per-service SID found among the
enabled groups, the process GUID as text (PCDS's string form —
Data1, Data2 and Data3 read as little-endian numbers and written
most significant digit first, Data4 bytes in order — lowercase and unbraced, 8-4-4-4-12; ingestion lowercases a
Local.Process or Remote.Process pattern and strips braces from a
braced one to match) — without copying the group
list: a token may carry a thousand groups and the judgment runs in
softirq context, so the snapshot borrows a view and asks membership
questions of it. The view is lock-free: group SIDs are fixed at token
creation and each group's attributes are an atomic.
The judgment compares SIDs and nothing else; what NTFE knows of a SID's
meaning is only what pnp-core compiles into it — the service-SID
derivation and a table of well-known names — used once, at ingestion,
to turn a name a rule wrote into a SID. The engine holds no directory
of principals and resolves no SID back to a name. Local.Service.Equal = resolvd is turned into the service's SID at ingestion by pnp-core,
with the same derivation peinit and authd use to mint it (the SHA-1 of
the uppercased UTF-16LE name under S-1-5-80); Local.User.Equal = LocalService by a table of well-known names. The viewer resolves the
other way, from the service definitions in the registry.
6.9.6 What the stream says #
A Flow event and a flow record carry both ends: the kind, the process
GUID, pid and comm, the user SID and the service SID (ABI 4, §6.A,
§6.B). An end that could not be attributed — a socket with no KACS
state, an inet socket nobody stamped, a loopback sender the inbound
seat could not see — is confessed: identity_unresolved in the status,
PEIOS_NTFE_EV_F_IDENTITY_UNRESOLVED on the event, and the flag on the
flow record's slot.
6.9.7 At rest: the listeners dump #
The same stamp, read without a packet: PEIOS_NTFE_IOC_LISTENERS
(listeners.c) walks the listening TCP hash and the bound UDP and
UDP-Lite tables of the root namespace the way /proc/net/tcp and
/proc/net/udp do — each bucket under its lock, records copied out
between buckets — and reports every socket prepared to receive with the
identity that governs it. It is the attack surface as a list, by whom,
and it exercises the stamp before a single flow is judged.
6.9.8 What was decided against #
- Identity in the per-packet layers. A packet carries no owner; the
socket does.
Local.*in aPacketrule is linted as never present, andPresenton it refuses the generation. - Many sentences for a shared receiver. Inbound multicast is one
flow with many endpoints; modelling N judgments would have made the
join-time question a per-packet one.
sharedand a later gate onIP_ADD_MEMBERSHIP(C11) instead. - Copying the group list into the snapshot. Up to 1024 SIDs per
token, in softirq: the
Principalview answers membership instead. Program.Trustand the image hash. PIP is under review, and unsigned binaries are not hashed at exec today; both wait.
Appendix 6.A NTFE ABI Reference
Peios / Peios technical reference / PKM / NTFE
Every name, value, offset and size in this appendix is generated from
pkm/uapi/pkm/ntfe.h by pkm/tools/gen-ntfe-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 device's read and poll semantics, what each ioctl expects, the error vocabulary, and the bounds that are not in the header -- is in the notes appendix, §6.B, which this generator does not touch.
6.A.1 Ioctl requests #
Request numbers on /dev/peios-ntfe, packed as <linux/ioctl.h>
packs them (direction, argument size, type 'N', number).
| Constant | Value | Definition |
|---|---|---|
PEIOS_NTFE_IOC_STATUS | 0x81704E01 | _IOR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_STATUS_NR, struct peios_ntfe_status) |
PEIOS_NTFE_IOC_COUNTERS | 0xC0184E02 | _IOWR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_COUNTERS_NR, struct peios_ntfe_counters_query) |
PEIOS_NTFE_IOC_FLOWS | 0xC0184E03 | _IOWR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_FLOWS_NR, struct peios_ntfe_flows_query) |
PEIOS_NTFE_IOC_LISTENERS | 0xC0184E04 | _IOWR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_LISTENERS_NR, struct peios_ntfe_listeners_query) |
6.A.2 Structure layouts #
Offsets and sizes are measured, not declared.
6.A.2.1 struct peios_ntfe_event #
Total size 456 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | seq |
| 8 | 8 | __u64 | t_ns |
| 16 | 1 | __u8 | seat |
| 17 | 1 | __u8 | layer |
| 18 | 1 | __u8 | verdict |
| 19 | 1 | __u8 | flags |
| 20 | 1 | __u8 | direction |
| 21 | 1 | __u8 | addr_family |
| 22 | 1 | __u8 | protocol |
| 23 | 1 | __u8 | flow_state |
| 24 | 4 | __u32 | ifindex |
| 28 | 2 | __u16 | src_port |
| 30 | 2 | __u16 | dst_port |
| 32 | 2 | __u16 | ether_type |
| 34 | 1 | __u8 | reject_kind |
| 35 | 1 | __u8 | _pad0 |
| 36 | 16 | __u8[16] | src_addr |
| 52 | 16 | __u8[16] | dst_addr |
| 68 | 4 | __u32 | length |
| 72 | 4 | __u32 | effects |
| 76 | 96 | __u8[PEIOS_NTFE_EV_ATTR_LEN] | attributed |
| 172 | 4 | __u32 | _pad1 |
| 176 | 1 | __u8 | local_kind |
| 177 | 1 | __u8 | remote_kind |
| 178 | 1 | __u8 | local_unresolved |
| 179 | 1 | __u8 | remote_unresolved |
| 180 | 4 | __s32 | local_pid |
| 184 | 4 | __s32 | remote_pid |
| 188 | 16 | __u8[PEIOS_NTFE_GUID_LEN] | local_guid |
| 204 | 16 | __u8[PEIOS_NTFE_GUID_LEN] | remote_guid |
| 220 | 16 | __u8[PEIOS_NTFE_COMM_LEN] | local_comm |
| 236 | 16 | __u8[PEIOS_NTFE_COMM_LEN] | remote_comm |
| 252 | 68 | __u8[PEIOS_NTFE_SID_LEN] | local_user |
| 320 | 68 | __u8[PEIOS_NTFE_SID_LEN] | remote_user |
| 388 | 32 | __u8[PEIOS_NTFE_SERVICE_SID_LEN] | local_service |
| 420 | 32 | __u8[PEIOS_NTFE_SERVICE_SID_LEN] | remote_service |
| 452 | 4 | __u32 | _pad2 |
6.A.2.2 struct peios_ntfe_status #
Total size 368 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | abi |
| 8 | 8 | __u64 | generation |
| 16 | 8 | __u64 | enforcing |
| 24 | 8 | __u64 | events_dropped |
| 32 | 8 | __u64 | seen_ingress |
| 40 | 8 | __u64 | seen_egress |
| 48 | 8 | __u64 | seen_local_in |
| 56 | 8 | __u64 | deferred |
| 64 | 8 | __u64 | fallback_judged |
| 72 | 8 | __u64 | parse_errors |
| 80 | 8 | __u64 | judged |
| 88 | 8 | __u64 | permissive |
| 96 | 8 | __u64 | fail_closed |
| 104 | 8 | __u64 | verdict_pass |
| 112 | 8 | __u64 | verdict_drop |
| 120 | 8 | __u64 | verdict_reject |
| 128 | 8 | __u64 | reject_degraded |
| 136 | 8 | __u64 | fx_tags |
| 144 | 8 | __u64 | fx_counts |
| 152 | 8 | __u64 | fx_reports |
| 160 | 8 | __u64 | fx_prompts |
| 168 | 8 | __u64 | last_ingest_error |
| 176 | 8 | __u64 | last_ingest_t_ns |
| 184 | 8 | __u64 | tag_writes |
| 192 | 8 | __u64 | tag_untracked |
| 200 | 8 | __u64 | tag_refused |
| 208 | 8 | __u64 | count_writes |
| 216 | 8 | __u64 | count_key_absent |
| 224 | 8 | __u64 | count_refused |
| 232 | 8 | __u64 | reports_emitted |
| 240 | 8 | __u64 | counter_cells |
| 248 | 8 | __u64 | reporting_level |
| 256 | 8 | __u64 | seen_local_out |
| 264 | 8 | __u64 | flow_judged |
| 272 | 8 | __u64 | flow_cached |
| 280 | 8 | __u64 | flow_rejudged |
| 288 | 8 | __u64 | flow_expired |
| 296 | 8 | __u64 | flow_uncached |
| 304 | 8 | __u64 | refusals_emitted |
| 312 | 8 | __u64 | refusals_bypassed |
| 320 | 8 | __u64 | teardowns_emitted |
| 328 | 8 | __u64 | identity_unresolved |
| 336 | 8 | __u64 | changes_noted |
| 344 | 8 | __u64 | changes_walked |
| 352 | 8 | __u64 | contexts |
| 360 | 8 | __u64[1] | _reserved |
6.A.2.3 struct peios_ntfe_counter_rec #
Total size 232 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 64 | __u8[PEIOS_NTFE_COUNTER_NAME_LEN] | name |
| 64 | 8 | __u64 | hash |
| 72 | 1 | __u8 | keyspec |
| 73 | 1 | __u8 | family |
| 74 | 2 | __u8[2] | _pad0 |
| 76 | 4 | __s32 | ifindex |
| 80 | 16 | __u8[16] | src_addr |
| 96 | 16 | __u8[16] | dst_addr |
| 112 | 8 | __u64 | total |
| 120 | 8 | __u64 | last_secs |
| 128 | 4 | __u32 | n_windows |
| 132 | 4 | __u32 | _pad1 |
| 136 | 32 | __u32[PEIOS_NTFE_COUNTER_MAX_WINDOWS] | window_secs |
| 168 | 64 | __u64[PEIOS_NTFE_COUNTER_MAX_WINDOWS] | window_value |
6.A.2.4 struct peios_ntfe_counters_query #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | buf |
| 8 | 4 | __u32 | buf_len |
| 12 | 4 | __u32 | count |
| 16 | 4 | __u32 | total |
| 20 | 4 | __u32 | _pad0 |
6.A.2.5 struct peios_ntfe_flow_rec #
Total size 568 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | id |
| 4 | 1 | __u8 | family |
| 5 | 1 | __u8 | protocol |
| 6 | 1 | __u8 | direction |
| 7 | 1 | __u8 | loopback |
| 8 | 1 | __u8 | seen_reply |
| 9 | 1 | __u8 | assured |
| 10 | 1 | __u8 | related |
| 11 | 1 | __u8 | judged |
| 12 | 4 | __s32 | ifindex |
| 16 | 4 | __u32 | timeout_secs |
| 20 | 16 | __u8[16] | src_addr |
| 36 | 16 | __u8[16] | dst_addr |
| 52 | 2 | __u16 | src_port |
| 54 | 2 | __u16 | dst_port |
| 56 | 1 | __u8 | icmp_type |
| 57 | 1 | __u8 | icmp_code |
| 58 | 1 | __u8 | n_tags |
| 59 | 5 | __u8[5] | _pad0 |
| 64 | 8 | __u64 | start_secs |
| 72 | 16 | __u64[2] | packets |
| 88 | 16 | __u64[2] | bytes |
| 104 | 16 | __u64[PEIOS_NTFE_FLOW_SENTENCES] | sentence_generation |
| 120 | 16 | __s64[PEIOS_NTFE_FLOW_SENTENCES] | sentence_expires_at |
| 136 | 16 | __u64[PEIOS_NTFE_FLOW_SENTENCES] | sentence_rule_hash |
| 152 | 2 | __u8[PEIOS_NTFE_FLOW_SENTENCES] | sentence_verdict |
| 154 | 2 | __u8[PEIOS_NTFE_FLOW_SENTENCES] | sentence_reject_kind |
| 156 | 4 | __u8[4] | _pad1 |
| 160 | 64 | __u64[PEIOS_NTFE_FLOW_MAX_TAGS] | tag_hash |
| 224 | 64 | __u64[PEIOS_NTFE_FLOW_MAX_TAGS] | tag_value |
| 288 | 2 | __u8[PEIOS_NTFE_FLOW_SENTENCES] | owner_kind |
| 290 | 2 | __u8[PEIOS_NTFE_FLOW_SENTENCES] | owner_unresolved |
| 292 | 4 | __u8[4] | _pad2 |
| 296 | 8 | __s32[PEIOS_NTFE_FLOW_SENTENCES] | owner_pid |
| 304 | 32 | __u8[32] | owner_guid |
| 336 | 32 | __u8[32] | owner_comm |
| 368 | 136 | __u8[136] | owner_user |
| 504 | 64 | __u8[64] | owner_service |
6.A.2.6 struct peios_ntfe_flows_query #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | buf |
| 8 | 4 | __u32 | buf_len |
| 12 | 4 | __u32 | count |
| 16 | 4 | __u32 | total |
| 20 | 4 | __u32 | _pad0 |
6.A.2.7 struct peios_ntfe_listener_rec #
Total size 168 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 1 | __u8 | family |
| 1 | 1 | __u8 | protocol |
| 2 | 1 | __u8 | reuseport |
| 3 | 1 | __u8 | connected |
| 4 | 1 | __u8 | v6only |
| 5 | 1 | __u8 | owner_kind |
| 6 | 1 | __u8 | owner_unresolved |
| 7 | 1 | __u8 | _pad0 |
| 8 | 2 | __u16 | port |
| 10 | 2 | __u16 | _pad1 |
| 12 | 4 | __s32 | ifindex |
| 16 | 16 | __u8[16] | addr |
| 32 | 4 | __s32 | owner_pid |
| 36 | 16 | __u8[PEIOS_NTFE_GUID_LEN] | owner_guid |
| 52 | 16 | __u8[PEIOS_NTFE_COMM_LEN] | owner_comm |
| 68 | 68 | __u8[PEIOS_NTFE_SID_LEN] | owner_user |
| 136 | 32 | __u8[PEIOS_NTFE_SERVICE_SID_LEN] | owner_service |
6.A.2.8 struct peios_ntfe_listeners_query #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | buf |
| 8 | 4 | __u32 | buf_len |
| 12 | 4 | __u32 | count |
| 16 | 4 | __u32 | total |
| 20 | 4 | __u32 | _pad0 |
6.A.3 Constants #
Grouped as the header groups them.
NTFE — the Network Traffic Filtering Engine: the verdict event stream and engine status.
The engine (net/ntfe) judges every traversal at its standing seats and appends one event per evaluation to a bounded ring. /dev/peios-ntfe (mode 0600; any number of openers, one reader of the stream at a time) drains it: read() returns whole events only — never a partial record — and blocks when the ring is empty unless O_NONBLOCK; poll() raises POLLIN when events are waiting. A slow reader loses the OLDEST events, and the loss is confessed in peios_ntfe_status.events_dropped (the honesty rule: drops are counted, never silent).
Events are emitted for real evaluations (a published forest judged the traversal) and for fail-closed drops; permissive traversals (a layer with no forest — all of them at generation 0) emit nothing, because there is no decision to attribute. Status tells that story instead: generation 0 means "not enforcing", loudly.
This ABI is EXPERIMENTAL while NTFE grows: no stability promise until
the design ships (PEI-598). Check abi before trusting the rest.
| Constant | Value |
|---|---|
PEIOS_NTFE_ABI_VERSION | 5 |
Which standing seat judged the traversal.
| Constant | Value |
|---|---|
PEIOS_NTFE_EV_SEAT_INGRESS | 1 |
PEIOS_NTFE_EV_SEAT_EGRESS | 2 |
PEIOS_NTFE_EV_SEAT_LOCAL_IN | 3 |
PEIOS_NTFE_EV_SEAT_LOCAL_OUT | 4 |
| Constant | Value |
|---|---|
PEIOS_NTFE_EV_LAYER_PACKET | 0 |
PEIOS_NTFE_EV_LAYER_RAWPACKET | 1 |
PEIOS_NTFE_EV_LAYER_FLOW | 2 |
The verdict, in strictness order.
| Constant | Value |
|---|---|
PEIOS_NTFE_EV_VERDICT_PASS | 0 |
PEIOS_NTFE_EV_VERDICT_REJECT | 1 |
PEIOS_NTFE_EV_VERDICT_DROP | 2 |
The story a REJECT told (meaningful iff verdict == REJECT).
| Constant | Value | Notes |
|---|---|---|
PEIOS_NTFE_EV_REJECT_REFUSED | 0 | RST / port-unreachable |
PEIOS_NTFE_EV_REJECT_PROHIBITED | 1 | admin-prohibited |
| Constant | Value |
|---|---|
PEIOS_NTFE_EV_DIR_IN | 0 |
PEIOS_NTFE_EV_DIR_OUT | 1 |
Flow state as the snapshot carried it (0 = the fact was absent).
| Constant | Value |
|---|---|
PEIOS_NTFE_EV_FLOW_ABSENT | 0 |
PEIOS_NTFE_EV_FLOW_NEW | 1 |
PEIOS_NTFE_EV_FLOW_ESTABLISHED | 2 |
PEIOS_NTFE_EV_FLOW_RELATED | 3 |
PEIOS_NTFE_EV_FLOW_INVALID | 4 |
PEIOS_NTFE_EV_FLOW_UNTRACKED | 5 |
| Constant | Value | Notes |
|---|---|---|
PEIOS_NTFE_EV_F_BACKSTOP | 0x01 | nothing yielded; DROP |
PEIOS_NTFE_EV_F_FAIL_CLOSED | 0x02 | evaluation failed; DROP |
PEIOS_NTFE_EV_F_REJECT_DEGRADED | 0x04 | REJECT emitted as DROP |
PEIOS_NTFE_EV_F_REJUDGED | 0x08 | Flow: a stale sentence re-judged |
PEIOS_NTFE_EV_F_IDENTITY_UNRESOLVED | 0x10 | Flow: an endpoint could not be attributed |
What stood at an endpoint: the Flow layer's Local / Remote facts (ABI 4).
| Constant | Value | Notes |
|---|---|---|
PEIOS_NTFE_EV_LOCAL_ABSENT | 0 | not a Flow event / not local |
PEIOS_NTFE_EV_LOCAL_PROGRAM | 1 | a process's socket |
PEIOS_NTFE_EV_LOCAL_KERNEL | 2 | the stack itself |
PEIOS_NTFE_EV_LOCAL_SHARED | 3 | inbound multicast / broadcast |
PEIOS_NTFE_EV_LOCAL_NONE | 4 | nothing receives it |
PEIOS_NTFE_EV_ATTR_LEN | 96 |
A SID's binary form: revision, sub-authority count, a 48-bit authority, up to 15 sub-authorities. Self-sized by its count byte; all zero = absent.
| Constant | Value |
|---|---|
PEIOS_NTFE_SID_LEN | 68 |
A per-service SID (S-1-5-80 + five sub-authorities) is exactly this.
| Constant | Value |
|---|---|
PEIOS_NTFE_SERVICE_SID_LEN | 32 |
PEIOS_NTFE_COMM_LEN | 16 |
PEIOS_NTFE_GUID_LEN | 16 |
One counter cell, as the counters dump reports it: the stream and key- spec of its table, the key it holds (only the facts the key-spec names are meaningful; the rest are zero), the cumulative total, and the value of every window the table answers.
| Constant | Value |
|---|---|
PEIOS_NTFE_COUNTER_NAME_LEN | 64 |
PEIOS_NTFE_COUNTER_MAX_WINDOWS | 8 |
| Constant | Value |
|---|---|
PEIOS_NTFE_KEY_SRC_ADDR | 0x01 |
PEIOS_NTFE_KEY_DST_ADDR | 0x02 |
PEIOS_NTFE_KEY_INTERFACE | 0x04 |
One live flow, as the flows dump reports it (ABI 3): conntrack's view of the flow (original-direction tuple, state, remaining lifetime, accounting), NTFE's extension (start time, the interface and direction at first judgment, the sentences, the tags). Tags are reported by hash; the policy names them.
| Constant | Value |
|---|---|
PEIOS_NTFE_FLOW_MAX_TAGS | 8 |
PEIOS_NTFE_FLOW_SENTENCES | 2 |
The listeners dump: fills buf with as many records as fit; count is
how many were written, total how many sockets the walk saw.
| Constant | Value |
|---|---|
PEIOS_NTFE_IOC_TYPE | 0x0000004E |
PEIOS_NTFE_IOC_STATUS_NR | 0x00000001 |
PEIOS_NTFE_IOC_COUNTERS_NR | 0x00000002 |
PEIOS_NTFE_IOC_FLOWS_NR | 0x00000003 |
PEIOS_NTFE_IOC_LISTENERS_NR | 0x00000004 |
Appendix 6.B NTFE ABI Notes
Peios / Peios technical reference / PKM / NTFE
§6.A is generated from pkm/uapi/pkm/ntfe.h and holds only what a
compiler can measure. This appendix holds the rest.
6.B.1 Stability #
The ABI is experimental while NTFE grows (PEI-598): no stability
promise until the design ships. peios_ntfe_status.abi carries
PEIOS_NTFE_ABI_VERSION; a consumer checks it before trusting any other
field or record layout. Version 2 (the machinery slice) added the
event's reject_kind, the store confessions and counter_cells and
reporting_level in the status, and the counters ioctl. Version 3 (the
Flow layer) added the LOCAL_OUT seat and Flow layer ids, the
REJUDGED event flag, the Flow and refusal counters in the status, and
the flows ioctl. Version 4 (the identity facts) added the endpoints'
identities to the event and the flow record, the IDENTITY_UNRESOLVED
event flag and the identity_unresolved counter (in a reserved slot,
so the status kept its size). Version 5 renamed the device and every
symbol from pnp to ntfe, let more than one file hold the device open,
and added changes_noted, changes_walked and contexts to the status:
the first took one of the two reserved words version 4 left, the other
two are new, so the status grew by two words and keeps one reserved. pnpd and the kernel ship together on the
experimental edition, so the check is a guard, not a negotiation.
6.B.2 /dev/peios-ntfe #
A misc device, created mode 0600 and owned by uid 0 — but on Peios mode
bits and uids decide nothing (DAC is neutralised): KACS decides an
open() by the opener's token against the node's security descriptor
(§3.9.5). On devtmpfs that descriptor is inherited from the seeded
root's, which grants GENERIC_ALL to SYSTEM alone, so the device is
SYSTEM's unless trusted userspace sets another.
Sequence numbers are monotonic per boot. A gap between consecutive
records read is the number of events the ring overwrote while the
reader was away, plus any a faulting read() consumed; events_dropped in the status is the running
total of the overwrites. A read() that fails with EFAULT has
already taken its records off the ring: they are lost, and counted
nowhere but in that gap.
6.B.3 Event fields #
attributedis the winning rule's path relative to its layer key, UTF-8, NUL-terminated, truncated toPEIOS_NTFE_EV_ATTR_LEN− 1 bytes (95), which may split a multi-byte character. Two reserved values:backstop(nothing yielded) andfail-closed(evaluation failed).effectspacks the effect counts the evaluation yielded —tags | counts << 8 | reports << 16 | prompts << 24, each saturating at 255. What the stores then applied is in the status confessions, not in the event.reject_kindis meaningful only whenverdictisPEIOS_NTFE_EV_VERDICT_REJECT; a degraded reject (flags & PEIOS_NTFE_EV_F_REJECT_DEGRADED) still carries the kind the rule chose.PEIOS_NTFE_EV_F_REJUDGEDmarks aFlow-layer evaluation that replaced a stale sentence (policy change or time edge). A packet answered by a current sentence produces no event at all.layer2 isFlow;seat4 isLOCAL_OUT. AFlowevent'sdirectionis the endpoint judged: the originator's side for a normal flow, each seat's own for a loopback flow's two judgments.- Addresses: the first 4 bytes when
addr_familyis 4, all 16 when 6, undefined when 0. Ports are 0 when the fact was absent — checkprotocol. flow_state0 means the fact was absent (the ingress seat), not that the flow was untracked; untracked is 5.- The identity fields (ABI 4) are set on
Flowevents only and zero elsewhere.local_kind/remote_kindarePEIOS_NTFE_EV_LOCAL_*:ABSENT(0) for a non-Flow event, and forremotewhenever the other end is not local;remoteis filled only on a loopback flow. For aPROGRAMend,*_guid,*_pidand*_commare the process facts at the socket's stamp,*_userthe token's user SID and*_serviceits per-service SID, both binary and self-sized (byte 1 is the sub-authority count; all zero = absent — a user program has no service SID).*_unresolvedsays the end could not be attributed and was reported as the kernel's or as absent.
6.B.4 Counter records #
nameis the stream name (a rule'sCOUNT(Name)), NUL-terminated;hashis its FNV-1a identity as the kernel keys tables.keyspecsays which ofsrc_addr,dst_addr,ifindexare meaningful for this cell's key; the others are zero.familyis 4, 6, or 0 when no address fact is keyed.window_secs[i]/window_value[i]fori < n_windowsare the table's windows and this cell's current value in each;totalis cumulative since the cell was created (or migrated — see §6.6);last_secsisCLOCK_REALTIMEseconds of the last write, for staleness.- The dump lists every table's cells consecutively; group by
(name, keyspec)to reconstruct tables.
6.B.5 Flow records #
idis conntrack's own id for the entry (nf_ct_get_id()), stable for the flow's life and the key to use across dumps.src_addr/dst_addr, the ports and the ICMP fields are the original-direction tuple — the originator first. For ICMP,src_portcarries the echo id andicmp_type/icmp_codethe type and code;dst_portis 0.direction,ifindexandloopbackare meaningful only whenjudgedis 1: they were recorded at the Flow layer's first judgment. A flow withjudged == 0has had no Flow judgment recorded: every packet of it that reached the flow dispatch (§6.8) found no Flow forest (a permissive generation) or failed closed, or none did — or it has no extension at all, and so nowhere to record one (flow_uncached; its sentences and identities read empty too).- The sentences are parallel arrays indexed by slot (UAPI records hold
scalars only): slot 0 is the flow's sentence; slot 1 is only ever
filled for a loopback flow (its inbound endpoint). A slot with
sentence_generation == 0is empty.sentence_expires_at0 means never.sentence_rule_hashis FNV-1a-64 (offset0xcbf29ce484222325, prime0x100000001b3) of the whole attributing path relative to the layer key, however long — not of the event's truncatedattributed.backstophashes like any other path;fail-closedis never a sentence's, since a failed evaluation is not cached. packets/bytesare conntrack's accounting, original then reply; NTFE turns conntrack accounting on at init (net.netfilter.nf_conntrack_acct).timeout_secsis the entry's remaining lifetime as conntrack sees it;start_secsisCLOCK_REALTIMEseconds when conntrack created it.tag_hash/tag_valuehold up toPEIOS_NTFE_FLOW_MAX_TAGS(8) present tags by name hash and value;n_tagsis the flow's total, so a value above 8 means some are not listed.- The identities (ABI 4) are per sentence slot, recorded at the flow's
first judgment and fixed:
owner_kind[slot]isPEIOS_NTFE_EV_LOCAL_*(ABSENT= not resolved;ABSENTwithowner_unresolved[slot]set = judged but unattributed, such as a loopback sender the outbound seat never recorded); the per-slot arrays are flattened at a fixed stride —owner_guid16 bytes per slot,owner_comm16,owner_user68,owner_service32 — so slot 1's user SID starts at byte 68. Slot 1 is filled only for a loopback flow.
6.B.6 Listener records #
- One record per socket prepared to receive: TCP in
LISTEN, and UDP / UDP-Lite bound to a port (connectedwhen it also has a peer and so receives from one address only).addrall zero is the wildcard;ifindexisSO_BINDTODEVICE, 0 for any;reuseportmarks a member of aSO_REUSEPORTgroup, of which each member is listed.v6onlysays anAF_INET6socket refuses v4-mapped traffic — a v6 socket without it answers on both families. - The owner fields are those of the flow record's slot, for the socket's
current stamp:
owner_kindisPROGRAMorKERNEL(a socket nobody stamped readsKERNELwithowner_unresolvedset). - The walk is the root namespace's tables only, copied out between hash buckets: a best-effort list of a set that changes under it.
6.B.7 Bounds not in the header #
6.B.8 Build configuration #
CONFIG_PEIOS_NTFE (bool) depends on SECURITY_PKM, NETFILTER,
NETFILTER_INGRESS, NETFILTER_EGRESS and NF_CONNTRACK=y — NTFE is built in and reads flow
facts on the packet path, so conntrack must be too. CONFIG_PEIOS_NTFE_KUNIT
builds the kernel-resident tests (pkm_kunit_ntfe), defaulting to
SECURITY_PKM_KUNIT. The production fragment (build/config/pkm.fragment)
enables NTFE and configures the nf_tables/xtables family out;
kernel/verify-kernel-config.sh asserts both.