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

Developing for Peios

How to build software for Peios.

Single-page view · as markdown

Debugging the kernel

Peios / Developing for Peios / Debugging the kernel

Most questions about why the Peios kernel did something — why an access was denied, why an event never reached userspace, why a registry lookup stalled — are answered from inside the kernel, before any userspace tool can see the state involved. Peios makes that interior observable through the same tracing infrastructure the rest of the Linux kernel uses: static tracepoints.

The PKM security subsystems each expose a tracepoint system:

  • kacs: — the access-control decisions. Every instrumented KACS hook records its verdict (allow/deny), the object it acted on (inode number and superblock magic — never a pathname), and a reason code naming the exact return path it took. This is where "why was this denied?" is answered.
  • kmes: — the health of the event substrate: ring-buffer drops, capacity swaps, rate-limit throttling, backpressure. These trace the machinery, not the security events KMES ships to userspace (those are the event stream).
  • lcs: — the registry source device: request/response round-trips, timeouts, transaction state transitions, source mark-downs.

Because these are ordinary kernel tracepoints, everything the kernel's tracing stack can do applies unchanged: enable individual events or whole subsystems, attach ftrace filters and triggers, sample with perf, or attach eBPF programs. They cost nothing when disabled (a patched-out branch) and record structured fields rather than formatted text, so you filter on ret, reason, or an inode number directly.

The Kernel tracepoints page covers how to enable them — at runtime through tracefs, or from the very first moments of boot through the kernel command line — and how to read what they emit.

Note

Safety. PKM tracepoints never record pathnames or security-descriptor bytes. They carry inode numbers, superblock magics, resolved policies, access masks, numeric identifiers, lengths, and reason codes only. This is by design, so they are safe to leave compiled into production kernels and enable in the field.

Kernel tracepoints

Peios / Developing for Peios / Debugging the kernel

PKM exposes its security subsystems through three tracepoint systems — kacs:, kmes:, and lcs: — registered with the standard Linux tracing infrastructure. This page shows how to turn them on and read them.

Discovering the events #

Every event and its fields are self-describing through tracefs. The live catalog is authoritative — prefer it over any static list:

# every PKM tracepoint
ls /sys/kernel/tracing/events/kacs /sys/kernel/tracing/events/kmes /sys/kernel/tracing/events/lcs

# the fields (and their symbolic decodings) of one event
cat /sys/kernel/tracing/events/kacs/kacs_file_access/format

The numeric reason, op, and state codes carried by these events are a stable, append-only diagnostic ABI defined in <pkm/trace.h>; the format file maps them back to their symbolic names for you.

Enabling at runtime #

Enable a whole subsystem, or a single event:

cd /sys/kernel/tracing

echo 1 > events/kacs/enable                 # all KACS access-decision events
echo 1 > events/kacs/kacs_file_access/enable # just one

cat trace                                    # read what has accumulated
echo 1 > tracing_on                          # (on by default)

Because the fields are structured, you filter in the kernel rather than grepping text. To see only denials:

echo 'ret != 0' > events/kacs/kacs_file_access/filter

To watch a single inode, or one reason:

echo 'ino == 1234' > events/kacs/kacs_file_access/filter
echo 'reason == 3' > events/kacs/kacs_sd_cache_lookup/filter   # 3 == miss-needs-synth

perf and eBPF attach to the same tracepoints by name — e.g. perf record -e kacs:kacs_file_access, or a tracepoint:kacs:kacs_file_access probe from bpftrace.

Enabling at boot #

The most common reason to reach for these is a decision that happens before userspace exists — the access checks that fire as the root filesystem mounts. tracefs is not available that early, so enable the events on the kernel command line and route them to the console with tp_printk:

trace_event=kacs:*,kmes:*,lcs:* tp_printk

trace_event= enables the listed events as the tracing subsystem initialises — which happens before the PKM LSM itself initialises, and well before the first access check — so no early decision is missed. tp_printk prints each enabled tracepoint to the kernel log, giving you a complete decision transcript on the console with no userspace involved. Narrow the selection (trace_event=kacs:kacs_file_access) to cut the volume.

Note

Migration note. This replaces the older kacs.trace=1 boot parameter, which emitted a single hand-rolled pr_info line per KACS access decision. The equivalent today is trace_event=kacs:* tp_printk, which produces the same pre-userspace transcript but as structured, filterable events across all three subsystems. kacs.trace=1 is no longer recognised.

Reading a KACS access decision #

A kacs: access-decision event answers "what did KACS decide about this object, and why?". The key fields:

  • verdict — allow or deny, derived from ret (0 is allow; a negative errno is a denial). Filter on ret for machine use.
  • reason — the specific return path taken, as a symbolic name (e.g. decision, unmanaged, no-token, pip-context). The same object can be denied for very different reasons; this names which one.
  • ino / sb_magic — the inode number and the filesystem's superblock magic, identifying the object without disclosing its path.
  • mount_policy — the resolved mount policy for the object's filesystem, which frequently explains a denial on an unmanaged or synthesis-only mount.
  • access — the desired-access mask being checked.

For a worked example of tracing a specific denial end to end, see Debugging a denial.

Running KUnit

Peios / Developing for Peios / Debugging the kernel

From a PKM checkout, build/test-kunit.sh boots an already-built kernel in QEMU and checks its serial log. Supply either a kernel build tree or its bzImage. The kernel must include KUnit and the PKM test suites; enable CONFIG_PROVE_LOCKING when investigating lock ordering.

build/test-kunit.sh /path/to/bzImage

The runner requires successful emulator exit, clean power-down, and complete top-level KTAP results. An unfiltered run must also contain PKM's smoke marker and a passing PKM suite summary. A timeout, failed assertion, kernel warning, panic, or diagnostic that disables lock checking fails the run even when the individual test cases report success. Read the retained serial log for the cause: passing assertions alone do not establish that lock checking stayed active.

Selecting tests #

PKM_KUNIT_FILTER passes a KUnit suite/test glob to the kernel. For example:

PKM_KUNIT_FILTER='pkm_kunit_file.*' build/test-kunit.sh /path/to/bzImage

A filtered run may omit the global smoke test or select an upstream single-case suite without an aggregate summary. It must still emit selected case results, complete its top-level KTAP plan, and power down cleanly. Selecting no cases is a failure. Kernel diagnostic checks remain enabled for filtered runs.

Logs and emulator settings #

VariablePurpose
PKM_KUNIT_LOGOverride the serial-log path; by default, kunit-qemu.log is written beside the supplied image or inside the supplied build tree.
PKM_KUNIT_TIMEOUTEmulator timeout in seconds, defaulting to 420.
PKM_KUNIT_INITRDOptional initrd path.
QEMU_BINEmulator executable, defaulting to qemu-system-x86_64.

The script does not build, sign, install, or publish a kernel. Its result applies to the image and selected tests that were actually run; native integration suites and hardware qualification are separate checks.

Process runtime reference

Peios / Developing for Peios / Process runtime reference

Use this reference when implementing a launcher, supervisor, threading library, or other code that creates and controls processes. For inspecting or stopping a running program, start with Task Manager and Process lifecycle.

  • Process creation: fork, vfork, clone/clone3, sharing flags, exec, posix_spawn, and pidfds.
  • Process lifecycle: termination, exit-status encoding, the wait family, and subreaper adoption.
  • Thread operations: TIDs, thread-exit notification, and thread-local storage.

These calls also interact with Peios security. See Token lifecycle for identity inheritance, the process security descriptor for process rights, and the two-check rule for cross-process access.

Process creation reference

Peios / Developing for Peios / Process runtime reference

This is the detailed counterpart to Creating processes. That page explains the ideas; this one gives the exact contract — what each call does, what a new process inherits, and what each clone sharing flag controls.

fork #

fork() creates a new process by duplicating the caller. The child is a near-identical copy that runs independently from the point of the call.

The child inherits:

  • a private copy of the parent's memory, made lazily (copy-on-write), so the duplication is cheap and changes on either side stay invisible to the other
  • copies of the parent's open file descriptors — each refers to the same open file description, so file offset, status flags, and signal-driven-I/O settings are shared between parent and child
  • open directory streams and open message-queue descriptors
  • open-file-description locks (flock, OFD locks) through those shared descriptors
  • the parent's signal dispositions (the handler set for each signal)
  • the file-creation mask (umask), the current directory, and the root directory
  • resource limits and the timer-slack value
  • the parent's identity — it begins acting on the same token (see Token lifecycle)
  • the parent's mitigations and protection, carried on its PSB

The child does not inherit:

  • the parent's PID — it receives a new PID and a new Process GUID
  • memory locks (mlock, mlockall)
  • memory regions marked MADV_DONTFORK (absent in the child) or MADV_WIPEONFORK (present but zeroed)
  • process resource usage and CPU-time counters — reset to zero
  • pending signals — the child's set starts empty
  • semaphore adjustments (semadj)
  • process-associated record locks (POSIX fcntl locks — distinct from the OFD and flock locks above, which are shared through the inherited descriptors)
  • timers (setitimer, alarm, timer_create)
  • outstanding asynchronous I/O
  • directory-change notifications (dnotify)
  • the PR_SET_PDEATHSIG parent-death-signal setting (reset)
  • I/O-port access permissions (ioperm)

The child's termination signal — what its parent is notified with when it ends — is SIGCHLD.

The child is created with a single thread — the one that called fork(). If the parent had other threads, they do not exist in the child.

vfork #

vfork() is a lightweight variant of fork() for the one case where the child will immediately replace itself with another program. It differs in two ways:

  • the caller is suspended from the call until the child either execs or exits;
  • the child runs in the parent's memory — no copy is made — until that point.

Because the two share memory and the parent is frozen, the child must do almost nothing first. The contract: the child must not return from the function that called vfork(), must not modify any variable other than the one holding the return value, and must not call any other function before a successful exec or _exit. Doing otherwise is undefined behaviour.

vfork is an optimisation for the create-then-replace pattern. Ordinary code should use posix_spawn (below) rather than reach for it directly.

clone and clone3 #

clone() is the general creation call. fork and thread creation are both clone with particular flag sets; clone exposes the choice directly. The caller gives a set of flags controlling what the new task shares with the creator, a stack for the new task, and locations for thread-ID and thread-local-storage bookkeeping.

clone3() is the modern form, taking a structure instead of positional arguments so it can grow new fields over time without changing the call:

FieldPurpose
flagsThe sharing flags (below).
pidfdWhere to store a pidfd for the new child.
child_tid / parent_tidWhere to record the new thread's ID, in the child's and the parent's memory.
exit_signalThe signal delivered to the parent when the child ends.
stack / stack_sizeThe new task's stack and its size.
tlsThe new task's thread-local-storage area.
set_tid / set_tid_sizeRequest specific thread IDs for the new task — a privileged operation (see below).
cgroupPlace the child directly into a resource group at creation (see resource management).

The structure is size-versioned: the caller passes the size it knows, the kernel reads up to the size it knows, and any unknown trailing fields must be zero. This is how new fields are added without breaking existing programs.

Requesting a specific thread ID (set_tid) is a privileged operation — it exists for checkpoint-and-restore tools that must recreate a process with its original ID. The privilege required follows the capability model.

clone sharing flags #

Each flag makes the new task share something with its creator instead of getting its own copy. The flags that build threads and control creation:

FlagEffect
CLONE_VMShare memory rather than taking a private copy.
CLONE_FSShare filesystem context — current directory, root directory, file-creation mask.
CLONE_FILESShare the open-descriptor table, so opening or closing a descriptor in one is seen by the other.
CLONE_SIGHANDShare the table of signal handlers. Requires CLONE_VM.
CLONE_THREADPut the new task in the same process (thread group) as the creator. Requires CLONE_SIGHAND.
CLONE_SETTLSSet the new task's thread-local-storage area.
CLONE_SYSVSEMShare the System V semaphore-adjustment list.
CLONE_IOShare the I/O context, so the two are accounted as one for disk I/O.
CLONE_PIDFDReturn a pidfd for the new child.
CLONE_PARENTMake the new task a sibling of the creator — its parent becomes the creator's parent, which is also what is signalled when the task ends. An init process (PID 1) cannot use this flag, as it would create unreapable zombies.
CLONE_PARENT_SETTID / CLONE_CHILD_SETTIDRecord the new task's ID in the parent's / child's memory.
CLONE_CHILD_CLEARTIDClear the recorded thread ID and wake a waiter when the task exits — the mechanism behind joining a thread.
CLONE_UNTRACEDPrevent a tracer from forcing tracing onto the new child.
CLONE_VFORKSuspend the creator until the child execs or exits (the vfork behaviour).

A thread is CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD (plus TLS and thread-ID bookkeeping) — everything shared, same process. A fork is the opposite end, with almost nothing shared.

Being in one thread group has consequences beyond shared memory. Every thread returns the same PID from getpid (the thread-group ID), and signal actions are process-wide — an unhandled fatal signal delivered to any thread ends them all — though each thread keeps its own signal mask. A thread that ends does not notify the thread that created it and cannot be collected with a wait call; only once every thread in the group has ended is the process's parent sent SIGCHLD. The new thread shares the creator's parent, and CLONE_THREAD requires CLONE_SIGHAND (and hence CLONE_VM).

Two further flag groups select separation rather than sharing, each documented where it belongs:

  • The CLONE_NEW* flags create namespaces — separate views of system resources (mounts, process IDs, networking, and more). Only CLONE_NEWNS is available to an ordinary process: a private mount table is an object its creator owns, described in the Peios Kernel TRM (§3.13). Every other namespace type needs SeTcbPrivilege, which is what CAP_SYS_ADMIN means on Peios.
  • CLONE_INTO_CGROUP places the new process into a resource group at creation, covered under resource management.

CLONE_DETACHED is a historical flag with no effect and is ignored.

The exec family #

An exec replaces the program running in the current process with a different one. The process keeps its PID, its Process GUID, and its identity — only the program changes (see Token lifecycle for the identity detail).

CallWhat it does
execveReplace the current program with the one at a given path.
execveatReplace it with a program named relative to an open directory, or — with AT_EMPTY_PATH — by an open file descriptor directly. AT_SYMLINK_NOFOLLOW refuses a final symbolic link.
fexecveReplace it with the program referred to by an open file descriptor (a thin wrapper over execveat).

What survives an exec #

A successful exec keeps the process itself but replaces the program it runs. Preserved across the call:

  • the PID, the parent PID, and the Process GUID
  • the process's identity (its token), its process group and session, and its controlling terminal
  • open file descriptors — except those marked close-on-exec, which are closed
  • the current directory, root directory, file-creation mask, and resource limits
  • pending signals, and the dispositions of signals that were ignored or left at their default

Reset or discarded:

  • all memory — the program's mappings, stack, heap, and data are replaced; memory locks are dropped and MADV_* region markings are gone
  • every thread except the one calling exec — the new program starts single-threaded
  • handlers for caught signals — reset to the default; the alternate signal stack is dropped
  • attached System V shared memory, POSIX shared-memory mappings, open POSIX message-queue descriptors and named semaphores, and in-process synchronisation objects (mutexes, condition variables)
  • POSIX timers, outstanding asynchronous I/O, open directory streams, and registered exit handlers
  • the floating-point environment, and the process name (set to the new program)

What an exec does to a process's identity when the program file is marked to run as another principal is not the Linux set-user-ID model — Peios handles that through the token, covered in setuid and uid0.

Whether a program is allowed to run at all is a separate, execution-policy question — covered under binary signing — not part of these calls.

posix_spawn #

posix_spawn() is the standard library function for the create-then-replace pattern: it makes a new process and runs a named program in it with a single call, with controls for arranging the child's file descriptors and a few attributes first. It is built on the calls above and is the recommended way to launch a program, in preference to assembling fork/vfork and exec by hand.

Process handles (the pidfd family) #

A pidfd is a file descriptor referring to one specific process — the reliable handle introduced in Creating processes. CLONE_PIDFD hands one back at creation; a few calls work with them afterwards:

CallWhat it does
pidfd_openObtain a pidfd for a process that already exists, given its PID.
pidfd_send_signalSend a signal to the process through its pidfd — with no risk of the PID having been recycled for a different process in between.
pidfd_getfdDuplicate one of the target process's open file descriptors into the caller.

pidfd_getfd reaches into another process, so it is not something any process may do to any other: it is gated by the right to act on the target, governed by that process's security descriptor.

See also #

  • Creating processes — the conceptual counterpart to this page.
  • Process lifecycle reference — ending a process and collecting its result.
  • Thread operations reference — per-thread calls: TIDs, exit notification, TLS.

Process lifecycle reference

Peios / Developing for Peios / Process runtime reference

This is the detailed counterpart to Process lifecycle. That page explains how a process ends and is cleaned up; this one gives the exact calls — how a process terminates, what its exit status encodes, and how a parent collects it.

Ending a process #

A process can end itself in a few ways, differing in how much tidying happens first:

CallWhat it does
exit()The orderly end. Runs every handler registered with atexit/on_exit (in reverse order of registration), flushes and closes the standard I/O streams, removes temporary files, then terminates.
_exit() / _Exit()The immediate end. Terminates at once — no registered handlers run and the standard I/O streams are not flushed — though open file descriptors are still closed. Used when a process must stop without running cleanup, such as a child after a failed exec.
exit_group()Ends the whole process — every thread in it — not just the calling one. This is what ordinary termination uses: a normal exit(), and a return from the program's entry point, both end the entire process.

Returning from the program's entry point is equivalent to calling exit() with the returned value.

At the lowest level the raw _exit system call ends only the calling thread; the library _exit and ordinary termination route through exit_group so the whole process ends. Ending one thread while the rest of the process keeps running is the unusual case.

A process can also be ended from outside, by a signal rather than by a call of its own — covered under signals. Either way, it leaves an exit status behind.

The exit status #

When a process ends it leaves a small exit status for its parent to read. It records one of two outcomes:

  • Ended on its own — carries an exit code: the low 8 bits of the value passed to exit/_exit (so 0–255, with 0 conventionally meaning success).
  • Ended by a signal — carries the number of the signal that ended it, plus a flag for whether the process produced a core dump.

The status is a packed value, read not directly but through a set of macros:

MacroTells you
WIFEXITEDthe process ended on its own — if so, WEXITSTATUS gives its exit code
WIFSIGNALEDthe process was ended by a signal — if so, WTERMSIG gives the signal number and WCOREDUMP whether it dumped core
WIFSTOPPEDthe process was stopped (not ended) by a signal — WSTOPSIG gives which
WIFCONTINUEDthe process was resumed by a continue signal

Collecting the result #

A parent collects a finished child's status — and in doing so clears the zombie — with one of the wait calls. They differ in which child they target and how much they report:

CallWhat it offers
waitWait for any child to end; return its PID and status. The simplest form.
waitpidWait for a chosen target (see below). Options adjust the behaviour: WNOHANG returns immediately if nothing is ready (a poll rather than a wait), and WUNTRACED/WCONTINUED also report children that have stopped or resumed, not only ended.
waitidThe most precise form. It can target a child by a pidfd (P_PIDFD) — the reliable handle from Creating processes — as well as by PID, by process group, or any child. It reports through a structured record (which child, and what happened: ended, killed, core-dumped, stopped, continued). Which kinds of change to wait for are chosen explicitly — WEXITED for children that have ended, WSTOPPED for those stopped by a signal, WCONTINUED for those resumed — and its WNOWAIT option peeks at the result while leaving the child collectable again later.
wait4Like waitpid, and additionally returns the child's resource usage — processor time consumed, peak memory, and so on.

waitpid's target is chosen by a single number: a positive value waits for that exact PID; -1 for any child; 0 for any child in the caller's own process group; and a value below -1 for any child in the process group whose ID is its absolute value.

Two further options matter for threads and other clone-created children, which may not notify their parent with SIGCHLD the way an ordinary child does: __WCLONE waits only for such clone children, and __WALL waits for every child regardless of type.

Orphan adoption and subreapers #

PID 1 is the catch-all, but a process can arrange to catch orphans within its own subtree. By marking itself a subreaper (with prctl(PR_SET_CHILD_SUBREAPER)), a process becomes the one that adopts any orphan descended from it — its grandchildren and below — instead of letting them reparent all the way up to PID 1. Service managers and container managers use this to keep responsibility for everything they launched: when something deep in their subtree loses its immediate parent, it reparents to the manager, which still learns when it finally ends.

See also #

  • Process lifecycle — the conceptual counterpart to this page.
  • Process creation reference — fork, clone, exec, and the pidfd calls.

Thread operations reference

Peios / Developing for Peios / Process runtime reference

This is the thread-level companion to the process and thread model — the operations that act on an individual thread rather than on the process as a whole.

Thread IDs #

Every thread has its own thread ID (TID), returned by gettid. Within a multi-threaded process all threads share one PID — the value getpid returns, which is really the thread-group ID (TGID) — but each thread's TID is unique. In a single-threaded process the TID, PID, and TGID are all the same number.

The TID is how the system targets one specific thread rather than the whole process: sending a signal to a single thread, setting one thread's CPU affinity, or addressing it under /proc. It names a thread the way a PID names a process — and, like a PID, it is a reference, not an identity. What a thread is acting as is its token, never its TID.

The TID is also distinct from the opaque handle a threading library returns when it creates a thread; that is a library-level identifier, not the kernel's TID.

Thread-exit notification #

A thread can have the kernel notify waiters when it exits — the mechanism threading libraries build thread join on. set_tid_address sets a clear-tid address for the calling thread (and returns its TID). When that thread later terminates, if it shares memory with other threads, the kernel writes 0 to the integer at that address and wakes one thread waiting on it (a single futex wake).

A thread waiting for another to finish blocks on that address; the kernel clears it and wakes the waiter at the exact moment the other thread ends. This is the same machinery as the CLONE_CHILD_CLEARTID flag in process creation — that flag arranges the clear-tid address at thread creation, where set_tid_address sets it afterwards.

Thread-local storage #

Thread-local storage (TLS) gives each thread its own private copy of a variable — a value that is per-thread rather than shared across the process, even though the threads share the same memory. The per-thread data lives in an ordinary block of memory; what makes it per-thread is a register pointing at this thread's block, so the same code reaches a different copy depending on which thread is running.

The kernel's part is maintaining that per-thread pointer. It is set when the thread is created (the CLONE_SETTLS flag carries the address) and, on x86-64, a running thread can change it through arch_prctl with ARCH_SET_FS (the older set_thread_area serves the same purpose on 32-bit). Everything above that — how thread-local variables are laid out and reached in code — is handled by the compiler and the threading library.

See also #

  • The process and thread model — the conceptual counterpart to this page.
  • Process creation reference — the clone flags that create threads and arrange TLS and clear-tid addresses.

Writing regman pages

Peios / Developing for Peios / Documenting configuration

The registry stores a value's type and bytes, but never its meaning — it does not know that a queue depth must be at least 16, or what changing it costs. That knowledge ships with the software that owns the key, as documentation regman reads. From the operator's side this is the registry manual; from yours, the package author's, it is a file you write and install.

This page is how you write one. The package that owns a set of registry keys is the package that documents them — the only arrangement that stays correct as packages come and go.

Put cross-references to another package's keys in your own page's body, rather than adding a second record for the same key. An exact record is a provider claim, so a redirect record makes a lookup report two providers. For example, netd's Machine\System\Network page points readers to Machine\System\Network\Dns, whose record belongs to resolvd.

Where the documentation lives #

regman reads a drop-in directory, /usr/share/regman/. Each *.regman file in it is one provider, and the provider's name is the file's stem:

/usr/share/regman/
    kmes.regman          # provider: kmes
    exampled.regman      # provider: exampled

A package documents its whole registry surface in one file. There is no central index to register with and no install-time hook: dropping the file in is the whole act, and removing it on uninstall is the whole undo. A missing directory simply means nothing is documented — not an error.

You ship the fragment the same way you ship any other file. Have your build target install it, then map it into the payload from the package file's [files] table:

[files]
"main:share/regman/exampled.regman" = "usr/share/regman/exampled.regman"

If the fragment is hand-written rather than generated — which it usually is — keep it beside the recipe and take it from there, no build target involved:

[files]
"@recipe:exampled.regman" = "usr/share/regman/exampled.regman"

See Packages for ref resolution, and Multi-package recipes if one recipe produces a family of packages that each document their own keys.

Note

regman reads shipped documentation, never the live registry. A .regman page describes what a setting means and should be — its type, default, valid range — not what some machine currently has it set to. Don't write current-state-specific prose; it won't be true on the next box.

The shape of a fragment #

A fragment is a sequence of records, each documenting one key or one value. A record is a fence line, a small header, a blank line, then a Markdown body:

--- machine\system\exampled maxqueuedepth
canonical: Machine\System\Exampled MaxQueueDepth
type: REG_DWORD
default: 1024
valid: 16–65536
applies: restart

Maximum number of jobs held in the spool queue before new submissions
are rejected with EAGAIN.

Raising this lets the queue absorb larger bursts at the cost of memory.
The queue is preallocated, so the change takes effect at the next service
restart rather than live.

Fragments are UTF-8 text. LF and CRLF line endings are accepted, including both in one file. regman fmt preserves each fence's existing line ending and leaves headers, body text and any preamble unchanged; it only replaces the fence's anchor. Indexed and unindexed lookups read the same records.

Four parts, in order:

  • The fence line — --- (three dashes, one space) followed by the anchor: the case-folded, lowercased lookup token regman matches against. You do not write this by hand; regman fmt bakes it from canonical (see The fmt/lint workflow, below).
  • The header — key: value lines, one per line, until the first blank line. Keys are case-insensitive; values are trimmed. Unknown keys are ignored, so a typo'd field is silently dropped rather than rejected — watch for it.
  • A blank line — this is what ends the header and starts the body. A record with no blank line has no body.
  • The body — Markdown, rendered when the page is shown.

Key docs and value docs #

There is no kind: field. A record is a value doc if it carries any of type, default, valid, or applies; otherwise it is a key doc. That is the whole distinction — a key doc is just a record that omits all four.

A key doc introduces a subtree. Its body explains the shared semantics once (validation philosophy, security intent, how the subsystem reads the keys), and regman <key> renders that body followed by an auto-generated index of the values beneath it:

--- machine\system\exampled
canonical: Machine\System\Exampled

The Exampled spooler reads its tuning parameters from the values under
this key. Compiled-in defaults apply until LCS is available; after that
Exampled reads, validates, and applies each value, then watches the
subtree for later changes.

A value doc documents one knob. The four value fields are what fill in its card:

FieldOnWhat it tells the reader
canonicalevery recordThe original-case Path[ Value] shown in the heading. The one required field.
typevalue docsRegistry type tag — REG_DWORD, REG_QWORD, REG_SZ, …
defaultvalue docsThe value used when nothing is set.
validvalue docsThe range, set, or constraint a sensible value must satisfy. Human-readable prose.
appliesvalue docsWhen a change takes effect: live, restart, or reboot.
deprecatedeitherPresent ⇒ the item is being retired; the value is the replacement or a note. Renders as a banner at the top of the card.

canonical is the only field any record must have — a record without it is dropped and flagged. The four value fields are not individually enforced, but each one you omit is a blank on the card, and applies in particular is the field operators reach for most. Treat a value doc as incomplete until it carries all four.

Note

regman renders field values verbatim — it does not reformat them. If you want the default to read 1024 (1 K jobs), write exactly that into the default: field. The card shows what you wrote.

Documenting a family of keys #

Plenty of configuration isn't at a fixed path. A service definition lives at Machine\System\Services\<name>, an interface's record at Machine\System\Network\Interfaces\<ifid>, a principal's policy at Machine\Generic\Authn\Policy\<SID>. There is one thing to explain and an open set of paths to explain it at, and the operator asking will name a real one — sshd, not <name>.

Write the varying component in angle brackets and regman treats it as a wildcard matching exactly one path component:

--- machine\system\services\<name> imagepath
canonical: Machine\System\Services\<name> ImagePath
type: REG_SZ
default: (required)
valid: a non-empty absolute path
applies: restart

Absolute path to the service binary.

regman 'Machine\System\Services\sshd' ImagePath now answers with that card, and regman 'Machine\System\Services\sshd' renders the family's key doc with its whole Values index. The heading still shows the canonical — the reader can see they've been handed the generic page, which is the honest thing to show.

Four things worth knowing:

  • One component, never a path tail. \X\<Y>\Z answers for \X\B\Z and not for \X\B\C\Z. A wildcard also never spans the space between a key path and a value name, so it cannot swallow a value it wasn't meant to cover.
  • The name inside is for the reader. <name>, <ifid> and <x> behave identically; pick the one that documents itself.
  • A concrete record wins. If some package documents Machine\System\Services\sshd ImagePath outright, that page answers instead of the generic one — the same most-specific-match rule as a port reservation selector. Key docs and value docs are resolved separately, so a service that ships its own key doc still gets the generic Values index beneath it.
  • There is no escape for a literal <…>. LCS permits < and > in a key name, so a key genuinely spelled <Y> can't be told apart from a wildcard. In practice nobody names a key that, and the notation was already how these docs wrote a varying component by hand.

Use a wildcard for a family whose members share their whole meaning, which is the usual case. Where one member genuinely differs — a specific service's own knob, a particular principal's privileges — document that one concretely and let specificity do the rest.

When the family is a tree #

Some families nest. A PNP rule's subkeys are exceptions, twelve deep at most; a netd profile's subkeys are derived profiles. The vocabulary doesn't change with depth — an exception is a rule, with the same values meaning the same things — so there is still one page to write, but no fixed number of <…> components reaches every member of it.

Put ... immediately inside the closing bracket and that component spans separators, matching one path component or many:

--- machine\system\network\rules\<layer>\<rule...> actions
canonical: Machine\System\Network\Rules\<Layer>\<rule...> Actions
type: REG_MULTI_SZ
default: (absent or empty — NULL, the rule abstains)
valid: one action expression per element
applies: live — at the next generation

What the rule does when it matches.

That answers for Rules\Packet\ssh Actions and for Rules\Packet\ssh\from-lan\not-vpn Actions alike. Note that <Layer> in the same anchor is still one component: the spanning form applies to the bracket it is written in, not to the record.

The rules that matter:

  • It still won't cross the space. <name...> spans \ and nothing else, so a key-path wildcard can never eat the value name after it.
  • It still needs at least one component. Rules\<Layer>\<rule...> does not answer for Rules\Packet, which has its own page.
  • Narrower beats broader. Concrete beats <name>, and <name> beats <name...>. A page written for the top of a tree and a page written for the whole tree can coexist; the narrower one answers where it applies, and no "documented by 2 packages" warning appears.

Reach for it only where the subtree really is a tree. A flat family — Services\<name>, Policy\<SID> — wants the plain form, which says something truer about the shape of the keys.

A wildcard in the value name #

Nothing restricts a wildcard to the key path. PNP documents rule conditions this way: a condition's value name is <Fact>.<Operator>, so each fact gets one record with the operator left open — Rules\<Layer>\<rule...> DstPort.<op>, whose card covers Equal, GreaterThan, LessThan and Present at once.

There is one trap, and it is worth knowing before you reach for this:

Warning

A wildcard stops at \ and at a space, but not at a dot. So a record at Local.<op> will also answer for Local.User.Equal — and if Local.User has its own page, an operator asking about it gets both, flagged as though two packages disagreed.

Where one fact's name is a prefix of another's, write that one out per operator (Local.Equal, Local.Present) instead of wildcarding it. The rest of the family is unaffected.

The other limit follows from the space rule: a value name that contains a space can't be reached by a wildcard at all. If the thing you are documenting has an optional-whitespace syntax, say so on the card and recommend the spelling that resolves.

The body's first line is the summary #

There is no summary: field. The first non-empty line of the body is taken as the one-sentence summary — it is what shows next to the value name in a key doc's index and in regman -k search results. So write the body like a good commit message or docstring: lead with one self-contained sentence, then elaborate in the paragraphs below.

The body is Markdown: **bold**, *emphasis*, `code`, headings, and bullet lists render; prose is wrapped to the terminal width. On a non-tty (piped) it renders plain, honouring NO_COLOR. Emphasis carries no styling of its own — the markers simply come off — because italic is not dependable across terminals and bold already covers making something stand out.

A delimiter only counts as markup when its closer is present, so a lone * or backtick stays in the text where you put it rather than disappearing. * and ** also need no whitespace just inside either end, which is what keeps a matched pair that was never markup — 2 * 3 * 4, or *.conf and *.h — from being read as one.

A complete fragment #

/usr/share/regman/exampled.regman, one key doc plus one value doc, as a package would ship it:

--- machine\system\exampled
canonical: Machine\System\Exampled

The Exampled spooler reads its tuning parameters from the values under
this key. Compiled-in defaults apply until LCS is available.

Security is on this key, not per value: every value inherits this key's
Security Descriptor.

--- machine\system\exampled maxqueuedepth
canonical: Machine\System\Exampled MaxQueueDepth
type: REG_DWORD
default: 1024
valid: 16–65536
applies: restart

Maximum number of jobs held in the spool queue before new submissions
are rejected with EAGAIN.

Raising this lets the queue absorb larger bursts at the cost of memory.
The queue is preallocated, so the change takes effect at the next service
restart rather than live.

That renders, for regman 'Machine\System\Exampled' MaxQueueDepth, as:

Machine\System\Exampled MaxQueueDepth                 documented by exampled

  Type     REG_DWORD
  Default  1024
  Valid    16–65536
  Applies  restart

Maximum number of jobs held in the spool queue before new submissions
are rejected with EAGAIN.

Raising this lets the queue absorb larger bursts at the cost of memory.
The queue is preallocated, so the change takes effect at the next service
restart rather than live.

— and the bare regman 'Machine\System\Exampled' renders the key body plus the index:

Machine\System\Exampled                               documented by exampled

The Exampled spooler reads its tuning parameters from the values under
this key. Compiled-in defaults apply until LCS is available.

Security is on this key, not per value: every value inherits this key's
Security Descriptor.

Values
  MaxQueueDepth  Maximum number of jobs held in the spool queue before n…

The fmt/lint workflow #

You write canonical with whatever casing reads best. The fence anchor — the folded, lowercased token the scanner matches — is derived from it, because the registry compares keys case-insensitively (Unicode Simple Case Folding) while a filesystem does not. Keeping the two in sync by hand is exactly the kind of error a tool should own, so two commands own it:

regman fmt  exampled.regman    # bake the folded anchor onto every fence line
regman lint exampled.regman    # verify structure and anchors

The workflow is:

  1. Write each record with its canonical and body. Open the record with a fence line — you can put any placeholder after the dashes, e.g. --- x; regman fmt overwrites it.
  2. Run regman fmt. It rewrites every fence to --- <fold(canonical)> and prints <file>: anchors updated if anything changed. It is idempotent — a second run is a no-op. A record missing canonical is skipped and reported.
  3. Run regman lint. It reports any record with no canonical: and any fence anchor that disagrees with its canonical (anchor for ...: fence has X, expected Y — run regman fmt). A clean fragment prints nothing and exits 0.

Wire regman lint into your build or CI so a malformed fragment fails the build rather than shipping silently broken.

Preview before you ship #

regman honours REGMAN_DIR, so you can point it at your working directory and see exactly what an operator will see, without installing anything:

REGMAN_DIR=. regman Machine\\System\\Exampled MaxQueueDepth
REGMAN_DIR=. regman Machine\\System\\Exampled
REGMAN_DIR=. regman -k queue

Rules the tools won't catch #

regman lint checks structure and anchors. These conventions it does not enforce — they are on you:

  • Never start a body line with --- (three dashes, a space, then text). Any such line is a fence — it will be read as the start of a new record and silently split your page in two. A bare --- on its own line (a Markdown thematic break) is fine; it's the --- text form that bites. If you need a horizontal rule, use *** or ___.
  • Keep applies to the vocabulary live / restart / reboot. A short parenthetical is fine (live (ring-buffer swap)); a freeform sentence defeats the at-a-glance purpose of the field.
  • Lead the body with one summary sentence (above) — an empty or buried first line leaves a blank in the key index and in -k results.
  • Don't invent fields. Unknown header keys are ignored, so defualt: or applies-to: vanish without warning. There is deliberately no access:/SD field (the deployed Security Descriptor is live state a shipped file can't know — say access intent in prose if it matters) and no since: field (the package version already records provenance).

Two documenters, one key #

If two installed packages document the same (key, value), regman does not pick a winner — it shows both records under a documented by N packages: banner, flagging the overlap as the anomaly it is. That's a safety net, not a feature: a key should have exactly one documenting package, the one that owns it. If you find yourself documenting another package's keys, that's usually a sign the ownership is wrong.

Keeping lookups fast #

Lookup is correct with no index at all — regman scans the corpus directly, and at realistic sizes that's a few milliseconds. An optional index (regman index, kept warm by a supervised regman index --watch) just skips the scan; it can be absent or stale without ever producing a wrong answer. None of this is your concern as a fragment author, with one exception worth knowing: package installs must replace a fragment by atomic rename rather than truncate-and-rewrite, so a lookup never sees a half-written file. peipkg already does this, so simply shipping the file the normal way is correct.

See also #

  • The registry manual — the operator's view of regman: reading a knob-card, the -k search, and the intent-not-state boundary.
  • Configuration, not storage — why the registry holds values without their meaning, the idea a .regman page exists to serve.
  • Packages and the recipe format reference — installing the fragment as part of your package.

Writing evman pages

Peios / Developing for Peios / Documenting events

An event record carries a type name and a MessagePack payload, but never its meaning — it does not say that outcome.reason is only present on failure, or which of two masks a field holds. That knowledge ships with the software that writes the event, as documentation evman reads. From the operator's side this is the event manual; from yours, the package author's, it is a file you write and install.

This page is how you write one. It assumes you have already designed your events — their types, fields and tiers — as Naming and shaping your events describes. The normative rules are PGSS §6.10; this page applies them.

Where the documentation lives #

evman reads a drop-in directory, /usr/share/evman/. Each *.evman file in it is one fragment, named for the component that ships it:

/usr/share/evman/
    kernel.evman                # the platform fragment: the shared field vocabulary
    kacs.evman
    org.example.backup.evman    # yours

A package ships one fragment per component, describing every event type that component writes and every field it defines. There is no central index to register with and no install-time hook: dropping the file in is the whole act, and removing it on uninstall is the whole undo.

Ship it the way you ship any other file. A hand-written fragment kept beside the recipe maps straight into the payload from the package file's [files] table:

[files]
"@recipe:org.example.backup.evman" = "usr/share/evman/org.example.backup.evman"

See Packages for ref resolution.

Note

The fragment is not optional documentation. The catalogue is the definition of what may be emitted: an emitter MUST NOT write a field the catalogue does not define (PGSS §6.4), and MUST describe every event type it writes. A package whose events have no fragment is emitting undefined events.

The shape of a fragment #

A fragment is a sequence of records. Each begins with an anchor line — three hyphens, a space, the record kind, a space and the name — then header lines, a blank line, and prose:

--- field object.org.example.backup.snapshot.id
type: bin.guid

The snapshot an event concerns, by the GUID the backup tool assigned it.

There are three kinds of record:

KindDefinesHeader keys
fieldOne field pathtype, and for an enumeration values and closed
groupA named set of fields that travel togetherinclude: lines, one field each
eventOne event typetier, gating if anything decides which occurrences are recorded, cardinality if one occurrence makes more than one record

The prose's first sentence is the summary evman shows in its indexes and -k results, so make it a complete sentence that stands on its own. Inline markup is **bold** and `code` only. The full key tables, including the rarely needed asserted and carried, are in §6.10.

Event records list their fields #

After an event's prose come the fields it carries, one per line, each saying when it is present:

field: object.file.path                       required
field: object.file.size                       when outcome.success == true
field: outcome.reason                         when outcome.success == false
  values: disk-full | source-unreadable | cancelled
  closed: false
  Why the snapshot was not written.
  • Presence is required, optional, or when and a condition on another field the event carries.
  • An indented paragraph after a field: line is a gloss: what the field means in this event. Use it wherever the general definition is broader than what this event puts there. A gloss may narrow a definition and must not contradict it.
  • Indented values: and closed: lines declare the values of a field whose definition leaves them to each event. outcome.reason is the common case: every event has its own reasons.
  • include: <group> carries every field of a group at once.

Define your own fields; reference everyone else's #

This is the rule that matters most, and the one a new author most often gets wrong.

A --- field record defines a field. A field: line on an event carries it. You carry any field defined anywhere — subject.token.sid, object.file.path, outcome.success are all defined in the platform fragment, and your events reference them freely. You define a field only when no existing one has the meaning you need, and then only beneath your own package name, under the role it describes:

--- field object.org.example.backup.snapshot.id

Two fragments defining the same path is an error, and so is redefining a standard field "to be safe": one value with two names is a value a security descriptor can deny under one and leak under the other. Before defining anything, look for it:

evman object.file          # what is defined under object.file?
evman -k snapshot          # does anything already mean this?

If you find a field every package like yours would want and nobody defines, that is a gap in the shared vocabulary worth reporting rather than working around.

Enumerations declare their values #

A field whose type is str.enum or uint.enum lists its values and says whether the list is closed:

--- field object.org.example.backup.snapshot.kind
type: str.enum
values: full | incremental | differential
closed: true

Which kind of snapshot was taken. ...
  • A str.enum value is a kebab-case string. A uint.enum value is a number and the name it renders as: 0 None | 512 Protected.
  • closed: true promises that no value is added without a new version of the field. Consumers rely on it, so only claim it when the set is genuinely complete; otherwise say false, and consumers will cope with a value they have not seen.
  • Take the values from your code, not from memory. Nothing checks that the list matches what your program writes.

A complete fragment #

--- field object.org.example.backup.snapshot.id
type: bin.guid

The snapshot an event concerns, by the GUID the backup tool assigned it.
Stable for the life of the snapshot, so it joins a snapshot's creation to
its later verification and deletion.

--- event org.example.backup.snapshot.created
tier: standard

A snapshot was written, or an attempt to write one failed.

field: object.kind                            required
  Always `file`.
field: object.file.path                       required
  The snapshot archive.
field: object.file.size                       when outcome.success == true
field: object.org.example.backup.snapshot.id  required
field: operation.duration                     required
field: outcome.success                        required
field: outcome.reason                         when outcome.success == false
  values: disk-full | source-unreadable | cancelled
  closed: false
  Why the snapshot was not written.
field: outcome.errno                          optional

Only object.org.example.backup.snapshot.id is defined here. Every other field is the platform's, and the event references it.

The lint workflow #

evman lint /usr/share/evman/*.evman org.example.backup.evman

Lint your fragment together with the installed ones. The rules that matter most — every referenced field is defined somewhere, no path is defined twice, no path is both a value and a map — can only be checked across the whole catalogue, and your fragment references fields defined in others. evman lint checks every rule of §6.10, prints each finding as file:line: rule N: message, and exits non-zero if there is any. A clean catalogue prints nothing.

Wire it into your build so a broken fragment fails the build rather than shipping.

Preview before you ship #

evman honours EVMAN_DIR, so you can see exactly what an operator will see without installing anything. Copy the installed fragments and yours into one directory and point it there:

mkdir -p /tmp/evman && cp /usr/share/evman/*.evman org.example.backup.evman /tmp/evman/
EVMAN_DIR=/tmp/evman evman org.example.backup.snapshot.created
EVMAN_DIR=/tmp/evman evman object.org.example.backup

Rules the tools won't catch #

evman lint checks structure, names, definitions and references. These it does not check, and they are on you:

  • That a value list is right. Lint checks that an enumeration has values, not that they are the ones your program writes.
  • That a condition is true. when outcome.success == false is checked for naming a field the event carries, not for matching when your program actually writes the field.
  • That the gloss agrees with the definition. A gloss may narrow a field's meaning, never change it. If you find yourself writing a gloss that redefines a field, you want a different field.
  • That the tier is honest. essential is for events that are extremely important and rare, or already governed by their own configuration; it cannot be switched off. Packages submitted to the Peios package repository are checked for it by hand.
  • Never start a prose line with --- and a word. It is an anchor: it starts a new record and silently splits yours in two.

See also #

  • evman — the operator's view: reading an event card, a field card, and the -k search.
  • Naming and shaping your events — designing the events this page documents.
  • The Catalogue — the normative format and rules.
  • Writing regman pages — the same job for your registry keys.

Packaging Python software

Peios / Developing for Peios / Python packages

Peios ships no pip. A Python project becomes a package the same way a C project does: a pekit recipe builds it, the files land under /usr, and peipkg installs and verifies them like any other payload. This page is what that recipe does and why, so that a package you write fits the ones already in the pool.

Where the files go #

The interpreter has one directory for installed Python code — its site-packages, inside the triplet directory:

/usr/lib/x86_64-linux-peios/python3.14/site-packages/

There is no separate pure-Python location and no dist-packages. Ask the interpreter for the path rather than writing it out; sysconfig.get_path("platlib") and sysconfig.get_path("purelib") both return it on Peios.

Two things follow from the path. It is versioned, so a package built for Python 3.14 is not visible to 3.15, and every Python package declares that in its dependencies (see The interpreter pin). And it sits inside the triplet directory, so a Python package is architecture = "x86_64" even when it contains no compiled code.

The system interpreter is the org.python.python3 package. Its standard library and shared runtime are in org.python.python3-libs; headers and embedding metadata are in org.python.python3-devel; and the static embedding library is in org.python.python3-static. Applications that only run Python do not need the development or static packages.

Libraries and applications #

Everything in site-packages is importable by every Python program on the system, which makes installing there an interface decision, not a filing one. Peios draws the line by what the software is for:

The package is…Modules go toPackage name
a library — other packages import it (docutils, flit_core)site-packagesthe project's reverse-DNS package name
an application — people run it (meson)/usr/lib/x86_64-linux-peios/<name>/the project's reverse-DNS package name

An application's modules are private: nothing else can import mesonbuild, so its internals never become an accidental API, and two applications cannot disagree about which version of a dependency site-packages may hold. The application's launcher is the one program that knows the address — it prepends the private directory to sys.path before importing. Site-packages stays on the path, so a private application uses shared libraries normally; privacy governs only whether its own modules are offered outward.

Some projects are both — docutils is a library that also ships rst2man. Classify by what other packages need from it: if anything imports it, it is a library, and its tools ride along in the same package.

Building without pip #

A recipe replaces pip with three steps that pip would otherwise do behind the scenes.

Build the wheel by calling the project's PEP 517 backend directly. The backend is named in the project's pyproject.toml; the pool packages Flit Core and Setuptools, and each is a build dependency of the recipe, not of the resulting package:

python3 -c 'import sys, flit_core.buildapi as b; b.build_wheel(sys.argv[1])' "$PEKIT_OUT/wheel"

Run it from the project's source tree. Some backends write into that tree (setuptools leaves build/ and *.egg-info behind); build from a copy when they do, so the fetched source stays pristine. Set PYTHONDONTWRITEBYTECODE=1 for the same reason.

Install the wheel by extracting it — a wheel is a zip archive of exactly the files that belong in site-packages:

python3 -c 'import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])' \
  "$PEKIT_OUT"/wheel/*.whl "$PEKIT_OUT$SITE"

Keep the .dist-info directory: importlib.metadata reads it at run time, and its entry_points.txt is the input to the next step.

Generate the launchers. A project declares its commands under [project.scripts]; the wheel records them in entry_points.txt under [console_scripts], one name = module:function per line. Each becomes a five-line script in /usr/bin:

#!/bin/python3
import sys
from docutils.core import rst2man
if __name__ == '__main__':
    sys.exit(rst2man())

The interpreter is /bin/python3, not /usr/bin/python3: /bin is the runtime view every program is reached through, and a payload never names /usr directly. A private application's launcher carries one more line before the import — sys.path.insert(0, '/lib/x86_64-linux-peios/meson') — again through the view.

Modules can carry a #! line of their own for running them directly: a #!/usr/bin/env python3 or #!/usr/bin/python3 at the top of an installed file. Rewrite it to #!/bin/python3 as well, and do it before compiling the bytecode. A checked-hash .pyc records the hash of its source, so editing the file afterwards leaves the bytecode stale, and Python recompiles it on every import.

Bytecode #

The recipe compiles bytecode and ships it in the package:

python3 -m compileall -q --invalidation-mode checked-hash -s "$PEKIT_OUT" -p / "$PEKIT_OUT$SITE"

Python's default is to write __pycache__ at first import, and Debian's is to write it at install time. Neither is acceptable on Peios: /usr is verified against the package manifest and is never written after install, so a .pyc that is not in the manifest is either a verification failure or a file that can never be written. Shipping the bytecode also removes the cost — every start-up would otherwise recompile.

checked-hash matters. A timestamp-validated .pyc embeds the source file's modification time and goes stale silently; a checked-hash one embeds a hash of the source and is re-validated against it on import. That makes the file reproducible, and it means an operator who shadows a .py through /lcl gets the shadowed source rather than the package's stale bytecode. -s and -p rewrite the path recorded in each .pyc from the staging directory to the installed one, so tracebacks name /usr/lib/....

The interpreter pin #

Every Python package depends on the interpreter's feature version:

[dependencies]
python3 = ">= 3.14, < 3.15"

Bytecode carries a per-version magic number, extension modules carry the interpreter's ABI tag in their file names, and site-packages itself is a versioned path — so nothing in the package survives a minor-version bump. The pin makes that a resolver fact: peipkg will not move the interpreter until rebuilt packages exist. An interpreter bump on Peios is a rebuild of every Python package, which is one pekit sweep.

The Python recipe follows upstream maintenance releases automatically within the current feature line. Its soft floor is Python 3.14 and its upper bound is 3.15: a 3.14 bug-fix release can be locked and rebuilt unattended, while 3.15 deliberately waits for the coordinated package sweep required by the ABI and path change.

The interpreter ships PEP 668's EXTERNALLY-MANAGED marker in its standard library. Nothing on the system reads it — there is no pip — but a pip an operator installs for their own use will find it and refuse to write into /usr, pointing at a virtual environment or /lcl instead. The interpreter also omits ensurepip and its bundled wheel. Create an environment with python3 -m venv --without-pip; install packaged Python software through peipkg.

A recipe, end to end #

The Docutils recipe in the pool is the reference for a library with console scripts: it builds through flit_core, runs the upstream test suite against a copy of the tree, extracts the wheel, generates eleven launchers from entry_points.txt, and compiles the bytecode. Meson is the reference for a private application, and Flit Core for the smallest possible case — a backend that builds itself.

See also #

  • Install destinations — the permitted top-level paths, including why /usr/lib/python3.X is not one of them.
  • Package files — the manifest keys a recipe's package definition may carry.
  • Recipe anatomy — the pekit recipe this page's steps live in.

Qualifying Python build backends #

Flit Core and Setuptools bootstrap themselves with the system interpreter and install-wheel. Their build.main targets remain independent of the Python test ecosystem; their package gates add a separate build.vendor target and offline tests.

The catalogue installer and staged-module checks share the distribution path /usr/lib/x86_64-linux-peios/python<major>.<minor>/site-packages, using the executing interpreter's major/minor version. The interpreter must also report that exact path through sysconfig.

Test-only Python tools are resolved during the explicit acquisition stage, in a native root. Pip runs from its own current wheel, which the acquisition step downloads and checks against the digest PyPI publishes for it, so the root needs only the Peios Python. The selected backend version constrains the candidate tool wheel automatically; other requirements follow the upstream test requirements and current compatible releases. Only portable Python wheels are accepted. The acquisition output retains every wheel, a requirements file and test-tools.json with their hashes. These inputs enter the corresponding-source bundle under acquisition/vendor. This is a per-job resolved record, not a manually maintained version list.

The test worker verifies the complete recorded wheel inventory before creating a private virtualenv. Pip is bootstrapped from its recorded wheel and installs only recorded local files into that virtualenv. Dependency consistency is checked there. Pip and the test ecosystem are not added to the build backend's runtime package or to the system interpreter. Nested virtualenv tests can clear PYTHONPATH and still find their tools. The worker has no external network; local file:// package-index fixtures and recorded wheel files remain usable.

The gates run all Flit Core tests and Setuptools's offline functional suite. Setuptools tests explicitly marked uses_network are excluded: these include its live-PyPI project integration matrix. Release-upload tooling and performance instrumentation are outside this functional package gate. Upstream platform/version skips and expected failures remain visible in JUnit results; a dependency import failure or failed assertion fails qualification.

One recorded adjustment applies only to disposable Setuptools test inputs: the unused coverage-plugin warning category is removed from the copied pytest configuration when coverage instrumentation is not enabled. Other warning errors remain enforced.

Peios's runtime Python deliberately omits CPython's regression tests. Setuptools therefore declares the Python source package as a native test dependency. When test.support is absent, the gate copies its helpers into the private virtualenv from the source matching the interpreter's exact major, minor and micro version. Older source-package archives are checked against their source-lock hash before extraction; prepared source trees are also supported. A version mismatch fails the gate. No test modules are added to the runtime package.

Each backend's installed-payload checks import from the staging directory and exercise wheel, metadata and source-archive hooks. The smoke wheel's complete RECORD is checked against its files, then the wheel is installed into a fresh directory and its module is imported in a separate interpreter with an explicit staged search path. Metadata, vendored code, licenses and checked-hash bytecode are also checked.

For a selected release candidate, run pekit test --version VERSION from its recipe directory, then package and lint it. Only build:vendor has network access; compilation and tests remain offline. Normal release discovery continues to follow upstream automatically.

Presentation hints

Peios / Developing for Peios / MSIP daemons

MSIP keeps what a page contains apart from how it is drawn. A daemon sends a turn of typed elements; a surface draws them however it draws that type. That split is what lets one surface serve every daemon and one daemon be driven by any surface, including a script.

It also means a daemon cannot lay a page out. What it can do is say what a page is for, and let the surface decide what that looks like. PGSS §3.7 gives a turn a class and §3.8 gives an element one — "presentation hints", which the specification is careful to say promise nothing. This page is the vocabulary the stock terminal surface (msip-tui, the renderer behind install-tui and oobe-tui) actually honours, so that a hint you send lands somewhere.

A class a surface does not know is ignored, by design. Sending one costs nothing and breaks nothing; it just does not draw anything either.

Turn classes #

ClassWhat the surface does
(none)A form: heading, elements stacked down a column, the actions in a bar beneath. Right for most pages.
menuThe page is a choice among its actions. They are laid down the body as a list, one highlighted, rather than in a bar. For a page whose only elements are actions and a sentence — a mode chooser, a repair menu.
confirmThe page asks whether you mean it. The heading is marked, and the destructive action is drawn as such even when not focused. For the page before something irreversible.
progressA job is running. The page's progress elements are drawn as a phase list — done, active with a bar, pending — and its log fills whatever height remains.

One class per turn is the expectation; the surface reads each independently, so nothing stops you sending two, but nothing today means anything by the combination.

Element state the surface reads #

These are defined by the element types appendix rather than being surface conventions, but what the surface makes of them is worth knowing when you write a page.

OnStateDrawn as
actionprimaryThe accent colour and bold. Also what an unattended answerer presses, so there is at most one.
actiondestructiveRed, and red-highlighted when focused. Pressing it is not otherwise made harder: the confirm page is where that happens.
stringsecretMasked; never echoed, logged, or carried in an update.
stringplaceholderGhost text in an empty, unfocused field.
tablealign: right on a columnRight-aligned cells. Sizes and counts read better that way.
tableenabled: false on a rowListed, dimmed, skipped by the highlight, refused as an answer.
tablenote on a rowA short remark after the cells — typically why the row is disabled.
tableemptyShown in place of the rows when there are none.
anyhelpSee below.
anyerrorRed, under the element, until an update clears it.

What it does whether you hint or not #

Some of the surface's behaviour is not a hint you can send but a fact to design pages around.

Disabled elements take focus. Tab reaches them, and while one is focused the footer shows its help. This is how the surface treats a thing you show greyed out: as something the person is meant to see and ask about, not something to hide. So an element you disable should carry a help that says why — "no keymaps are packaged yet", "domain membership needs a directory source" — because that sentence is what the person will read.

Focus opens on the first element that can be answered. A page that starts with two disabled fields opens on the first enabled one, or the primary action.

A field's note line is reserved. An element with help or an error always has a line under it, so focus moving does not move the page. Help is shown there while the element is focused; an error is shown there always.

A table drops columns from the right when it has no room for them all, as the appendix asks. Put the column a person must see first.

Leaving is the surface's sentence, not yours. Esc opens a question whose wording comes from the surface binary, because what leaving means — an installation carrying on without you, a machine left without an account — is a fact about the program, not the page.

A script sees none of this. msip-drive prints elements and answers them from its arguments; every hint above is invisible to it, which is the point of hints. A page has to work with none of them honoured.

Rehearse the headless installer in a lab

Peios / Developing for Peios / MSIP daemons

Use this exercise to understand how msip-drive answers an installerd conversation. It simulates an installation against an invented disk. For an actual installation, use Installing to disk.

Important

This is a source-derived rehearsal for installer commit 0b687372ad41132668824216da51558bbfe53c01. The commands below have not been run to validate this guide against an image or released binaries. They are not a validated unattended-install recipe.

1. Prepare a private, disposable lab #

Use a disposable lab instance with the matching installerd and msip-drive binaries and an account allowed to use its lab socket. Create a new private directory, restrict access using the lab system's access controls, and verify that protection before starting. In each terminal, set LAB to that directory's absolute path. The examples do not create or protect the directory for you. Keep any saved output there and use only synthetic, nonsensitive data.

Warning

Never use the default /run/installerd.sock, a production socket, or an old lab socket. Even with --dry-run, daemon startup attempts to remove the supplied socket path, binds a socket there, and calls sd set to apply its descriptor. Descriptor failures only warn; they do not stop startup. A wrong path can disrupt access to an existing service. Inspect warnings and verify the private directory's protection; do not infer protection from a listening message alone.

These are daemon startup effects, including warning-only socket security. Dry-run does not partition, format, or copy to the target disk; it is not a side-effect-free or fully isolated mode.

Save this JSON as inventory.json in that directory:

{"disks":[{"device":"/dev/msip-lab-only","model":"Synthetic MSIP lab disk","size":8589934592}],"controllers":[]}

The device is deliberately invented, not a real target to substitute later. Omitted fields have schema defaults. Always supply the inventory: without it, dry-run still probes this machine's block devices and controllers. With it, the executor uses the supplied synthetic inventory.

2. Start only the lab daemon #

In the first terminal, with LAB set and verified:

installerd --dry-run --socket "${LAB:?Set LAB to the new private directory}/installerd.sock" \
  --inventory "$LAB/inventory.json"

Before starting a client, check that daemon output explicitly says installerd (dry run) listening on followed by the exact private socket path. Stop if the mode, path, or security checks are wrong. Leave the daemon running; do not connect any other surface to it. Use a fresh directory and daemon for each attempt rather than reusing an uncertain conversation.

3. Drive the synthetic conversation #

In the second terminal, with LAB set to the same verified directory:

msip-drive --socket "${LAB:?Set LAB to the new private directory}/installerd.sock" --kind install \
  --set disk.target=/dev/msip-lab-only \
  --press act.install --press nav.next --press act.begin --press act.reboot

The last action is intentional: successful job execution produces a finish page, not an ended conversation. The dry-run executor's restart returns success without rebooting the machine. Do not remove --dry-run or aim this sequence at a real service.

The expected sequence below is inferred from the flow and client, not captured test output:

  1. bound: reports a conversation ID and attached=false.
  2. Turns advance through mode, disk.choose, confirm, install.progress, and install.done. The client waits on the progress turn.
  3. act.reboot produces == end: Complete Restarting the machine. and the client exits with status 0 for that completed conversation.

install.done means the executor reported job success. End Complete means the restart request was accepted or simulated; neither proves a restart or successful disk boot. A real restart request is accepted when svctl shutdown reboot returns success. In this lab, no disk installation or reboot takes place. Simulated percentages do not verify partitioning, copying, image integrity, cleanup, or bootability.

If it stalls or reconnects #

Do not blindly replay the command. The client's Start implicitly joins the daemon's one live, in-memory conversation and replays its current turn. It has no conversation-ID guard and can consume a queued action immediately; checking printed attached=true is not a barrier before an action runs. If that appears in this fresh-lab exercise, stop the attempt and investigate the unexpected state. The new private daemon and exclusive socket are the precondition, not a check performed by the client.

Disconnecting the client does not cancel the daemon's job. A daemon restart loses the in-memory conversation; a failed or completed conversation is also cleared, so a later Start opens a new one. This is not durable recovery. See job lifetime and disconnect and Start handling.

The driver consumes actions only on NewTurn, not same-turn Updated events. A rescan or rejected input/reboot can therefore leave it waiting indefinitely, even with more presses queued. It has no built-in timeout. An external watchdog that stops the client neither cancels the job nor undoes changes. A client with no presses is not a persistent observer: it exits with status 1 when an actionable turn arrives. These limits follow from the client loop and same-turn rejection/patch handling.

Keep the evidence in scope #

  • Protect inventory, console output, and logs. Do not add credentials or first-boot account setup to this exercise. --set values are command-line arguments; only daemon-marked secret elements are masked in the client trace. The client also prints only the last three stored log lines per update, so its output is not a unique, exhaustive audit journal. See trace handling and log rendering.
  • The checked-in VM queue-inspection script omits act.reboot and logs, rather than asserts, the client's exit status. At this revision, a successful job reaches the actionable done turn with no presses left, so the client exits 1. That script is not proof of a completed headless conversation; do not copy its real-device or mounting commands here.
  • When finished, stop only the dedicated lab daemon. A real unattended-install guide still needs runtime validation of the shipped binaries, failure and reconnect handling, and independent checks of the installed disk and boot.

Writing a feature

Peios / Developing for Peios / Writing features

A feature is how a package does more than put files in place: define a service, change a setting, make something its files rely on. Reach for one only when the package can't do without it. A feature's scripts run as whoever turns it on, with that person's authority and no more, and each one is code an Administrator has to trust.

Its directory #

A feature is a directory your package installs under /usr/libexec/features/<name>/, read at /libexec/features/<name>/. Its name is letters, digits, -, _ and .. In it, any of:

FileRuns
install.shTo install it: set up what it needs, not yet in use.
enable.shTo turn it on.
disable.shTo turn it off, leaving what install set up.
uninstall.shTo remove it: take away what install set up.
feature.tomlNothing: it says what the feature is.

Ship only the scripts that have something to do. A step with no script just records the new state. Other files may sit beside them for the scripts to use.

What a script is given #

Each script is run directly, so it needs a #! line and to be executable. It runs:

  • as whoever asked, with their token: feat adds no authority;
  • in the feature's directory, so it can name the files beside it;
  • with FEAT_NAME, FEAT_DIR and FEAT_PHASE (install, enable, disable or uninstall) set, beside the rest of the caller's environment;
  • with the caller's standard output and error, or, under a program such as Feature Manager, with its output read line by line and shown, and its standard input empty.

A script that exits with anything but 0 has failed: the feature is left interrupted, and the person is shown what the script said.

The rules a script keeps #

  • Be safe to run again. Asking again after an interruption reruns the script from the start, whatever it had done. Set values rather than add to them; treat "already there" and "already gone" as done.
  • Be safe to run first. An interrupted uninstall can be followed by an install, and an interrupted turn-on by a turn-off, so a script can't assume the step before it finished.
  • Say what you did, briefly. Each line is shown to the person, under Feature Manager as the change runs. Start lines with the feature's name.
  • Fail with words. Write why to standard error before exiting non-zero. It is what the person sees.
  • Ask nothing. There may be no terminal, and standard input may be empty.
  • Undo what you do. uninstall.sh takes away everything install.sh made; disable.sh reverses enable.sh.

feature.toml #

title = "Dynamic Boot"
description = """
Keeps this machine's boot image up to date. Two services, mkirf-watch and
mkuki-watch, make it again whenever the kernel, the initramfs or the kernel
command line changes, so a change to any of them is used at the next boot
without making the boot image by hand.

Turning it on or off takes effect at the next boot."""
MemberIs
titleIts name, in Title Case, for people. Without one, its directory's name is shown.
descriptionWhat it does, in a sentence or a few. Wrap lines as you like: they are joined, and a blank line starts a new paragraph. Say when a change takes effect, if not at once.

Both are optional, and members feat doesn't know are ignored. A feature.toml that can't be read doesn't hide the feature: it is listed, with the problem said beside it.

Packaging it #

Map the files into place in your package's [files]:

[files]
"src/feature.toml" = "usr/libexec/features/dynamic-boot/feature.toml"
"src/install.sh"   = "usr/libexec/features/dynamic-boot/install.sh"
"src/enable.sh"    = "usr/libexec/features/dynamic-boot/enable.sh"
"src/disable.sh"   = "usr/libexec/features/dynamic-boot/disable.sh"
"src/uninstall.sh" = "usr/libexec/features/dynamic-boot/uninstall.sh"

and depend on dev.peios.peiosutils, which ships feat, and on whatever the scripts run. Installing the package runs none of them: the feature waits, not installed, until someone turns it on.

A feature's package is named for it, feat-<name>, as Peios's own dev.peios.feat-dynamic-boot is: a whole example.

Writing a boot hook

Peios / Developing for Peios / Writing boot hooks

A boot hook is a shell script that runs inside the initramfs, before the real root is mounted — during the initramfs stage. Hooks are how Peios keeps prelude deployment-agnostic: every part of early boot that depends on this particular machine is a hook, and prelude runs the hooks without knowing what any of them do.

The work hooks do is the work of getting to the real root: loading the storage driver that makes the system's disk visible; unlocking an encrypted container; assembling an LVM volume group or a RAID array; and, finally, mounting the real root filesystem onto /mnt/rootfs. None of that is the same on two arbitrary machines, so none of it is built into prelude — it is all hooks. prelude supplies the fixed skeleton of the boot; hooks supply everything that varies.

Where hooks live #

Hooks live in two directories, which mirror the layout's vendor/operator split everywhere else:

DirectoryFor
/usr/libexec/prelude/hooks.d/hooks shipped by packages
/lcl/libexec/prelude/hooks.d/hooks an operator put there by hand

Every regular file directly in either directory is a hook, and prelude runs all of them. Neither is searched recursively — a subdirectory is not a hook — and the file extension does not matter. A hook is recognised by being a file in one of those directories, and it is run according to its #! shebang line, the same way any script is run.

A file name identifies a hook. The same name in both directories is one hook with two candidate bodies, not two hooks: the operator's copy wins and the packaged one is skipped, so a shipped hook can be replaced without deleting a package's payload. The build says which file lost, because a hook silently replaced by another is exactly the kind of thing that should not be quiet.

An older hooks/ directory at the initramfs root is still read, and every hook found there produces a build warning naming where it should move. That is deliberate: a hook in a directory nobody scans is not an error — it simply is not there, and the boot fails much later with nothing mounted — so the move cannot be a flag day. The warning going quiet is what says the migration is done. Choose a shebang whose interpreter exists in the initramfs when the hook runs. The examples below use #!/usr/bin/sh; this is not a universal requirement. The reviewed disk-root hook uses #!/bin/sh. Verify the interpreter and required tools in the image you build.

How hooks get there #

There are two ways a hook reaches the directory, and they are identical as far as prelude is concerned:

  • From a package. A package can supply a hook under /usr/libexec/prelude/hooks.d/, together with the tools it needs. Verify the selected image actually includes both; the encryption examples below do not establish that a particular encryption package is available.
  • By hand. An administrator can write a hook and place it in /lcl/libexec/prelude/hooks.d/ directly. A site with an unusual storage arrangement, or a one-off need, does not have to build a package — a script in the directory is a hook.

Either way, the next time the initramfs is built the new hook is picked up. (See The initramfs stage for the build.)

The ordering problem #

Hooks have an order, and the order matters. A hook that mounts the root cannot run before the hook that loads the disk's storage driver; a hook that unlocks an encrypted volume cannot run before that volume's driver is loaded. Run them in the wrong order and the boot fails.

The obvious approach — number the files, 10-modules, 20-crypto, 30-mount, and run them in numeric order — is the approach Peios deliberately does not take. Numeric prefixes work right up until two packages, written by people who never spoke to each other, both pick 20. Then every package author has to know the whole number line, and the "order" is a fiction held together by convention. This is the historical sysvinit problem, and it does not scale.

Instead, a hook declares what it needs and what it offers, in terms of named capabilities, and the order is computed from those declarations. The hook that mounts the root says "I require crypto-unlocked"; the hook that unlocks encryption says "I provide crypto-unlocked"; the order follows. No hook needs to know any other hook's name or position — only the capabilities. Packages that never met each other compose correctly because they agree on capability names, not on numbers.

The metadata block #

A hook declares its capabilities in a metadata block at the top of the script: a fenced comment block, every line of it a comment, so the block is invisible to the shell when the script actually runs.

#!/usr/bin/sh
# /// hook
# contributes = ["rootfs-ready"]
# ///

# ... the hook's actual work follows ...
cryptsetup open /dev/sda2 root

The rules of the block are small and exact:

  • It opens with a line that is exactly # /// hook and closes with a line that is exactly # ///.
  • Every line in between is a comment line — # followed by content, or a bare #. This is what keeps the block inert: the shell sees only comments.
  • The content of those lines, with the # stripped, is up to four keys:
    • provides — capabilities this hook satisfies on its own. Several hooks may provide the same capability, and any one of them suffices; they are alternatives.
    • contributes — capabilities this hook is one part of. Every contributor must complete before the capability counts as satisfied.
    • requires — capabilities that must already be satisfied before this hook runs. If nothing supplies one, the build fails.
    • after — capabilities to be ordered after if anything supplies them. If nothing does, the hook simply runs; this is not an error.
  • Each key's value is a list of capability names in double quotes — provides = ["a", "b"]. An empty list is allowed, and a trailing comma is allowed.

Why there are four keys and not two #

The four are two ways to supply a capability crossed with two ways to consume one, and each pair exists to say something the other cannot.

contributes is how a hook runs before something. A hook is otherwise ordered only by what it consumes — so "run before the root is mounted" would require the root-mounting hooks to name your hook, which they cannot, because they were packaged before it existed. Declaring yourself part of a capability inverts the relationship: everything that consumes it now waits for you. Without this, the only way to be early is to win the file-name tie-break, which is the numeric-prefix problem wearing a disguise.

after is how a shared vocabulary survives a small image. A requires on a capability nothing supplies is a build error — deliberately, since it catches an initramfs that cannot possibly boot. But that makes any standard capability name dangerous: a hook mentioning network-up would break every image that has no networking. after expresses "if this happens at all, it happens before me", which is what most ordering against an optional milestone actually means.

provides and contributes differ in what "satisfied" means. With alternatives, one supplier doing the job is enough — for example, two hardware-specific implementations of the same discovery step in a custom image. With contributors, the capability is not satisfied until every one of them has completed — three hooks each unlocking a layer of an encrypted stack, say. A capability must be one or the other; declaring it both ways is a build error, because there would be no answer to whether it is satisfied yet.

The format is a small, deliberate subset of TOML: enough to declare four optional lists of names, and no more. It is checked strictly when the initramfs is built — an unknown key, a list that is not well-formed, a block that is opened and never closed, two blocks in one file — each of these is a build error, not a quiet misread. A typo in a hook's metadata is caught at build time, with a message naming the hook and the line.

The metadata lives inside the hook script, not in a separate file, so a hook is a single self-contained thing: copy the script and its ordering travels with it.

The capability vocabulary #

Capabilities are just names, and a hook can in principle name its own. But the milestones every initramfs passes through are a small fixed vocabulary, so that hooks from unrelated packages line up against the same reference points:

CapabilityMeaning
initramfs-readyThe initramfs itself is usable — its filesystem topology assembled, its console set up.
rootfs-ready/mnt/rootfs is mounted and ready for read/write manipulation.
rootfs-strata-readyThe full StrataFS topology exists inside /mnt/rootfs.

The vocabulary is deliberately short. Names for things Peios does not yet do — device settling, volume assembly — are not reserved in advance: a capability that nothing supplies and nothing consumes is a name with no meaning behind it, and inventing one early only fixes a shape before we know it. Driver loading, which Peios does do, has no capability of its own for the same reason: the coldplug hook simply contributes to rootfs-ready, which is what it means for the root-mount hooks, and nothing has yet needed to order against it more precisely than that.

Once every hook has finished, prelude chroots into /mnt/rootfs. There is no "last point at which a hook can run" capability, because that is already every hook's guarantee.

initramfs-ready is the one capability with a rule #

A hook that must run before all the others cannot say so by ordinary means — it would need every other hook to name it, including hooks written by people who have never heard of it, whose forgetting would not fail the build but would quietly run their hook in an initramfs with no /bin.

So initramfs-ready carries one extra rule:

Every hook that does not supply initramfs-ready is implicitly ordered after it.

The exemption is derived, not declared — you are exempt exactly when you are part of the capability. There is no cycle to construct and nothing for a hook author to remember. A hook assembling the initramfs's own topology simply declares contributes = ["initramfs-ready"] and lands before everything.

Three things fall out of the existing rules rather than needing special cases. An initramfs with no contributors leaves the capability unsupplied, and an after on an unsupplied capability is vacuous — so a minimal initramfs just runs, with nothing to configure. A hook carrying no metadata block does not gain the edge, because it already runs after every declaring hook; adding it would have pulled the escape hatch into the DAG. And a hook that already waits on initramfs-ready, by requires as readily as by after, keeps the declaration it wrote: the implicit edge would order it no differently.

mkirf materialises these edges into the sequence as ordinary after entries, rather than leaving prelude to know the rule. That keeps one implementation instead of two that have to agree, and makes the sequence file explain itself — in a rescue shell you can read why a hook ran where it did, instead of needing a rule that appears nowhere in the image. Each stanza names a dependency once: a hook that declared requires = ["initramfs-ready"] shows that line and no after beside it, because the two keys mean different things at boot — requires waits for the capability to be achieved, after only for it to be settled.

More than one hook may supply the same capability #

Nothing limits a capability to a single supplier, and rootfs-ready is where that matters: getting a root filesystem ready is several jobs. A hook that unlocks an encrypted container, a hook that assembles a volume group, and a hook that performs the mount each contributes = ["rootfs-ready"], and the capability is not achieved until all of them have finished. None of them needs to know the others exist.

A capability does not have to be a hard, machine-specific thing. What matters for ordering is the declaration, not how much work the hook does to honour it. An encryption hook on a machine with no encrypted volumes has nothing to unlock and declines (exit 69) — which, for a capability it contributes to, completes its part: "nothing needed doing here" is a way of being done. The capability is still achieved, and everything waiting on it proceeds.

How the order is resolved #

When the initramfs is built, mkirf reads every hook's metadata and computes the running order:

  • A hook that consumes a capability (by requires or by after) runs after every hook that supplies it (by provides or by contributes). That single rule is the whole of the ordering.
  • Hooks with no ordering relationship between them run in a stable, predictable order — by file name — so the resolved sequence is the same every time the initramfs is built.

Note that ordering does not distinguish alternatives from contributors, nor hard requirements from soft ones: all four keys produce the same "supplier first" edge. What they change is validity — whether an unsupplied capability is an error — and what a capability being "satisfied" will mean to prelude at boot.

Three situations are build errors. They stop the build, and no initramfs is produced:

  • A cycle — hook A consumes something B supplies, and B consumes something A supplies. There is no order that satisfies both; the build reports the hooks caught in the cycle.
  • An unsatisfied requirement — a hook requires a capability that nothing installed supplies. Rather than build an initramfs that is guaranteed to fail at boot, mkirf reports the missing capability. An after on an unsupplied capability is explicitly fine.
  • A capability that is both provided and contributed to — one hook calls it an alternative, another calls itself a part of it. The two answers to "is it satisfied?" contradict each other, so mkirf names both sides and stops rather than picking one.

This is the payoff of declared capabilities: an impossible or incomplete hook set is a build error on a running system, with a readable message, never a boot that hangs with no explanation.

The resolved order is recorded inside the initramfs image, at /system/prelude/hooks.seq.<n>, and prelude reads it at boot. An operator does not write or edit those files — they are generated. The hook directories are the source of truth; the order is derived from what they contain.

The <n> is the format version, and mkirf writes every version it can express faithfully, prelude reading the newest it understands. That is not ceremony: mkirf ships in peiosutils and prelude in its own package, so an image can pair a newer writer with an older reader, and separate files are what let one image satisfy both. A sequence newer than prelude understands is a warning when a usable one sits beside it, and a boot failure when it is the only one present. The warning arrives on the console tagged [ WARN ], naming both the sequence it passed over and the one it fell back on, and it is one of the lines peios.quiet=2 does not remove: the boot is not running the manifest the image considers authoritative, and that is the sort of thing you need to know from a quiet production boot that afterwards behaves oddly.

prelude checks the manifest it is given rather than trusting the build that wrote it — the two ship in separately versioned packages, so an image can pair a writer and a reader that were never tested together. A stanza key before any hook, an unknown key, a hook line with no path, a marker that disagrees with the file name, and a requires naming a capability nothing in that sequence provides or contributes to all refuse the boot, naming the hook and what was wrong with it. The last of those is the same refusal mkirf gives at build time, in the same words. An after on an unsupplied capability stays fine at boot exactly as it is at build time.

A missing sequence is always an error, never "no hooks to run" — mkirf writes one for every image, including an empty one for an image with no hooks at all. Were an absent file read as "nothing to do", a manifest that failed to generate would become a boot that silently ran no hooks, mounted no root, and reported the failure a long way from its cause.

Hooks with no metadata #

A hook is allowed to carry no metadata block at all. It is still a valid hook — it simply has no declared capabilities, and therefore no ordering constraints. Such a hook runs after all the hooks that do have constraints, in file-name order.

The build emits a warning for a hook with no block — because a hook that merely forgot its metadata looks identical to one that genuinely has none, and the warning makes the difference visible. A hook that is deliberately unconstrained can carry an empty block — a # /// hook line immediately followed by # /// — to say so on purpose, which suppresses the warning.

How prelude runs a hook #

At boot, prelude runs the hooks one at a time, each to completion before the next begins, scheduling from their declarations — repeated passes over the sequence, running whatever is ready, retrying anything that deferred once something else has progressed. Each hook runs as a separate process with a minimal environment — PATH=/usr/bin and TERM=linux — so the initramfs's shell and utilities (dash, peiosutils, and any tools a feature package added) are found without depending on a not-yet-mounted root-level view.

A hook reports what happened through its exit code, and there are four things it can say:

ExitMeaningWhat prelude does
0Satisfied — I did my part.Its provides are achieved; its contributes are one step closer to complete.
69Declined — not applicable on this machine.It is not counted toward the capabilities it declared.
75Deferred — I cannot run yet, and I have changed nothing.Re-queued and tried again once something else has made progress.
anything elseFailed.The boot stops and the machine halts.

The two middle codes are borrowed from sysexits.h (EX_UNAVAILABLE, EX_TEMPFAIL) rather than invented, for a practical reason: 1 and 2 are what any failing command returns and 126, 127 and 128+n are the shell's own, so a small dedicated range is the only place a deliberate signal cannot be mistaken for an accident. A hook killed by a signal is always a failure, whatever code it might have produced. So is one that is stopped by a job-control signal: prelude reports it as stopped, kills it, and ends the boot. Nothing in the initramfs can resume a stopped hook — there is no job control, no shell and no other process to send it SIGCONT — so waiting for it would be waiting forever.

Declining is not the same as succeeding #

A hook that is not the right one for this machine declines (69) rather than exiting 0. The difference matters because provides means alternatives: a capability with several providers is achieved as soon as one of them is satisfied, and a provider that exits 0 is claiming to be that one.

A hardware-specific provider can use this to stand aside when the machine has no device it understands. If every provider declines, the capability is never achieved and prelude stops with a message naming it.

Live boot and disk boot deliberately do not use runtime decline as their selector. Their initramfs packages conflict, and the installer replaces the live package with the disk package before rebuilding the image. The root-mount hook that is present therefore owns the job: disk-boot treats a missing root= as a broken image, while live-boot treats a missing medium as a failure. This moves an impossible two-flavour image to package-resolution time and preserves the actual cause when a selected flavour is misconfigured.

For contributes the sense is reversed: since every contributor must complete, a contributor that declines has completed — "nothing needed doing here" is a way of being done.

Deferring is a promise that nothing happened #

Deferred (75) means the hook's inputs are not ready yet. prelude runs hooks in repeated passes, retrying deferred ones once something else has progressed, and stops with a diagnosis if a whole pass completes nothing while work remains.

The contract that makes this safe is that a deferring hook has done nothing. Re-running it is free in a way that re-running a failed hook could never be — a failure may have left a half-assembled array, an opened device, or a burnt unlock attempt behind it. So:

  • Defer before you act, never after. Check whether you can do the job; if not, exit 75 immediately. Do not defer partway through.
  • Failure is still failure. If the work was attempted and went wrong, exit non-zero. Deferring a genuine error turns one clear failure into a stall reported as "nothing left to wait for".

This is what lets layered arrangements compose without absolute ordering: three hooks each contributing to an encrypted stack can be written without knowing which layer comes first, because the ones whose devices do not exist yet simply defer and are retried.

A few more consequences for anyone writing a hook:

  • Check, and exit non-zero on failure. A hook that mounts the root must exit non-zero if the mount failed. A hook that fails silently turns into prelude's generic "nothing mounted the root" failure later — which is harder to diagnose than the hook reporting its own error at the point it happened.
  • Hooks run as SYSTEM. Everything in the initramfs runs on the SYSTEM token (see Bootstrap tokens), so a hook has full authority. There is no identity model to work within inside the initramfs — that begins on the real root.

Reporting to the console #

The exit code is what prelude reads. What a person reads is whatever the hook writes to its own stdout and stderr, which go straight to the console: prelude runs each hook with a bare fork and execve and no pipe, so it never sees a hook's output and cannot label, align or colour it. That is deliberate — it keeps a PID 1 free of a pump it would otherwise have to drain — but it does mean the only place a hook's lines can be shaped is inside the hook.

So that every hook does not invent its own shape, prelude ships a helper. Source it and report through it:

. /usr/libexec/prelude/hook-log.sh
hook_log_init stratafs-base

log      "mounting $target"     # progress, no outcome yet
log_ok   "mounted $target"      # it worked
log_skip "$target not present"  # deliberately not done
log_warn "$target was already mounted"   # wrong, but the boot continues
log_fail "mount $target failed"          # it did not work

which produces lines that line up with prelude's own and with peinit's later:

[      ] stratafs-base: mounting /bin
[  OK  ] stratafs-base: mounted /bin
[ SKIP ] stratafs-base: /opt not present
[ WARN ] stratafs-base: /bin was already mounted
[FAILED] stratafs-base: mount /bin failed

Three rules worth stating:

  • Pass your own name to hook_log_init, not prelude and not the file name. These lines are yours, and attributing them to prelude is how a hook's failure gets read as prelude's.
  • Match the verb to the tag. log_ok claims the thing is done, so it reads "mounted", never "mounting". A hook that announces its intention and then reports the outcome writes two lines, not one ambiguous one.
  • The helper is prelude-hook-abi level 3. Declare prelude-hook-abi = ">= 3" in the package. Sourcing a file that is not there aborts the hook under set -e, so this is a hard requirement rather than a preference.

log_warn and log_fail write to stderr; the rest write to stdout. Both reach the console. Colour is decided the same way prelude and peinit decide it — on unless the kernel command line says TERM=dumb — so a hook never has to think about it.

Writing a hook #

These examples illustrate hook metadata and exit handling, not a ready-to-use encrypted-root configuration. They assume the named devices, interpreter, tools and suitable mount/access policy are supplied by your image. Adapt and test them in an appropriate development image before using them for boot; the literal device names are examples.

A minimal example that mounts an ext4 root from a known partition:

#!/usr/bin/sh
# /// hook
# contributes = ["rootfs-ready"]
# ///

# The device may not exist yet — another hook may still have to unlock or
# assemble it. Defer rather than fail: exit 75 promises we changed nothing,
# so prelude can retry us once something else has made progress.
[ -b /dev/mapper/root ] || exit 75

mount -t ext4 /dev/mapper/root /mnt/rootfs || exit 1

What this declares: it is one part of rootfs-ready, so anything waiting on a usable root filesystem is ordered after it. It does not name the hooks it depends on, because it does not know them.

A hook that unlocks the container it mounts, to pair with it:

#!/usr/bin/sh
# /// hook
# contributes = ["rootfs-ready"]
# ///

# Nothing encrypted on this machine — decline, which completes our part of
# the conjunction without claiming to have unlocked anything.
[ -e /dev/sda2 ] || exit 69

cryptsetup open /dev/sda2 root || exit 1

Installed together, these two compose without either one naming the other, and without an ordering declaration between them at all. If the mount hook runs first it finds no device and defers; the unlock hook then runs; the mount hook is retried and succeeds. Add a volume-assembly hook later — also just contributes = ["rootfs-ready"] — and it slots in the same way, because each hook knows only whether it can act yet.

That is the model: hooks are composed by capability, ordering is declared only where it is genuinely known, and the rest is settled at boot by hooks that can say "not yet".

What hooks are not #

A few clarifications:

  • Hooks do not run on the real root. They run inside the initramfs, before the handoff. Work that belongs to the running system — starting services, applying mount and access policy — is peinit's job, not a hook's.
  • A boot hook is not a service. "Hook" here means specifically an initramfs hook. The initramfs stage ends when prelude execs the real init; everything after that — services, supervision, restart policy — is peinit's domain and works nothing like a hook.
  • The generated order file is not edited by hand. It is the build's record of the resolved order. The hooks/ directory is the source; the order is computed from it, every time the image is built.
  • File names are not the ordering mechanism. A file name identifies a hook, and is the tie-break between hooks that have no capability relationship. Renaming a hook does not change where it runs relative to a hook it shares a capability with — the provides/requires declarations do that.

Where to go next #

For the stage that runs the hooks, read The initramfs stage.

For the build that validates hooks and resolves their order, read mkirf.

For what takes over once a hook has mounted the real root, read peinit at PID 1.

Implementation scope #

The metadata and ordering behavior is described by mkirf's hook implementation, and the runtime outcomes by prelude's scheduler. The console helpers come from hook-log.sh. These pinned sources do not verify that a particular released image supplies the tools or storage configuration used in an illustrative hook.

What is the Peios SDK

Peios / Developing for Peios / SDK basics

The Peios SDK is how your own software talks to Peios.

Peios exposes its security world — identities, tokens, access checks, file security, the registry, and the audit/event stream — through a kernel interface built out of raw syscalls, ioctls, and packed byte buffers in the MS-DTYP wire formats. That interface is precise, but it is not something you want to hand-assemble from C. The SDK is the layer that lifts it into ordinary functions you can call: build a SID, open a token, run an access check, read a registry value, emit an event.

It is a C ABI, not a C-only library. The shipping product is a set of hand-written C headers and a shared object with a frozen application binary interface. Anything that can call C — Go via cgo, Rust via bindgen, Python via ctypes, Zig, C++ — can link it and get the same surface. The library happens to be implemented in Rust, but that is invisible across the boundary: callers see peios_* functions, <peios/*.h> headers, and errno.

Who it's for #

You want the SDK if you are building software that runs on Peios and needs to participate in its security model — a service that checks whether a caller may perform an action, a tool that reads or writes security descriptors, an agent that emits audit events, or anything that reads and writes the registry.

You do not need it to simply run on Peios. Ordinary POSIX programs run under Peios's Linux-compatibility surface without ever linking libpeios. The SDK is for programs that want to reach past POSIX and speak KACS, LCS, and KMES directly. If you are administering a running system rather than writing code against it, the Peios operator documentation is the place to start.

The three surfaces #

Peios's kernel boundary has three subsystems, and the SDK mirrors them one-to-one. Everything in the SDK belongs to one of these:

SubsystemWhat it isSDK headers
KACS — Kernel Access Control SubsystemIdentities, tokens, access decisions, file security, and process security. The heart of the model.<peios/security.h>, <peios/token.h>, <peios/access.h>, <peios/file.h>, <peios/process.h>
LCS — the registryA hierarchical, transactional, secured key/value store — Peios's system configuration database.<peios/registry.h>
KMES — the event systemThe msgpack-framed audit and event stream: emit events, consume them.<peios/msgpack.h>, <peios/event.h>

The umbrella header <peios.h> pulls in all of them; you can also include the individual concept headers for a tighter compile surface.

libpeios, librsi, and the substrate #

The SDK is two libraries on a shared foundation, each with its own role:

  • libpeios — the userspace C-ABI library for KACS, LCS, and KMES. It is the registry client and the whole of the access-control and event surface, and it is what the bulk of this documentation describes. Link -lpeios, include <peios.h>.
  • librsi — the library for implementing a registry source (a storage backend): the provider counterpart to libpeios's registry client. Where libpeios reads and writes the registry, librsi is what a program uses to be the thing that holds the data and answers the kernel. Link -lrsi, include <rsi.h>. See Registry sources.
  • peios-cabi — the internal C-ABI substrate both libraries are built on (the allocator wiring, the errno slot, the syscall/ioctl wrappers, the getxattr-style buffer helpers). You never link it directly; it is shared plumbing, documented here only so the conventions it sets make sense.

So the registry has two sides in this SDK: the client side in libpeios (open keys, read/write values) and the source side in librsi (back the keys and values, serve the kernel's requests). Most programs are clients; you reach for librsi only when you are providing storage.

Because the two libraries share one substrate, one error model, and one audience, they share one doc set. Learn the conventions once and they hold across both.

What this documentation promises #

This is user-facing documentation, not a specification. The authoritative byte-level contracts live in the PCSA specifications — PCDS for the core data structures, PGSS, PSPK and PSPU for the interfaces — and in the <pkm/*.h> kernel headers; where a detail is normative, this documentation points you at the specification that owns it. Where a detail is about this implementation rather than the contract, it points at the Peios Kernel manual instead. What you get here is the working knowledge to use the library well: what every function does, what it returns, how memory and errors flow, and how the pieces fit together — with enough coverage that you should not need to open the library's source to answer a question. When you find a gap, that is a documentation bug worth reporting.

Where to go next #

  • Installing and linking — the packages, the headers, and how to build against the library.
  • Library conventions — the error model, the memory rules, and the buffer protocol that every function in the SDK follows. Read this once; it saves you re-learning it per module.
  • Your first program — a small, complete program you can compile and run.

Installing and linking

Peios / Developing for Peios / SDK basics

libpeios ships as a small set of packages, split the same way a C library conventionally is: a lean runtime package that programs depend on, and a development package that carries the headers and the linker symlink. You install the runtime everywhere the library is used and the development package only where you compile.

The packages #

PackageContentsWhen you need it
dev.peios.libpeiosThe versioned shared object libpeios.so.0 (the runtime soname).At runtime, on every machine that runs a program linked against the library. Pulled in automatically as a dependency of anything built against it.
dev.peios.libpeios-develThe public headers (peios.h + peios/*.h), the unversioned libpeios.so linker symlink, and the peios.pc pkg-config descriptor. Depends on a matching dev.peios.libpeios and the Peios kernel UAPI headers.At build time, on machines where you compile.
dev.peios.libpeios-staticThe static archive libpeios.a.Only if you link the library statically instead of against the shared object.
dev.peios.libpeios-debuginfoSplit DWARF debug info, build-id indexed.Debugging or profiling through the library.
dev.peios.libpeios-debugsourceThe referenced Rust sources for the debug info.Stepping into the library's own source in a debugger.

Installing dev.peios.libpeios-devel pulls in the matching dev.peios.libpeios runtime and kernel-headers automatically — the development package pins the exact runtime version whose ABI its headers describe, so the headers you compile against and the shared object you load cannot disagree.

Obtaining the SDK #

On a Peios system, the development package comes from the package repository the system is configured with:

peipkg install dev.peios.libpeios-devel

The packages are built, signed and published into the Peios repository, but that repository is not yet hosted at a public address. Until it is, the source is the route for anyone outside the project. All of it is public:

RepositoryWhat it is
https://github.com/peios/libpeiosThe library. cargo build --release produces libpeios.so and libpeios.a; the headers are in include/.
https://github.com/peios/librsiThe registry-source library, built the same way.
https://github.com/peios/peios-rsThe Rust peios and peios-sys crates.
https://github.com/peios/peios-cabiThe shared C-ABI substrate both libraries are built on.

The library builds themselves need nothing beyond a Rust toolchain: they pin the kernel ABI through their Cargo dependencies. Compiling a C program against the resulting headers needs the <pkm/*.h> kernel UAPI headers described below.

The headers #

Everything lives under <peios/…>, with one umbrella:

#include <peios.h>          /* the whole API */

or, for a tighter compile surface, just the concept headers you use:

#include <peios/security.h> /* SIDs, security descriptors, ACLs */
#include <peios/token.h>    /* tokens */
#include <peios/access.h>   /* access checks */
#include <peios/file.h>     /* file security */
#include <peios/process.h>  /* process security */
#include <peios/registry.h> /* the LCS registry */
#include <peios/msgpack.h>  /* msgpack framing */
#include <peios/event.h>    /* the KMES event stream */

The headers are hand-written and are the real API — they carry the prose docs and the layout notes that a generated header can't. (Internally they are checked against the library's Rust surface by a cbindgen-based verifier, so they cannot silently drift from what the shared object actually exports. You do not interact with that machinery; it just means the header you read is the contract you get.)

The <pkm/*.h> dependency #

The Peios headers do not re-invent the kernel's wire constants — they use them directly. So <peios/security.h> includes <pkm/sid.h> and <pkm/sd.h>, and the other headers pull in their matching <pkm/*.h> UAPI headers for the KACS_*, LCS_*, and KMES_* constants and the #[repr(C)] argument structs. Those PKM kernel UAPI headers must be on your include path when you compile. They ship with the Peios kernel headers; on a normal Peios development install they are already where the toolchain looks. If a build fails with fatal error: pkm/sid.h: No such file or directory, that is what is missing — add the kernel UAPI include directory to your compiler's search path.

Compiling and linking #

With pkg-config (recommended) #

The development package installs a peios.pc descriptor, so pkg-config knows the include and library flags:

cc myprog.c $(pkg-config --cflags --libs peios) -o myprog

pkg-config --cflags peios expands to the include flags and --libs peios to -lpeios plus the library directory. This is the form to prefer — it stays correct across install prefixes and multiarch library directories.

By hand #

If you are not using pkg-config, link against -lpeios directly:

cc myprog.c -lpeios -o myprog

Add -I / -L flags if your headers and library live outside the compiler's default search paths.

Statically #

Install dev.peios.libpeios-static and point the linker at the archive:

cc myprog.c -o myprog /usr/lib/x86_64-linux-peios/libpeios.a

The static archive is also what the library's own integration tests link against, so it is a fully supported way to build. Static linking folds the library into your binary — you then do not need the libpeios runtime package on the target, though you still need whatever C runtime your program uses.

Linking from other languages #

Because libpeios is a C ABI, any language with a C FFI can call it. The shape is always the same: point the FFI at the libpeios.so.0 shared object (or the static archive), declare the peios_* functions with their C signatures, and follow the library conventions for buffers and errors exactly as a C caller would.

  • Rust — use the maintained bindings: the safe peios crate (or the raw peios-sys), covered in Using the SDK from Rust. You don't need to hand-roll bindgen. (The library is Rust internally, but the crates still bind it strictly as a C library through the stable ABI — the supported entry point.)
  • Go — cgo against <peios.h> with #cgo pkg-config: peios.
  • Python — ctypes or cffi against libpeios.so.0.
  • C++ — include the headers directly; they are wrapped in extern "C" and compile as C++.

The one rule that matters across every language: the errno-based error model and the caller-buffer / two-call buffer protocol are part of the contract, not a C convenience. Read Library conventions before you wrap the API in another language's idioms.

librsi — the registry-source library #

Everything above describes libpeios. If you are writing a registry source, you link the sibling library librsi instead (or as well). It is built on the same peios-cabi substrate and follows the identical library conventions — raw fds, the int/ssize_t error model, the borrow discipline.

The packages #

librsi takes the same split as libpeios:

PackageContentsWhen you need it
dev.peios.librsiThe versioned shared object librsi.so.0.At runtime, wherever a source runs.
dev.peios.librsi-develThe public headers (rsi.h + rsi/*.h), the librsi.so linker symlink, and the rsi.pc pkg-config descriptor. Depends on a matching dev.peios.librsi and kernel-headers.At build time.
dev.peios.librsi-staticThe static archive librsi.a.Only for static linking.
dev.peios.librsi-debuginfoSplit DWARF debug info.Debugging or profiling through the library.
dev.peios.librsi-debugsourceThe referenced Rust sources.Stepping into the library's own source.
peipkg install dev.peios.librsi-devel

The <rsi/*.h> headers include <pkm/lcs.h>, so the same UAPI-header requirement described above applies. To build from source instead, cargo build --release in a clone of https://github.com/peios/librsi produces librsi.so and librsi.a, with the headers in include/.

Headers and linking #

Include the umbrella or the individual concept headers:

#include <rsi.h>            /* the whole librsi API */
/* or: */
#include <rsi/source.h>    /* registration */
#include <rsi/request.h>   /* decoding requests */
#include <rsi/response.h>  /* building responses */

and compile with pkg-config (the descriptor's module name is rsi):

cc mysource.c $(pkg-config --cflags --libs rsi) -o mysource

or link -lrsi directly, or against librsi.a for a static build — exactly the three forms shown for libpeios above. The two libraries are independent: a program can be a client (libpeios), a source (librsi), or both, linking each as needed.

Your first program

Peios / Developing for Peios / SDK basics

This is a complete program you can compile and run. It does not touch the kernel — it works entirely with the security-descriptor vocabulary from <peios/security.h>, which makes it a safe first outing: no privileges, no live tokens, just the conventions from Library conventions put to work.

The task: take a security descriptor written in SDDL text, turn it into wire bytes, read its owner, and print that owner's SID in string form. Along the way you exercise the two-call buffer protocol twice, parse with a zero-copy view, and handle errors the libpeios way.

The program #

#include <peios/security.h>

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void)
{
    /* An SDDL descriptor: owner and group are Administrators (BA), with a
       DACL granting them full access (FA). */
    const char *sddl = "O:BAG:BAD:(A;;FA;;;BA)";

    /* 1. Parse the SDDL text into self-relative SD wire bytes.
          First call probes for the size (cap == 0), then we retrieve. */
    ssize_t need = peios_sddl_parse_sd(NULL, 0, sddl);
    if (need < 0) {
        fprintf(stderr, "parse probe failed: %s\n", strerror(errno));
        return 1;
    }

    unsigned char *sd = malloc((size_t)need);
    if (!sd)
        return 1;

    ssize_t sd_len = peios_sddl_parse_sd(sd, (size_t)need, sddl);
    if (sd_len < 0) {
        fprintf(stderr, "parse failed: %s\n", strerror(errno));
        free(sd);
        return 1;
    }
    printf("security descriptor: %zd bytes\n", sd_len);

    /* 2. Parse the SD into a zero-copy view. The view borrows `sd` — it
          must stay alive and unmodified until we are done reading. */
    peios_sd_view view;
    if (peios_sd_parse(sd, (size_t)sd_len, &view) != 0) {
        fprintf(stderr, "sd parse failed: %s\n", strerror(errno));
        free(sd);
        return 1;
    }

    /* 3. Pull out the owner SID. On success `owner` points INTO `sd`. */
    const void *owner;
    size_t owner_len;
    if (peios_sd_view_owner(&view, &owner, &owner_len) != 0) {
        printf("no owner set\n");
        free(sd);
        return 0;
    }

    /* 4. Format the owner SID as its "S-1-…" string. The byte form of a
          SID is bounded, but its string form is variable, so we probe.
          A string length excludes the NUL, so allocate len + 1. */
    ssize_t slen = peios_sid_format(owner, owner_len, NULL, 0);
    if (slen < 0) {
        fprintf(stderr, "sid format probe failed: %s\n", strerror(errno));
        free(sd);
        return 1;
    }

    char *str = malloc((size_t)slen + 1);
    if (!str) {
        free(sd);
        return 1;
    }
    if (peios_sid_format(owner, owner_len, str, (size_t)slen + 1) < 0) {
        fprintf(stderr, "sid format failed: %s\n", strerror(errno));
        free(str);
        free(sd);
        return 1;
    }

    printf("owner: %s\n", str);

    /* 5. Only now free `sd` — every pointer the view gave us pointed into
          it, so it had to outlive the last read. */
    free(str);
    free(sd);
    return 0;
}

Building and running #

cc first.c $(pkg-config --cflags --libs peios) -o first
./first

Expected output:

security descriptor: 44 bytes
owner: S-1-5-32-544

S-1-5-32-544 is the well-known SID for the local Administrators group — which is exactly the BA you wrote in the SDDL string. (The byte count may differ across versions; the owner will not.)

What just happened #

Every convention from the previous page showed up here:

  • The two-call protocol, twice. peios_sddl_parse_sd and peios_sid_format were each called first with a NULL/0 buffer to learn the size, then again to fill a right-sized allocation. Neither could ever truncate: a too-small buffer would have returned -1 with ERANGE, not a partial result.
  • A string length excludes the NUL. That is why the SID string buffer was slen + 1.
  • A zero-copy view borrowed our buffer. owner pointed into sd, so sd had to stay alive and unmodified until after the final peios_sid_format. Freeing it earlier would have left owner dangling — the one mistake this pattern invites, and the reason step 5 frees last.
  • Errors came back through the return value and errno. Every call was checked; strerror(errno) explained any failure. No exceptions, no out-of-band error channel.

A shortcut worth knowing #

Not every buffer needs a probe. Some results have a known ceiling, and the library gives you a constant so you can use a fixed stack buffer and skip the first call entirely. A SID is the classic case — it is never larger than PEIOS_SID_MAX_BYTES:

unsigned char sid[PEIOS_SID_MAX_BYTES];
ssize_t n = peios_sid_well_known(sid, sizeof sid, PEIOS_WKS_ADMINISTRATORS);
/* n > 0: `sid` holds S-1-5-32-544 in wire form, no malloc, no probe. */

Use the probe when a length is genuinely unbounded (strings, whole descriptors, ACLs); use the fixed-size shortcut when the module documents a ceiling for the thing you are building.

Where to go next #

You now have the mechanics. From here, pick the subsystem you need:

  • Access control (KACS) — the security vocabulary you just used, plus tokens, access checks, file security, and process security.
  • The registry (LCS) — reading and writing Peios's configuration store.
  • Events (KMES) — emitting and consuming the audit/event stream.

Using the SDK from Rust

Peios / Developing for Peios / SDK basics

The SDK is a C ABI, so it is reachable from any language with a C FFI — but Rust gets first-class, maintained bindings rather than hand-rolled extern blocks. If you are writing Rust on Peios, use them. This page is the on-ramp: what the crates are, how to wire them into a build, and where the docs live. It is deliberately not an API reference — that job belongs to the crate's rustdoc, which is generated from the code and so never drifts from it.

The two crates #

peios-rs is two crates, layered:

CrateWhat it isUse it when
peios-sysRaw, unsafe FFI. Bindings are generated at build time by bindgen from the hand-written <peios.h> (the shipping API, which the ABI verifier proves is identical to the Rust source).You need the raw C surface — an escape hatch, or to build your own abstractions.
peiosThe safe, idiomatic wrapper: RAII handle types, Result-returning methods, typed wire-constant families, and owned buffers — so you never touch a raw fd, a sticky-error builder, or a getxattr-style size probe directly.Almost always. This is the crate you want.

Both bind libpeios strictly as a C library through its stable ABI — they never depend on libpeios's internal Rust crates. The C ABI is the only supported entry point, and binding through it means the crates exercise the exact surface every other consumer uses.

The safe crate's modules mirror the libpeios concept headers one-for-one — security, token, access, file, process, event, msgpack, registry — so everything you learn about the C surface maps straight across.

Where the docs live #

Two places, and the split is deliberate:

  • The API reference is the crate's rustdoc. Every public item in peios is documented (the crate is built with #![warn(missing_docs)], so this is enforced), with a crate-level overview, per-module docs, and the error model. Build and browse it locally with:

    cargo doc -p peios --open
    

    This is the reference precisely because it is generated from the code — it can never fall out of sync with the actual method signatures and types the way hand-written prose would.

  • The concepts live in these docs. The what and why — what a token is, how the two-call protocol works, how an access check reaches a verdict, the RSI source model — are identical whether you call from C or Rust, and they are documented once, here. The safe crate mirrors the concept headers, so each learn section maps to a module:

    Rust moduleConcepts in
    security, token, access, file, processAccess control
    registryThe registry
    event, msgpackEvents

    Read the concept page for the model, then the rustdoc for the exact Rust API.

Adding the dependency #

Add the peios crate to your Cargo.toml:

[dependencies]
peios = { git = "https://github.com/peios/peios-rs" }

(or a path/registry dependency, however you consume Peios crates). The features:

FeatureEffect
defaultDynamic linking against libpeios.so — the intended production model (one soname-versioned system copy; fixes ship once).
staticStatic linking against libpeios.a. See the caveat below.
uapiReuse the canonical Rust mirror of the pkm UAPI types (peios-uapi) instead of letting bindgen emit its own copy, so the kacs_*/pkm types are one identity across crates.

A first taste #

The safe crate turns the C conventions into ordinary Rust. Compare this with the C first program — no size probes, no manual close, errors as Result:

use peios::token::{Token, TokenAccess};

fn main() -> peios::Result<()> {
    // Open my own token; the handle closes on drop.
    let me = Token::open_self(false, TokenAccess::QUERY)?;

    let sid = me.user()?;          // owned Sid, not a caller buffer + length
    let il  = me.integrity()?;     // typed IntegrityLevel, not a raw u32
    println!("running as {sid} at integrity {il:?}");

    Ok(())
}

The idiomatic wins show up most in the fiddly places. Impersonation, for instance, is an RAII guard — identity reverts when the guard drops, so it is exception-safe by construction rather than needing a manual revert in every cleanup path:

use std::os::fd::BorrowedFd;

fn serve(conn: BorrowedFd<'_>) -> peios::Result<()> {
    let caller = Token::open_peer(conn)?;
    let _guard = caller.impersonate_scoped()?;   // now acting as the caller
    do_work_as_caller()?;                        // any early return still reverts
    Ok(())
    // `_guard` drops here (or on the `?` above) → identity reverts automatically
}

Success-side status values — a file's opened-vs-created disposition, a key's created-new-vs-opened — come back alongside the handle, not as errors; genuine failures are the Err(Error) side, where Error wraps the errno the C ABI set.

Linking: dynamic vs static #

This is the one Rust-specific wrinkle worth understanding, because the static path has a sharp edge.

  • Dynamic (default) links libpeios.so — the production model, and the path a normal std Rust program must use.
  • Static (--features static) links libpeios.a. That archive is a Rust staticlib: it bakes in its own copy of the Rust runtime (the panic = "abort" handler, the global-allocator shim, the alloc-error handler). Linking it into a consumer that also carries that runtime — i.e. any Rust binary that links std — collides on those symbols (rust_begin_unwind, __rust_alloc_error_handler, …). So the static path is for C consumers and no_std Rust binaries that supply no conflicting runtime. A std Rust consumer — including cargo test — must use the dynamic path.

Keep that rule in mind and static linking is a non-issue: reach for it only from no_std, and use the default dynamic link everywhere else.

Finding libpeios at build time #

peios-sys's build script resolves the library and headers in this order:

  1. Environment override — set all three:
    • PEIOS_LIB_DIR — the directory containing libpeios.{so,a}
    • PEIOS_INCLUDE — the directory containing <peios.h>
    • PKM_UAPI — the directory containing <pkm/*.h> (referenced by peios.h's signatures)
  2. pkg-config — otherwise, if libpeios installs its peios.pc on PKG_CONFIG_PATH. The build requires the minimum libpeios version declared in peios-sys/Cargo.toml under package.metadata.libpeios.minimum-version. If the installed SDK is older, the build stops before generating bindings and reports the required and detected versions, instead of failing later with a missing Rust binding. A missing SDK reports the required version and setup guidance too. This applies to both dynamic and static linking.

The explicit environment override bypasses pkg-config, including its version check. It remains useful for uninstalled development builds: you are responsible for selecting compatible libpeios headers and a matching library at or above the crate's declared minimum. The shipping headers do not expose a version macro, so the build script cannot verify a version for this path.

On a system where libpeios is installed from its packages, pkg-config just works and you set nothing. For a local checkout, point the three variables at your build tree:

PEIOS_LIB_DIR=../libpeios/target/release \
PEIOS_INCLUDE=../libpeios/include \
PKM_UAPI=../pkm/uapi \
cargo build

One bring-up gotcha for local dynamic builds: the crate bakes an rpath that resolves libpeios by its soname (libpeios.so.0), but a plain cargo build of libpeios emits only the unversioned libpeios.so. Create the soname link once so the run resolves:

ln -sf libpeios.so ../libpeios/target/release/libpeios.so.0

(This is a checkout convenience — on a real system the packaged libpeios.so.0 is already there.)

Privilege names #

peios::security::Privileges exposes the published kernel privileges, including TAKE_OWNERSHIP, SYSTEM_PROFILE and RELABEL. Their canonical names are SeTakeOwnershipPrivilege, SeSystemProfilePrivilege and SeRelabelPrivilege. Use parse_name for a canonical name and canonical_names to enumerate a mask in bit order. Names are case-sensitive; an unknown name returns None.

The values come from the kernel ABI constants. Naming a privilege does not grant it: the token must hold it and the operation's privilege rules still apply. The retired SeBindPrivilegedPortPrivilege is not accepted.

For checkout builds, use the versioned pkm/uapi source headers rather than an old out/build/headers installation. With the uapi feature, peios-sys pins the same peios-uapi revision as libpeios.

The bottom line #

  • Depend on the peios crate; drop to peios-sys only for the raw surface.
  • Concepts: these docs. API reference: cargo doc -p peios --open.
  • Use the default dynamic link unless you are no_std.

Everything the library conventions page teaches still holds underneath — the crate just wraps it in Rust idiom.

Access control overview

Peios / Developing for Peios / Access control

KACS — the Kernel Access Control Subsystem — is the heart of Peios's security model, and it is the part of the SDK you are most likely to reach for. This section is a tour: it explains how the pieces fit together and points you at the right guide (and the right reference page) for each task. If you have not yet read the library conventions, read those first — everything here assumes the error and buffer rules they describe.

The four nouns #

Almost everything in KACS is built from four things:

NounWhat it isSDK home
SIDThe unique binary name of a principal — a user, group, machine, or well-known system actor.security.h
Security descriptor (SD)What protects an object: its owner, its group, and the ACLs that grant or deny access. Built from ACEs, each naming a SID and an access mask.security.h
TokenThe runtime object that carries an identity — a user SID, groups, privileges, an integrity level, claims. Every access decision is made against a token. A token is a file descriptor.token.h
Access checkThe act of deciding whether a token may perform a desired access on an object, given the object's SD.access.h

The relationship is simple to state: an access check asks whether a token (the subject) is granted some access to an object protected by a security descriptor, and both the token and the SD are expressed in terms of SIDs.

The shape of a decision #

When you need to make an authorisation decision in your own code, the pattern is almost always the same three steps:

  1. Get the subject's token. Usually the caller's — often via peios_token_open_peer on a socket, so you learn who connected — or your own effective token (token_fd = -1).
  2. Get the object's security descriptor. You either hold it already, read it off a file with peios_file_get_sd, or build one with a peios_sd_builder.
  3. Run the check. peios_access_check tells you whether the desired rights are granted, and exactly which subset was granted.

The checking access guide walks that end to end.

Advisory versus enforced #

There is one distinction worth internalising early. The SDK's access check is advisory: it computes what the answer would be. It does not enforce anything — enforcement of a real operation (opening a file, adjusting a token) happens inside the kernel against the subject's own process security block.

So you use peios_access_check when your code is the resource manager: you own some object — a record in your database, a slot in your service — that isn't a kernel object, and you want to make the grant/deny decision using Peios identities and the same rules the kernel would apply. For actual kernel objects (files, tokens, registry keys), you don't pre-check and then act; you just act, and the kernel enforces, returning EACCES if denied.

Identity is not always your identity #

A recurring theme in KACS is acting as someone other than yourself:

  • Impersonation lets a service temporarily adopt a caller's identity so its access checks run as them — the way a server does work "on behalf of" a client without running as root and hand-rolling permission logic. See working with tokens.
  • Restricted tokens let you drop power — derive a strictly less-privileged token to hand to less-trusted code.
  • Integrity levels and confinement bound what a token can touch regardless of its SIDs.

These are what make KACS more than POSIX uid/gid, and the token module is where you reach for them.

Where to go in this section #

  • Working with tokens — who am I, who is calling me, and how to act as someone else.
  • Checking access — building a security descriptor and making a decision end to end.
  • Securing files — the native open and reading/writing file security descriptors.
  • Hardening a process — turning on process mitigations.

For the exhaustive per-function detail behind any of these, the reference section documents every symbol.

Working with tokens

Peios / Developing for Peios / Access control

A token is the runtime carrier of an identity, and it is a file descriptor. This guide covers the everyday token tasks; token.h is the exhaustive reference for every call and field.

Who am I? #

To inspect your own identity, open your effective token and query it:

int tok = peios_token_open_self(0, KACS_TOKEN_QUERY);
if (tok < 0) { /* errno */ }

unsigned char sid[PEIOS_SID_MAX_BYTES];
ssize_t n = peios_token_user(tok, sid, sizeof sid);   /* the user SID */

uint32_t il;
peios_token_integrity(tok, &il);                      /* integrity level RID */

struct peios_privilege_set privs;
peios_token_privileges(tok, &privs);                  /* held/enabled privileges */

close(tok);

peios_token_open_self gives you the effective token — if your thread is impersonating, that's the impersonated identity. Pass KACS_TOKEN_OPEN_REAL in the flags to get your process's real primary token regardless. The access argument is the handle rights you want; KACS_TOKEN_QUERY is enough to read.

Group SIDs and other list-valued classes come back as buffers you parse with the security.h views: read CLASS_GROUPS with peios_token_query, then peios_sid_array_parse it.

Who is calling me? #

The most useful token trick in a service is learning the identity of whoever connected to your socket. When a client connects over a Unix stream or seqpacket socket, KACS captures their token at connect() time, and you open it from the accepted connection:

int conn = accept(listener, NULL, NULL);
int caller = peios_token_open_peer(conn);   /* QUERY | IMPERSONATE rights */
if (caller < 0) { /* errno */ }

/* Now query the caller's identity, or impersonate them (below). */

This is local authentication with no passwords and no handshake — the kernel vouches for who is on the other end. The handle comes with fixed QUERY | IMPERSONATE | DUPLICATE rights — enough to inspect the caller, act as them, and (for a process launched on their behalf) turn the identity into a primary token, but never to adjust it.

It works in the other direction too: right after connect(), peios_token_open_peer(sock) on the client's socket returns the identity of whoever is listening (at Identification level — enough to check, not to act as), so a client can verify it reached the genuine service and not something else that bound the path.

Acting as the caller #

Once you hold a caller's (impersonation) token, you can impersonate them: adopt their identity on your current thread so every subsequent access check runs as them, not as your service. This is how you do work on a client's behalf without running privileged and re-implementing their permissions.

if (peios_token_impersonate(caller) != 0) { /* errno */ }

/* ... do the work here: file opens, access checks, etc. all run as the caller ... */

peios_token_revert();   /* back to your own identity */
close(caller);

Always pair peios_token_impersonate with peios_token_revert, ideally in the cleanup path, so a failure partway through can't leave your thread wearing someone else's identity. peios_token_revert is a safe no-op if you weren't impersonating.

The full flow for a request handler is: accept → peios_token_open_peer → peios_token_impersonate → serve the request → peios_token_revert → close. When the handler runs start to finish on one thread and has no other use for the token, peios_token_impersonate_peer(conn) collapses the open, impersonate and close into one call.

A client decides how far its identity travels: peios_socket_set_impersonation_level(sock, KACS_IMLEVEL_IDENTIFICATION) lets the server learn who it is without being able to act as it. And a client that writes on behalf of many identities over one connection — a service impersonating its callers through a connection pool — turns on peios_socket_set_pass_token(sock, true), after which every send carries whoever is impersonating at that moment and the server's peios_token_open_peer tracks it request by request.

Handing an identity to a manager #

Impersonation acts as someone on your thread. The other shape is handing an identity to another process so it can act — the pattern behind the Peinit jobs socket (PSPU §7): a submitter such as backupd holds a token for the user whose data it is backing up, and wants Peinit to run the backup job as that user, not as backupd. Nothing is impersonated; the token itself is attached to the request, and the kernel vouches that the sender could have acted as it.

int jobs = socket(AF_UNIX, SOCK_SEQPACKET | SOCK_CLOEXEC, 0);
connect(jobs, ...);                                   /* who may submit is who may connect */

const char *req = "{\"command\":\"submit\",\"image_path\":\"/usr/bin/backup\"}";
if (peios_socket_send_message(jobs, req, strlen(req), user_token, NULL, 0, 0) < 0) {
    /* EACCES: user_token lacks TOKEN_IMPERSONATE; EPERM: the attach would lower its level */
}

Three things make this safe, and none of them is the manager's to get wrong:

  • The kernel gates the attach like an impersonation. You need TOKEN_IMPERSONATE on the handle, and the token arrives at the sending socket's impersonation level — so a token the sender could only identify, never act as, cannot be smuggled through. Peinit refuses anything below Impersonation. For a job that must reach remote systems as the user, set the socket to Delegation first: peios_socket_set_impersonation_level(jobs, KACS_IMLEVEL_DELEGATION) — and the attach then only succeeds if the token itself is at Delegation, since the level only ratchets down.
  • The receiver owns what it is handed. peios_socket_recv_message gives the manager a token handle with QUERY | IMPERSONATE | DUPLICATE — enough to inspect the identity, check its level with peios_token_impersonation_level, and duplicate it into a primary token for the new process, but never to adjust it.
  • No token means the sender's own. A request that attaches nothing is run as the connecting process's primary token: the manager opens it through peios_socket_peer_pidfd and peios_token_open_process — the kernel's handle on the peer, never a PID the peer named — which is fork-like and needs no privilege on the submitter's part.

Descriptors travel the same way: pass a socket for the job to serve on, or a pipe to receive its output, as SCM_RIGHTS on the same message. The full protocol — what the manager does with them and how the job is managed afterwards — is PSPU §7; the calls are in the reference.

From Rust the same pattern is peios::socket::send_message / recv_message, with the received Token and OwnedFds owned by the ReceivedMessage, and peios::socket::peer_pidfd for the fallback:

use std::os::fd::AsFd;
use peios::socket;
use peios::token::{ImpersonationLevel, Token, TokenAccess};

socket::send_message(jobs.as_fd(), request, Some(user_token.as_fd()), &[], 0)?;

// The manager's side. Every descriptor that arrived is owned by `msg`.
let msg = socket::recv_message(conn.as_fd(), &mut buf, 8, 0)?;
if msg.truncated || msg.control_truncated {
    return Err(refused());
}
let identity = match msg.token {
    Some(token) if token.impersonation_level()? >= ImpersonationLevel::Impersonation => token,
    Some(_) => return Err(refused()),   // below Impersonation
    None => {
        let pidfd = socket::peer_pidfd(conn.as_fd())?;
        Token::open_process(pidfd.as_fd(), TokenAccess::QUERY | TokenAccess::DUPLICATE)?
    }
};

Dropping power #

To run less-trusted code with less authority than you hold, derive a restricted token and hand it over. peios_token_restrict can delete privileges, demote groups to deny-only, and add restricting SIDs:

struct peios_token_restrict spec = {
    .privs_to_delete = KACS_SE_DEBUG_PRIVILEGE | KACS_SE_IMPERSONATE_PRIVILEGE,
    .flags           = KACS_TOKEN_RESTRICT_WRITE_RESTRICTED,
};
int weak = peios_token_restrict(my_primary, &spec);

The result is a strictly less-powerful token. Combined with integrity levels and confinement (both set when minting a token), this is the basis of sandboxing on Peios.

Minting tokens #

Creating a token from scratch requires SeCreateTokenPrivilege and is the province of authentication authorities, not ordinary programs. When you do need it, the token-spec builder is the ergonomic path — typed setters for the user SID, groups, privileges, integrity, claims, and the rest, then peios_token_builder_create. Mind the index convention for owner/primary-group references, and don't add the logon SID yourself — the kernel injects it.

Next #

  • Checking access — use a token to make an authorisation decision.
  • token.h reference — every token call in full.
  • Impersonation — the operator-side model and its two gates.

Checking access

Peios / Developing for Peios / Access control

This guide walks a complete access decision: you have an object to protect, a caller to check, and you want KACS to tell you what they're allowed. The exhaustive detail lives in access.h and security.h; here we put them together.

When to use this #

Reach for peios_access_check when your program is the resource manager — you own something that isn't a kernel object (a document, an API route, a record) and you want to gate it with Peios identities and rules. For actual kernel objects, don't pre-check; just perform the operation and let the kernel enforce.

Remember the check is advisory: it computes the answer, you enforce it.

Step 1 — describe what protects the object #

An object is protected by a security descriptor. If you don't already have one, build it. Say the resource should be readable and writable by its owner and read-only for a "viewers" group:

/* An ACL: allow OWNER full, allow VIEWERS read. */
peios_acl_builder *acl = peios_acl_builder_new();
peios_acl_builder_allow(acl, owner_sid, owner_len, KACS_ACCESS_ALL, 0);
peios_acl_builder_allow(acl, viewers_sid, viewers_len, KACS_ACCESS_READ, 0);

size_t acl_len;
const void *acl_bytes = peios_acl_builder_bytes(acl, &acl_len);

/* Wrap it in a security descriptor with an owner. */
peios_sd_builder *sd = peios_sd_builder_new();
peios_sd_builder_owner(sd, owner_sid, owner_len);
peios_sd_builder_dacl(sd, acl_bytes, acl_len);

size_t sd_len;
const void *sd_bytes = peios_sd_builder_bytes(sd, &sd_len);

(You can also write the descriptor as SDDL text and parse it — often easier when the policy is fixed.)

Step 2 — identify the subject #

The subject is a token. Most often it's the caller you opened from a socket, or your own effective token. In the request you pass either a token fd or -1 for your own effective token.

Step 3 — run the check #

Fill in a request and call. desired is what you're testing; mapping folds any generic rights to the object class's specific bits (use the class's published mapping — here we'll treat the object like a file):

struct peios_access_request req = {
    .token_fd = caller_fd,               /* or -1 for my own token */
    .sd       = sd_bytes, .sd_len = sd_len,
    .desired  = KACS_ACCESS_READ | KACS_ACCESS_WRITE,
    .mapping  = peios_file_generic_mapping,
};

uint32_t granted = 0;
int rc = peios_access_check(&req, &granted, NULL);

Step 4 — interpret the result #

if (rc == 0) {
    /* Every desired right was granted. */
} else if (errno == EACCES) {
    /* Denied. `granted` still holds what WAS allowed — e.g. maybe READ
       succeeded but WRITE did not. Decide per-right from `granted`. */
    bool may_read  = granted & KACS_ACCESS_READ;
    bool may_write = granted & KACS_ACCESS_WRITE;
} else {
    /* A real error: bad token fd, malformed SD, etc. */
    perror("access_check");
}

The key idea: a denial is not an error to log and bail on — it's the expected "no". And because granted is filled even on denial, you can ask for a broad set of rights in one call and read back exactly which subset the subject has, rather than probing right by right. Clean up the builders (peios_acl_builder_free, peios_sd_builder_free) and the token fd when done.

Per-property checks #

If your object has properties or property-sets with their own object ACEs, evaluate the whole tree in one call with peios_access_check_list: supply an object-type tree and get one result per node, so you learn (for instance) that a caller may read most of an object but not one protected field — without a separate check per field.

Auditing a decision #

Pass a non-NULL peios_access_audit to learn what a SYSTEM_AUDIT ACE match would log, and whether a staged central access policy would decide differently — the signal you watch when rolling out a policy change.

If your daemon guards objects of its own, name the object in the request's audit context, so the kernel's audit record of the decision says what was checked:

struct peios_audit_field name = {
    .key = "name", .value_type = PEIOS_AUDIT_STR,
    .bytes = "jellyfin", .len = strlen("jellyfin"),
};
uint8_t ctx[128];
ssize_t n = peios_audit_context_encode("service", &name, 1, ctx, sizeof ctx);
req.audit_context = ctx;
req.audit_context_len = (size_t)n;   /* check n >= 0 first */

Do not write an event of your own recording the grant or the refusal. The kernel's record is the audit record, and a SACL on the object decides whether it is written (PGSS §6.7).

Next #

  • access.h reference — every field of the request and the audit outputs.
  • Access decisions — the operator-side account of how KACS reaches a verdict (and how to debug a surprising denial).

Securing files

Peios / Developing for Peios / Access control

Peios files carry real security descriptors, and the SDK opens them with a native KACS open rather than POSIX open(). This guide covers the two everyday tasks: opening a file with a specific access, and reading or changing a file's security. The full surface is in file.h.

To keep the fragments readable, most error checks are elided here — every call below returns -1 with errno on failure, and real code must check each one (see Library conventions).

The native open #

peios_file_open is shaped like NtCreateFile: you state the access you want, what to do about existence (the disposition), any create options, and — when creating — the security descriptor to stamp on the new file. It returns an ordinary Linux fd whose granted access is fixed for the fd's lifetime, which means you can safely hand it to another process by SCM_RIGHTS, dup, or across exec: the fd carries exactly the access it was opened with.

Important

At kernel 8e0e22de3a59cad506bbbf8873de456e16ad272d, OPEN_IF or OVERWRITE_IF with a creator SD fails with EINVAL for an existing path. Plain OPEN with a creator SD fails with EOPNOTSUPP, and OVERWRITE with one fails with EINVAL. The pinned source findings resolve these cases, not every open-interface discrepancy. No deployed build was tested.

Create a new file, readable and writable, with a creator SD. An existing path is an error:

struct peios_open_params p = {
    .desired_access = KACS_FILE_READ_DATA | KACS_FILE_WRITE_DATA,
    .disposition    = KACS_DISPOSITION_CREATE,    /* fail if the path exists */
    .sd             = creator_sd, .sd_len = creator_sd_len,
};

uint32_t status = 0;
int fd = peios_file_open(AT_FDCWD, "state.db", &p, &status);
if (fd < 0) { perror("open"); return -1; }

/* On success, status == KACS_STATUS_CREATED. */

To open an existing file, use KACS_DISPOSITION_OPEN with sd = NULL and sd_len = 0. If the create-only call returns EEXIST, decide whether accepting the existing object is appropriate before making that separate open call. The two calls are not an atomic create-or-open transaction; checking stat first does not remove the race either.

If parent inheritance is appropriate for a newly created file, OPEN_IF with sd = NULL and sd_len = 0 can report created versus opened in status_out without a separate stat. Only DIRECTORY and DELETE_ON_CLOSE create-option bits are accepted by the pinned kernel; put no-follow in flags. See the opening reference for the SDK fields.

Reading a file's security descriptor #

To see who can do what to a file, read its SD. secinfo selects which components you want — owner, group, DACL, SACL — so you fetch only what you need:

/* Probe, allocate, read (two-call). */
ssize_t need = peios_file_get_sd(AT_FDCWD, "state.db",
                                 KACS_SECINFO_OWNER | KACS_SECINFO_DACL,
                                 NULL, 0, 0);
void *sd = malloc(need);
peios_file_get_sd(AT_FDCWD, "state.db",
                  KACS_SECINFO_OWNER | KACS_SECINFO_DACL, sd, need, 0);

/* Parse it with a security.h view. */
peios_sd_view v;
peios_sd_parse(sd, need, &v);
peios_acl_view dacl;
if (peios_sd_view_dacl(&v, &dacl) == 0) {
    unsigned n = peios_acl_view_count(&dacl);
    /* iterate ACEs … */
}
free(sd);

If you already hold a file fd, use the fd-targeted peios_fd_get_sd instead of a path — no second path resolution, and for a normal file fd the check uses the access already baked in at open.

Changing a file's security descriptor #

Writing an SD is component-selective too: name the components you're changing in secinfo, and everything you don't name is preserved. To tighten a file's DACL without touching its owner or SACL:

/* Build an SD carrying only a DACL. */
peios_sd_builder *b = peios_sd_builder_new();
peios_sd_builder_dacl(b, new_acl, new_acl_len);
size_t sd_len; const void *sd_bytes = peios_sd_builder_bytes(b, &sd_len);

peios_file_set_sd(AT_FDCWD, "state.db", KACS_SECINFO_DACL, sd_bytes, sd_len, 0);
peios_sd_builder_free(b);

Because only KACS_SECINFO_DACL is selected, the owner, group, and SACL are left exactly as they were. The security.h builders are how you assemble the SD to apply.

Raw file-security interface #

The SDK helpers above wrap kacs_get_sd and kacs_set_sd. The following low-level details belong to programs implementing descriptor tools; shell users should use Managing file security. The generated KACS ABI is the source for syscall numbers and KACS_SECINFO_* constants; the *_SECURITY_INFORMATION names below are their specification spellings.

Raw access to security.peios.sd or system.ntfs_security is denied for reads, writes and removal. In particular, a raw read would expose the SACL without the component-specific ACCESS_SYSTEM_SECURITY gate. The syscall pair unifies access rules, validates descriptors and abstracts filesystem storage; see File Descriptor Storage.

kacs_get_sd #

The read syscall:

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

Returns the SD bytes for the requested components.

ParameterMeaning
dirfd, pathThe file to read. Standard dirfd-relative resolution.
security_infoA bitmask saying which components to return. See below.
buf, buf_lenOutput buffer.
flagsAT_EMPTY_PATH (use dirfd as fd-relative), AT_SYMLINK_NOFOLLOW (operate on the terminal symlink itself).

The kernel:

  1. Resolves the path.
  2. Checks the access rights corresponding to the requested components (covered below). Paths and O_PATH handles use a live AccessCheck; ordinary file descriptors use their cached granted mask.
  3. Reads the file's SD from its filesystem-native storage.
  4. Constructs a subset SD containing only the requested components. Other components have offset 0 in the header and the corresponding PRESENT bit is clear.
  5. Returns the subset SD in buf, with the total length as the return value.

Probe mode #

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

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

A non-probe call with buf_len < required fails with -ERANGE without truncating the result. Probe again, allocate and retry; the size can change between calls. Do not assume an out-parameter receives the required size: this signature has none. The SDK two-call protocol describes the wrapper convention.

Access rules #

Different components need different rights:

Requested component (via security_info flag)Required right
OWNER_SECURITY_INFORMATIONREAD_CONTROL
GROUP_SECURITY_INFORMATIONREAD_CONTROL
DACL_SECURITY_INFORMATIONREAD_CONTROL
SACL_SECURITY_INFORMATIONACCESS_SYSTEM_SECURITY
LABEL_SECURITY_INFORMATIONREAD_CONTROL (the integrity label is in the SACL but its read is gated by READ_CONTROL, not ACCESS_SYSTEM_SECURITY)

(The numeric flag values are catalogued in Other constants.)

READ_CONTROL is the standard "read SD" right. Its implicit grant to the owner is subject to OWNER RIGHTS and the other access layers. ACCESS_SYSTEM_SECURITY is gated by SeSecurityPrivilege; an administrator label alone is not sufficient.

A caller asking for components they do not have rights for gets -EACCES. The component check is all-or-nothing: if any requested component fails its access check, the whole call fails rather than returning the permitted subset.

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

SACL and LABEL are mutually exclusive #

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

kacs_set_sd #

The write syscall:

result = kacs_set_sd(dirfd, path, security_info, sd_buf, sd_len, flags)
ParameterMeaning
dirfd, pathTarget file.
security_infoWhich components to update.
sd_buf, sd_lenThe new SD bytes (self-relative format).
flagsAT_EMPTY_PATH, AT_SYMLINK_NOFOLLOW (operate on the terminal symlink itself).

The kernel:

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

Validation and component-right checks must succeed before the selected components are merged; unselected components are preserved. This is not a concurrent-update transaction. The Kernel TRM storage rules describe a last-writer-wins window: the xattr write and cache publication are not atomic with each other, although readers do not see a partially written descriptor. Coordinate writers rather than treating a read/edit/write sequence as compare-and-swap.

Access rules for writes #

ComponentRequired right
OwnerWRITE_OWNER (plus the owner SID validation)
GroupWRITE_OWNER
DACLWRITE_DAC
SACLACCESS_SYSTEM_SECURITY
LABEL (integrity label only)WRITE_OWNER (plus integrity constraint)

WRITE_DAC is the standard "modify DACL" right; the owner implicit grant is subject to OWNER RIGHTS suppression and the other access layers. WRITE_OWNER is needed to change the owner field (and the validation rules apply per Ownership). ACCESS_SYSTEM_SECURITY is the SACL gate.

The integrity label #

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

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

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

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

SD parsing and validation #

The provided SD blob must be:

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

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

Setting only some components #

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

The blob structure must still be valid — components not being applied are typically absent (offset 0, PRESENT bit clear) in the blob, but the blob's header still needs to be a valid SD header. After the merge, the resulting descriptor must retain a non-null owner; its group may be null.

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

Ownership transfer rules #

Setting the owner via kacs_set_sd triggers the rules described in Ownership:

  • The new owner must be the caller's own user_sid, or a SID in the caller's groups with SE_GROUP_OWNER set, or the caller must hold SeRestorePrivilege.
  • The operation must pass the WRITE_OWNER gate. SeTakeOwnershipPrivilege can supply that right through a live access check, subject to mandatory policy; possession does not bypass a cached fd mask or the new-owner SID restriction. See Ownership for availability and recovery limits.

A failed component-right check returns -EACCES; a failed owner-SID constraint without SeRestorePrivilege returns -EPERM.

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

File-security errors #

These are raw kernel error names; the SDK wrappers return -1 and set errno.

ErrorCause
-EACCESAccess check failed for the requested components.
-EINVALMalformed SD blob, invalid security_information combination, size limit exceeded, or other validation failure.
-EPERMOwner SID validation failed without SeRestorePrivilege, or integrity label too high without SeRelabelPrivilege, or attempted MANDATORY attribute removal without SeTcbPrivilege.
-ERANGE(kacs_get_sd only) Non-probe buffer too small; probe again and retry.
-ENOENTThe target path does not exist.
-EROFSThe selected path reaches the object through a read-only mount.

The Kernel TRM raw-call contract says AT_SYMLINK_NOFOLLOW on kacs_get_sd and kacs_set_sd selects the symlink object, unlike native open, which fails with ELOOP in that case. The flag descriptions above follow that kernel contract.

Important

The SDK query reference now records the pinned-source result: AT_SYMLINK_NOFOLLOW queries the terminal link's own SD, whereas native open rejects that link with ELOOP. libpeios forwards the query's flags. This resolves the former blanket no-follow query error; it does not establish that a link-SD write succeeds. The set-security description above retains its separate rights and storage requirements. No runtime test or historical release boundary was established.

For live-check versus cached-fd restore behavior, mandatory SACL attributes and full-SACL label constraints, see The Set-Security Interface. A full SACL write replaces the entire SACL. A label-only write preserves non-label ACEs; its input is either no SACL, to remove the explicit label, or a SACL containing exactly one non-inherit-only mandatory-label ACE.

Pre-flighting an open #

Sometimes you want to know whether a caller could open a file before you actually do. Read the file's SD, then run an access check against the caller's token with peios_file_generic_mapping — no open, no side effects, just the verdict.

Next #

  • file.h reference — every parameter, plus the fd-targeted calls and mount policy.
  • File access — the operator-side model of native file security.

Hardening a process

Peios / Developing for Peios / Access control

Peios lets a process opt into hardening — exploit mitigations enforced by the kernel on that process's security block. The SDK exposes this through a single call, peios_process_set_mitigations. This short guide covers using it well; the full flag set and semantics are in process.h and the operator docs.

Harden yourself at startup #

The common case is a program hardening itself early in main, before it processes any untrusted input:

#include <peios/process.h>

int main(void)
{
    /* Enforce W^X and speculation mitigation lock; abort on failure. */
    if (peios_process_set_mitigations(-1, KACS_MIT_WXP | KACS_MIT_SML) != 0) {
        perror("set_mitigations");
        return 1;   /* refuse to run unhardened */
    }
    /* ... the rest of the program runs with those protections on ... */
}

pidfd == -1 targets the calling process. mitigations is a mask of KACS_MIT_* bits (from <pkm/psb.h>); combine the ones you want and set them together.

Three things to know #

It's one-way. Mitigation bits can only be set, never cleared — once on, they stay on for the life of the process. That's the point: a mitigation you could turn off is one an attacker could turn off. Treat each call as a permanent commitment.

It's all-or-nothing, and fails closed. If any requested protection can't actually be activated, the call changes nothing and returns -1. You never end up believing a mitigation is on when it isn't. So request the set you require together and check the result once — success means the whole set is active. (Bits from earlier successful calls stay on regardless.)

Targeting another process is privileged. To harden a process other than your own, you need PROCESS_SET_INFORMATION on it and PIP dominance over it — you can't reach into a process you don't already dominate. For self-hardening (-1), neither is needed.

Choosing what to enable #

The bit catalogue and what each mitigation defends against is documented operator-side under process mitigations (and in the Peios Kernel TRM §3.3, the Process Security Block). A couple of notes for the SDK caller:

  • KACS_MIT_ALL is the mask of all valid bits — useful for validating input, not usually what you'd blanket-enable without thought.
  • KACS_MIT_CFI is a legacy alias that expands to KACS_MIT_CFIF | KACS_MIT_CFIB.

Enable the specific protections your program can tolerate, verify the call succeeded, and prefer to do it before you touch untrusted data.

Next #

  • process.h reference — the call in full.
  • Process mitigations — every mitigation and its threat model.

Registry overview

Peios / Developing for Peios / The registry

LCS — the Layered Configuration Subsystem — is Peios's registry: a kernel-mediated, hierarchical, secured configuration store. If you have used the Windows registry it will feel familiar — a tree of keys holding typed values — but LCS adds one defining idea: layers. This section teaches the client API; registry.h is the exhaustive reference.

Keys and values #

A key is a node in the tree. It has an immutable GUID identity, a KACS security descriptor that governs who can read or change it, and it holds values and child keys. You address a key by path and open it for specific KEY_* rights, getting back a key fd whose granted access is fixed for its lifetime.

A value is a named, typed piece of data on a key (REG_SZ, REG_DWORD, REG_BINARY, and the rest). An empty name is the key's default value.

Layers and precedence — the LCS idea #

Here is what makes LCS more than a key/value store. Every value write is tagged with a layer, and layers have a fixed precedence order. When you read a value, LCS resolves the effective entry — the one from the highest-precedence layer that has something to say — and that's what you get back.

This is what lets configuration compose cleanly:

  • A base layer ships default configuration.
  • A site or policy layer overlays organisation-wide settings.
  • A machine-local layer overrides per-host.

They all coexist on the same key. Reading gives you the winner; writing targets a specific layer (or the base layer by default). And because it's layers rather than destructive overwrites, removing a higher layer's entry lets the lower one re-emerge — you can override and then un-override without losing the original.

Tombstones are the tool for "hide, don't delete": a per-value tombstone masks lower layers for one value, and a blanket tombstone masks all lower values of a key on a layer at once. Keys have the same idea via hide.

Reads report where the answer came from #

Because a value can come from any layer, the read tells you which layer won and gives you a sequence number for the effective entry. That sequence number is the basis of safe updates: pass it back as a compare-and-swap guard on a write, and the write only lands if nothing changed underneath you.

Transactions #

Mutating operations — creating keys, setting and deleting values — can be grouped into a transaction and committed atomically, so a multi-step configuration change either fully applies or not at all. Transactions are abort-by-default: close the transaction fd without committing and nothing happens. See watching and transactions.

Watches #

You can ask a key to notify you when it changes — values, subkeys, or its security — optionally across its whole subtree. The elegant part: once armed, the key fd itself becomes pollable, so a registry watch drops straight into an epoll loop with no side channel.

The client, not the source #

This SDK is the registry client — it reads and writes the store. It does not implement a registry source (a storage backend); that's a separate library, librsi. As a client you speak only the calls in registry.h.

Where to go in this section #

  • Reading and writing — open a key, read effective values, write to layers, do safe updates.
  • Watching and transactions — react to changes and apply atomic multi-step edits.
  • registry.h reference — every call in full.

Reading and writing

Peios / Developing for Peios / The registry

This guide covers the bread-and-butter registry operations. The full call signatures and error sets are in registry.h; here we string them together. Recall the registry's descriptor-struct buffer convention: reads fill *_cap/*_len fields and a zero-capacity buffer probes.

The fragments below elide some error checks for brevity — every call returns -1 with errno on failure, and real code must check each one (see Library conventions).

Opening a key #

Open a key for the rights you need:

int key = peios_reg_open_key(-1, "/System/MyApp", KEY_READ, 0);
if (key < 0) {
    if (errno == ENOENT) { /* not there */ }
    else if (errno == EACCES) { /* not allowed */ }
    return -1;
}

parent_fd == -1 means path is absolute. To open relative to a key you already hold, pass that key fd as the parent. Use peios_reg_create_key instead when the key might not exist yet — it opens-or-creates and reports which happened.

Reading the effective value #

Reading resolves layer precedence for you and hands back the winning value, its type, and which layer it came from:

struct peios_reg_value v = {0};
unsigned char data[256];
v.data = data;  v.data_cap = sizeof data;
/* leave v.layer NULL if you don't care which layer won */

if (peios_reg_query_value(key, "Timeout", 7, -1, &v) == 0) {
    /* v.type is REG_*, v.data_len bytes valid in `data`, v.sequence is
       the effective entry's sequence number. */
} else if (errno == ENOENT) {
    /* no effective value (or a tombstone masks it) */
} else if (errno == ERANGE) {
    /* data buffer too small; v.data_len holds the required size — grow & retry */
}

The name_len is explicit (value names are length-counted; 0 reads the key's default value). Pass a transaction fd as the fourth argument to read within a transaction, or -1 for none.

To read every value at once, use peios_reg_query_values_batch — one call fills a buffer with all effective values in a packed record format. To walk them one at a time, loop peios_reg_enum_value from index 0 until ENOENT.

Writing a value #

Writes target a specific layer. Pass NULL/0 for the layer to write the base layer:

uint32_t timeout = 30;
int rc = peios_reg_set_value(key, "Timeout", 7, REG_DWORD,
                             &timeout, sizeof timeout,
                             NULL, 0,      /* base layer */
                             -1,           /* auto-commit (no transaction) */
                             0);           /* no CAS guard */

Writing to a higher-precedence layer overrides lower ones without destroying them; deleting that layer's entry later lets the lower value re-emerge.

Safe updates with compare-and-swap #

To read-modify-write without clobbering a concurrent change, feed the sequence you read back as the expected_seq guard on the write. The write only lands if nothing changed underneath you; otherwise it fails with EAGAIN and you retry:

for (;;) {
    struct peios_reg_value v = {0};
    unsigned char buf[64]; v.data = buf; v.data_cap = sizeof buf;
    if (peios_reg_query_value(key, "Counter", 7, -1, &v) != 0)
        break;                                     /* real error — don't spin */

    uint32_t n; memcpy(&n, buf, sizeof n); n++;

    int rc = peios_reg_set_value(key, "Counter", 7, REG_DWORD,
                                 &n, sizeof n, NULL, 0, -1,
                                 v.sequence);          /* CAS on the sequence */
    if (rc == 0) break;                                /* success */
    if (errno != EAGAIN) { /* real error */ break; }   /* else: retry */
}

Passing expected_seq == 0 disables the guard (an unconditional write).

Cleaning up #

Close key fds with close() when done. Values you wrote with auto-commit (txn_fd == -1) are already durable to the layer; to force the source to persist a hive's pending writes at a known point, call peios_reg_flush.

Next #

  • Watching and transactions — react to changes and batch atomic edits.
  • registry.h reference — every call, field, and error.

Watching and transactions

Peios / Developing for Peios / The registry

Beyond simple reads and writes, LCS gives you two higher-order tools: watches to react to change, and transactions to apply several edits atomically. Both are in registry.h; this guide shows the shape of using them. To keep that shape visible, the fragments elide most error checks — every call returns -1 with errno on failure, and real code must check each one (see Library conventions).

Watching for changes #

Ask a key to notify you when it changes, then poll its fd. Because the armed key fd is itself pollable, a watch integrates directly into whatever event loop you already run:

int key = peios_reg_open_key(-1, "/System/MyApp", KEY_READ | KEY_NOTIFY, 0);

/* Watch values and subkeys, across the whole subtree. */
peios_reg_notify(key, REG_NOTIFY_VALUE | REG_NOTIFY_SUBKEY, 1 /* subtree */);

struct pollfd pfd = { .fd = key, .events = POLLIN };
for (;;) {
    poll(&pfd, 1, -1);
    if (pfd.revents & POLLIN) {
        /* read() the key fd to drain the change records, then re-read
           whatever you care about. */
        char records[512];
        ssize_t n = read(key, records, sizeof records);
        /* ... process change records ... */
    }
}

filter is a mask of REG_NOTIFY_VALUE, REG_NOTIFY_SUBKEY, and REG_NOTIFY_SD; REG_NOTIFY_ALL covers all three. The subtree flag extends the watch to descendants. Arming needs KEY_NOTIFY on the key. Call peios_reg_notify(key, 0, 0) to disarm.

Each read() drains as many complete change records as fit — every record starts [total_len: u32][event_type: u16][name_len: u16][name], so you step through the buffer by total_len. The full layout, the REG_WATCH_* event types, and the extra path fields a subtree watch appends are in the reference. Two practical notes: a buffer too small for even one record fails EINVAL, so size it generously rather than exactly; and a REG_WATCH_OVERFLOW record means events were dropped — re-read the key's state instead of trusting the stream.

This is how a service picks up configuration changes live — no polling loop re-reading values on a timer, just a blocking poll that wakes when something actually changed.

Transactions #

When a configuration change spans several operations — create a key, set a few values, delete a stale one — you usually want it to be all-or-nothing. That's a transaction.

Begin one, pass its fd as the txn_fd argument to each operation you want enlisted, then commit:

int txn = peios_reg_begin_transaction();

int key = peios_reg_create_key(-1, "/System/MyApp/v2", KEY_WRITE, 0,
                               NULL /* base layer */, txn, NULL);
uint32_t one = 1;
peios_reg_set_value(key, "Enabled", 7, REG_DWORD, &one, sizeof one,
                    NULL, 0, txn, 0);
peios_reg_delete_value(key, "LegacyFlag", 10, NULL, 0, txn);

int rc = peios_reg_commit(txn);
if (rc == 0) {
    /* everything applied atomically; txn is terminal — close it */
} else if (errno == EBUSY || errno == EIO) {
    /* transient: the transaction is still active — retry the commit */
}
close(txn);
close(key);

Two things to keep in mind:

  • Abort is the default. If you close the transaction fd without committing — including on any early-return error path — nothing is applied. So you don't need explicit rollback logic; just don't commit.
  • Commit can be retried. EBUSY (write-lock contention) and EIO (source failure) leave the transaction active, so you can retry peios_reg_commit. Only 0 (committed — the fd is now terminal) and EINVAL (already committed or never bound) are final. Check state at any point with peios_reg_txn_status.

Backup and restore #

To snapshot a key and its whole subtree, or replace one from a snapshot, use peios_reg_backup and peios_reg_restore. They stream to and from an fd and are gated by SeBackupPrivilege / SeRestorePrivilege; restore applies in a single transaction.

Next #

  • registry.h reference — the notify filters, transaction states, and every error in full.
  • Reading and writing — the value operations you enlist in a transaction.

Events overview

Peios / Developing for Peios / Events

KMES is Peios's event system, and it is the sole event path on the system. Audit records, subsystem events, and your own application events all travel the same way: the kernel stamps each event with trusted metadata and writes it into a per-CPU, lock-free ring buffer, and consumers drain those rings. This section teaches both sides — producing and consuming — via event.h and msgpack.h.

What an event is #

An event has two parts:

  1. Kernel-stamped metadata you cannot forge — a CLOCK_REALTIME timestamp, a per-CPU monotonic sequence number, the CPU id, an origin class, and identity GUIDs (the effective token, the true token, and the process). This is the trustworthy skeleton: when you consume an event, you know who emitted it and when, because the kernel wrote that, not the emitter.
  2. A payload — a single MessagePack value that you define. This is your event's actual content.

The event type names the kind of event. It's a dotted name made of your package name, what the event is about, and what happened, such as org.example.backup.snapshot.created. Its fields follow one vocabulary shared by every emitter on the system. Naming and shaping your events shows how to choose both.

Payloads are MessagePack, and you own them #

The kernel does not build or interpret payloads — it only structurally validates them on emit (one well-formed MessagePack value, within size and nesting limits). So userspace owns encoding and decoding, and the SDK ships a MessagePack codec whose validator's acceptance is matched to the kernel's check. Build a payload with the writer, and a successful peios_mp_writer_bytes (or peios_mp_validate) means the emit call will accept it.

Per-CPU rings and lost events #

Events live in per-CPU ring buffers — one ring per logical CPU, lock-free so producers never block on consumers. Two consequences shape how you consume:

  • You drain per CPU. To see everything, run a reader per CPU (discover the count by attaching upward from CPU 0 until it fails).
  • Rings can lap. If you don't drain fast enough, new events overwrite old ones you haven't read. The per-CPU sequence numbers are contiguous, so a gap in the sequence means events were lost — and the reader tracks that count for you.

Privileges #

The two sides are gated separately:

  • Emitting requires SeAuditPrivilege.
  • Consuming (attaching to a ring) requires SeSecurityPrivilege.

Two ways to consume #

The SDK offers a high-level reader that hides the entire lock-free drain — barriers, lapping recovery, lost-event accounting, buffer-resize handling, and the wait — behind a simple next/wait loop. That's what almost everyone should use. There is also a low-level ring API for callers who need to drive the drain inside their own event loop. Both are covered in consuming events.

Where to go in this section #

  • Naming and shaping your events — design an event before you emit it: its type, its fields, its tier and its catalogue entry.
  • Emitting events — build a payload and emit, singly or in batches.
  • Consuming events — drain the rings with the high-level reader (and, briefly, the low-level ring).
  • event.h and msgpack.h — the exhaustive reference.

Naming and shaping your events

Peios / Developing for Peios / Events

Every event on Peios follows one naming scheme and one field vocabulary, so that an operator can find your events with the same queries and security descriptors they use for everyone else's. This guide walks through designing one event, from deciding that it should be an event to describing it for the catalogue. The rules themselves are in PGSS Events; this page applies them.

The running example is a backup tool, packaged as org.example.backup, that writes a snapshot archive and wants to record each one.

Decide that it is an event #

Ask two questions, in order:

  1. Does an operator care? If only the tool's developers would read it, write a log line instead.
  2. Does one instance matter on its own? If it does, it's an event. If only the trend matters — snapshots per hour, bytes per day — it's a metric.

An operator wants to know that last night's snapshot failed, so each snapshot is an event. See Events, logs and tiers.

Name the event type #

An event type is your package name, then one or more nouns from general to specific, then a past-tense verb:

org.example.backup.snapshot.created
  • The root is your package name, split at its dots. Short roots such as kacs and peinit are reserved to platform components.
  • Add a noun only where someone would write a policy on the set beneath it. org.example.backup.snapshot groups everything about snapshots, which an administrator might plausibly switch off or grant access to as a whole.
  • End with what happened, in the past tense: created, started, ended, deleted.
  • Use one type for success and failure. A failed snapshot is still org.example.backup.snapshot.created, carrying outcome.success = false. Don't add a snapshot.failed type.
  • Use lowercase and kebab-case within a segment: copied-up, never copied_up or copiedUp.

See Event types for the full grammar.

Choose the fields #

A field is named for the part it plays, then what it is, then which property:

FieldValue in this event
object.kindfile
object.file.pathThe snapshot archive written
object.file.sizeIts size in bytes
operation.durationHow long the snapshot took
outcome.successWhether it was written
outcome.reasonWhy not, when it wasn't
outcome.errnoThe error number, when there is one

Look a field up before you invent one. The standard fields cover almost everything an event carries: who acted (subject.*), what was acted on (object.*), where something came from or went to (source.* and destination.*), and how it turned out (outcome.*). Every defined field is described in /usr/share/evman/, and evman looks them up: evman object.file lists what is defined under object.file, and evman -k size searches.

A few things never go in your payload:

  • Anything the header already carries. The kernel stamps the time, the sequence number and your token and process identities on every event. Don't write a timestamp.
  • A principal's name. Carry its SID in subject.token.sid or similar. Consumers resolve names when they read the record.
  • A field you have no value for. Leave the key out rather than writing nil, "" or 0.

If your event genuinely needs a field the catalogue doesn't have, put it beneath your package name, under the role it describes:

object.org.example.backup.snapshot.id

This should be rare. If you find a field that every backup tool would want, that's a gap in the standard set worth reporting.

Encode the values #

Each field's type fixes its wire form. The ones you'll meet most:

Kind of valueWrite it as
A SIDThe binary SID, never an SDDL string
A GUID16 raw bytes, never a hyphenated string
A timeNanoseconds since the Unix epoch, as an unsigned integer
A durationNanoseconds, as an unsigned integer
A yes or noA MessagePack boolean, never the string "true"
One of a fixed setA kebab-case string: disk-full, not 2 or DISK_FULL

The full table is in Values.

Build the payload #

A dotted field path is a chain of nested maps: object.file.path is {object: {file: {path: …}}}. Never write a key containing a dot; it becomes a different field that no query can reach.

peios_mp_writer *w = peios_mp_writer_new();

peios_mp_write_map(w, 3);                       /* object, operation, outcome */

peios_mp_write_str(w, "object", 6);
peios_mp_write_map(w, 2);                       /* kind, file */
peios_mp_write_str(w, "kind", 4);
peios_mp_write_str(w, "file", 4);
peios_mp_write_str(w, "file", 4);
peios_mp_write_map(w, 2);                       /* path, size */
peios_mp_write_str(w, "path", 4);
peios_mp_write_str(w, path, path_len);
peios_mp_write_str(w, "size", 4);
peios_mp_write_uint(w, size_bytes);

peios_mp_write_str(w, "operation", 9);
peios_mp_write_map(w, 1);
peios_mp_write_str(w, "duration", 8);
peios_mp_write_uint(w, elapsed_ns);

peios_mp_write_str(w, "outcome", 7);
peios_mp_write_map(w, 1);
peios_mp_write_str(w, "success", 7);
peios_mp_write_bool(w, true);

When the snapshot fails, the same type carries the reason instead of the size:

peios_mp_write_str(w, "outcome", 7);
peios_mp_write_map(w, 3);
peios_mp_write_str(w, "success", 7);
peios_mp_write_bool(w, false);
peios_mp_write_str(w, "reason", 6);
peios_mp_write_str(w, "disk-full", 9);
peios_mp_write_str(w, "errno", 5);
peios_mp_write_int(w, -ENOSPC);

outcome.reason is for filtering, so it's a value from a fixed set. If you want to tell a person more, add outcome.detail as free text; nothing parses it. See Outcomes.

Emitting events covers sending the finished payload.

Choose a tier #

Every event type declares how much it matters to an operator:

TierUse it for
standardWhat an administrator reviews: lifecycle, configuration, failures. Most events.
verboseDetail for an active investigation. Off unless switched on.
debugFor your own developers. Off unless switched on.
essentialExtremely important and rare, or already governed by its own configuration. Never switched off.

A snapshot record is standard. Reserve essential for the two reasons in its row; a package submitted to the Peios package repository is checked for it.

Administrators switch event types on and off in the emission policy, under Machine\Generic\Events. Your types live under Machine\Generic\Events\org\example\backup, and an event type that is switched off is never written. See Emission policy.

Describe it in a fragment #

Ship a fragment describing every event type you write and every field you define, installed as /usr/share/evman/org.example.backup.evman:

--- field object.org.example.backup.snapshot.id
type: bin.guid

The snapshot an event concerns, by the GUID the backup tool assigned it.

--- event org.example.backup.snapshot.created
tier: standard

A snapshot was written, or an attempt to write one failed.

field: object.kind                            required
field: object.file.path                       required
  The snapshot archive.
field: object.file.size                       when outcome.success == true
field: object.org.example.backup.snapshot.id  required
field: operation.duration                     required
field: outcome.success                        required
field: outcome.reason                         when outcome.success == false
  values: disk-full | source-unreadable | cancelled
  closed: false
  Why the snapshot was not written.
field: outcome.errno                          optional

You define only your own field. The standard ones are referenced, never redefined: two fragments defining the same field is an error, because one value with two names can be denied under one and read under the other. Writing evman pages covers the format, checking a fragment with evman lint, and installing it; the normative rules are in The catalogue.

If your program guards access to something #

A daemon that decides who may use its own objects doesn't emit its own "access denied" event. It makes the decision through the kernel's access check and passes the object's identity along, so that the kernel's audit records the decision like any other. See Access decisions.

Where to go next #

  • Emitting events: send the payload, singly or in batches.
  • PGSS Events: the rules this page applies, in full.

Emitting events

Peios / Developing for Peios / Events

Emitting an event is two steps: build a MessagePack payload, then hand it and an event type to the kernel. This guide shows both; event.h and msgpack.h are the full references. Emitting requires SeAuditPrivilege.

Choose the event type and fields first: Naming and shaping your events covers that, and the examples below use its backup-tool event.

Ask whether the type is on #

An administrator can switch event types on and off in the registry, and an emitter must not write a type that is switched off (PGSS §6.9). Open a policy handle once, and ask it before building each payload. The tier is the one your fragment declares for the type:

peios_event_policy *policy = peios_event_policy_open();     /* once, at startup */

static const char type[] = "org.example.backup.snapshot.created";
if (peios_event_policy_enabled(policy, type, sizeof type - 1,
                               PEIOS_EVENT_TIER_STANDARD) != 1)
    return;                                                  /* off: build nothing */

The handle caches its answers and watches the registry, so asking costs little. A change the administrator commits applies to your next decision. Essential types are always on. See the emission policy for the details.

Build the payload #

Use the MessagePack writer to encode a single top-level value: a map whose nested maps spell the field paths. This payload carries outcome.success:

peios_mp_writer *w = peios_mp_writer_new();

peios_mp_write_map(w, 1);                              /* {outcome: {success: true}} */
peios_mp_write_str(w, "outcome", 7);
peios_mp_write_map(w, 1);
peios_mp_write_str(w, "success", 7);  peios_mp_write_bool(w, true);

const void *payload;
ssize_t plen = peios_mp_writer_bytes(w, &payload);     /* validates as it borrows */
if (plen < 0) { /* EINVAL: malformed/under-filled — check peios_mp_writer_error */ }

peios_mp_writer_bytes validates that what you built is exactly one well-formed value, so a non-negative return means the payload is emit-ready. (Remember a map of n needs 2*n values — one per key and value.)

Emit it #

static const char type[] = "org.example.backup.snapshot.created";

int rc = peios_event_emit(type, sizeof type - 1, payload, (uint32_t)plen);
peios_mp_writer_free(w);

if (rc != 0) {
    switch (errno) {
    case EPERM:  /* no SeAuditPrivilege */          break;
    case EINVAL: /* zero-length type or bad payload */ break;
    case ENOSPC: /* payload too large */            break;
    case EAGAIN: /* rate-limited — back off */      break;
    }
}

The event type is length-counted UTF-8 and must be non-zero length. It isn't NUL-terminated on the wire, which is why the example passes sizeof type - 1. On success the kernel stamps the trusted metadata (timestamp, sequence, identity GUIDs) and sets origin_class = userspace, which queries see as emitter.class; you don't provide any of that, and your payload must not repeat it.

Validating untrusted payloads first #

If a payload's shape comes from dynamic or untrusted input, validate it in userspace before emitting so you handle the failure on your terms rather than as an EINVAL from the kernel:

if (peios_mp_validate(payload, plen, KMES_CONFIG_MAX_NESTING_DEPTH_DEFAULT) != 0) {
    /* reject it yourself */
}

The validator's acceptance matches the kernel's emit-time check at that depth bound.

Emitting in batches #

A high-rate producer should batch. peios_event_emit_batch emits many events in one call, so a single timestamp capture, identity capture, and consumer wake cover the whole set:

#define EVENT_TYPE(s) s, sizeof(s) - 1

struct peios_event_entry entries[3] = {
    { EVENT_TYPE("org.example.backup.snapshot.created"), pa, pa_len },
    { EVENT_TYPE("org.example.backup.snapshot.created"), pb, pb_len },
    { EVENT_TYPE("org.example.backup.snapshot.deleted"), pc, pc_len },
};

uint32_t emitted = 0;
int rc = peios_event_emit_batch(entries, 3, &emitted);
if (rc != 0) {
    /* errno is from entries[emitted] — the first that failed.
       entries[0..emitted) were emitted; resume from `emitted`. */
}

count must be in [1, KMES_BATCH_MAX_ENTRIES]. On failure, errno is the reason the first failing entry failed and emitted tells you how many succeeded before it, so you know exactly where to resume. One caveat: rate-limiting is all-or-nothing for a batch — an EAGAIN emits none of it, so on EAGAIN back off and retry the whole batch.

Next #

  • Consuming events — the other side of the pipe.
  • msgpack.h reference — the full encoder, including containers, extensions, and raw splicing.

Consuming events

Peios / Developing for Peios / Events

Consuming events means draining the per-CPU ring buffers. The SDK's high-level reader hides all the hard parts, so most consumers are a short loop. This guide shows that loop and how to parse what you read; event.h has the full API. Consuming requires SeSecurityPrivilege.

The reader loop #

Open a reader for a CPU, then loop next/wait:

peios_event_reader *r = peios_event_reader_open(cpu);
if (!r) { /* errno */ }

for (;;) {
    struct peios_event ev;
    int rc = peios_event_reader_next(r, &ev);
    if (rc == 1) {
        handle_event(&ev);                       /* got one */
    } else if (rc == 0) {
        peios_event_reader_wait(r, -1);          /* none right now — sleep */
    } else {
        break;                                   /* error */
    }
}
peios_event_reader_close(r);

peios_event_reader_next returns 1 (event filled), 0 (nothing available — call wait), or -1 (error). peios_event_reader_wait blocks until events arrive or the timeout elapses (negative = forever). The reader handles the memory barriers, lapping recovery, buffer-resize handling, and futex wait internally — you just alternate the two calls.

Parsing an event #

Each struct peios_event gives you the trusted metadata by value and the payload as a MessagePack value. Parse it with a reader:

void handle_event(const struct peios_event *ev)
{
    /* Metadata is trustworthy — the kernel stamped it. */
    /* ev->timestamp, ev->sequence, ev->cpu_id, ev->origin_class,
       ev->process_guid, ev->effective_token_guid, ... */

    /* event_type and payload are NOT NUL-terminated — use the lengths. */
    static const char type[] = "org.example.backup.snapshot.created";

    if (ev->event_type_len == sizeof type - 1 &&
        memcmp(ev->event_type, type, sizeof type - 1) == 0) {

        struct peios_mp_reader mp;
        peios_mp_reader_init(&mp, ev->payload, ev->payload_len);

        ssize_t pairs = peios_mp_read_map(&mp);
        for (ssize_t i = 0; i < pairs; i++) {
            const char *key; ssize_t klen = peios_mp_read_str(&mp, &key);
            /* dispatch on key; read or peios_mp_skip(&mp) the value */
        }
    }
}

The lifetime rule #

ev->event_type and ev->payload point into the ring mapping. They are valid only until your next peios_event_reader_next call, and only while the slot hasn't been overwritten. So copy out anything you need to keep before continuing the loop — don't stash the raw pointers.

Draining every CPU #

Rings are per-CPU, so one reader sees only one CPU's events. To consume the whole machine, run a reader per CPU — typically one thread each. Discover the CPU count by attaching upward until it fails:

uint32_t ncpu = 0;
for (;;) {
    uint64_t cap;
    int fd = peios_event_attach(ncpu, &cap);   /* low-level probe */
    if (fd < 0) break;                          /* EINVAL past the last CPU */
    close(fd);
    ncpu++;
}
/* now spawn one peios_event_reader per cpu in [0, ncpu) */

Watching for loss #

If a consumer falls behind, the producer laps it and events are lost. Poll peios_event_reader_lost to see the cumulative count of lost events (derived from sequence gaps). A rising number means you aren't draining fast enough — process events more cheaply, hand off to a worker, or accept the loss deliberately.

The low-level ring #

If the reader's loop doesn't fit your event model — you want the ring integrated into an existing epoll/state-machine loop, driving the read position yourself — the low-level ring API exposes the mapping directly: peios_event_ring_map, the position accessors (write_pos/tail_pos/generation), peios_event_ring_event_at to parse a slot, and peios_event_ring_wait to sleep. It's more bookkeeping (you own the read position and the empty/lapping/generation checks) for more control. Reach for it only when you need to; the high-level reader is the right default.

Next #

  • event.h reference — the full reader and ring APIs, and every struct peios_event field.
  • Auditing — the operator-side view of the event and audit stream.

Registry sources overview

Peios / Developing for Peios / Registry sources

A registry source is a storage backend for the LCS registry. It is the other half of the registry story: where a client opens keys and reads values, a source is what actually holds those keys and values and answers the kernel when it needs them. If you are implementing a place for registry data to live — a file-backed store, a database adapter, an in-memory provider for tests — you are writing a source, and librsi is the library for it.

This is a different library from libpeios, for a different audience. libpeios is the registry client; librsi is the registry source. They share the SDK's conventions and substrate, but you link librsi (-lrsi, <rsi.h>) to write a source.

How it works #

A source talks to the kernel over the RSI protocol — the Registry Source Interface — on a single file descriptor:

flowchart LR
    client[Registry client<br/>libpeios] -->|syscalls| kernel[LCS in the kernel]
    kernel <-->|RSI framed protocol<br/>over the source fd| source[Your source<br/>librsi]

The flow is:

  1. Register. Your process declares which hives (registry subtrees) it backs and registers with the kernel, receiving a source fd. This needs SeTcbPrivilege.
  2. Serve. The kernel sends your source requests — "look up this child", "store this value", "begin this transaction" — as framed messages you read(2) from the source fd. You decode each one, do the work against your storage, and write(2) back a framed response.
  3. Repeat until the source fd reaches EOF (the source is closing).

When a client reads or writes a key in one of your hives, the kernel turns that into RSI requests to you. Your source is the source of truth; the kernel mediates, enforces security, and resolves layer precedence on top of what you report.

The serve loop #

Every source is, at heart, this loop:

for (;;) {
    ssize_t n = rsi_read_request(src_fd, buf, sizeof buf);   /* read a frame */
    if (n == 0) break;                                       /* EOF: closing */
    if (n < 0) { /* errno */ break; }

    struct rsi_request req;
    rsi_parse_request(buf, n, &req);                         /* split header/payload */

    switch (req.op_code) {                                   /* dispatch */
        case RSI_LOOKUP:     /* decode, resolve, rsi_respond_lookup */    break;
        case RSI_SET_VALUE:  /* decode, store,   rsi_respond_status */    break;
        /* … one case per op you support … */
    }
}

The three headers map onto the three steps:

  • <rsi/source.h> — registering and getting the source fd.
  • <rsi/request.h> — reading and decoding requests.
  • <rsi/response.h> — building and sending responses.

What the kernel handles, and what you handle #

The division of labour matters, because it keeps a source simple:

  • The kernel handles security (access checks against key SDs), layer precedence resolution, transaction coordination across sources, and the client-facing syscall surface. It also assigns the global sequence numbers.
  • Your source handles durable storage: it stores the name→GUID entries, the key metadata records, and the layered values, and it reports them back faithfully when asked. It honours transaction boundaries (buffer, then commit or abort) and compare-and-swap guards on writes.

You do not implement precedence, access control, or the client protocol — you store data and answer questions about it. That's why a source's serve loop is mostly a dispatch table over storage operations.

Requests come in families #

The request reference groups the operations, and it helps to hold the shape in mind:

  • Path/entry ops — the name→GUID hierarchy (LOOKUP, CREATE_ENTRY, HIDE_ENTRY, DELETE_ENTRY, ENUM_CHILDREN).
  • Key ops — key metadata records (CREATE_KEY, READ_KEY, DROP_KEY, WRITE_KEY).
  • Value ops — the typed values on a key (QUERY_VALUES, SET_VALUE, DELETE_VALUE_ENTRY, SET_BLANKET_TOMBSTONE).
  • Transaction ops — atomic grouping (BEGIN/COMMIT/ABORT_TRANSACTION).
  • Layer ops — DELETE_LAYER, FLUSH.

Most reply with a simple status; five carry data back on success.

Where to go in this section #

  • Registering a source — declare your hives and get the source fd.
  • Serving requests — the read/parse/dispatch/decode loop in full.
  • Building responses — status-only and payload-bearing replies.

Registering a source

Peios / Developing for Peios / Registry sources

Every source starts by registering. You tell the kernel which hives your process backs, and it hands you a source fd to serve on. The full detail is in rsi/source.h; this guide walks the decisions.

Declare your hives #

A hive is a registry subtree with its own root key. Fill in one struct rsi_hive per hive your source backs:

struct rsi_hive hive = {
    .name     = "MyStore",
    .name_len = 7,
    .flags    = 0,                    /* global hive */
    .root_guid = { /* 16-byte GUID of the hive root key */ },
    /* scope_guid left zero for a global hive */
};

The two decisions per hive:

  • Global or private? A global hive (flags = 0, scope_guid zero) is visible system-wide. A private hive (flags = RSI_HIVE_PRIVATE, scope_guid non-zero) is scoped — only tokens carrying that scope GUID in their LCS credentials can resolve it. Use a private hive for per-application or per-tenant state that shouldn't be system-visible.
  • The root GUID. Every hive is anchored at a root key identified by root_guid. This is the GUID paths in the hive resolve from, and it must match the root your storage actually holds.

Register #

Hand the kernel your hive array and the highest sequence number you've already persisted:

int src = rsi_register(&hive, 1, /*max_sequence=*/0);
if (src < 0) {
    perror("rsi_register");   /* EPERM if you lack SeTcbPrivilege */
    return -1;
}
/* `src` is the source fd — serve the RSI protocol on it. */

Two things to get right:

  • SeTcbPrivilege is required. Registration is a trusted operation; without the privilege you get EPERM. Sources run as trusted system components.
  • max_sequence is your durability contract. It is the highest sequence number this source has already persisted. The kernel resumes its global sequence counter past it, so it never hands out a number you've used. A brand-new source with no stored state passes 0; a source restarting with durable data must scan that data for the highest sequence it ever wrote and pass that. Getting this wrong risks sequence reuse, so make it the first thing your restart path computes.

You can register several hives in one call by passing an array and a count greater than one; the kernel enforces its configured MaxHivesPerSource limit (ENOSPC if you exceed it).

Optional transaction lifetime control #

The kernel also supports lifetime extension version 1. A source using it opens /dev/pkm_registry, calls REG_SRC_NEGOTIATE with version 1 and zero flags, retains the returned session identifier, and then calls REG_SRC_REGISTER. The current rsi_register convenience function uses the legacy registration sequence; negotiate through the explicit device/ioctl sequence when adopting this extension.

After reading each request, query REG_SRC_REQUEST_CONTEXT with its request id and the negotiated session. It returns the original request deadline and any transaction lifetime and cleanup-lease deadlines, all in CLOCK_MONOTONIC nanoseconds. Renew live transactions using REG_SRC_RENEW, independently of the normal request queue. Renewal cannot extend the transaction lifetime or revive an expired lease. Both read-write transactions and read-only snapshots use the same ownership rules. Serialize expiry cleanup with transaction operations and wait for an admitted commit's actual result before reclaiming its state.

The data-frame layout is unchanged. Sources without negotiation retain the legacy protocol. Current loregd requires the extension and must be deployed with a kernel that implements it. The PKM TRM, §5.8.3, specifies the control ioctls and their error and lifetime rules.

After registration #

The returned fd is your serve endpoint — you read(2) requests and write(2) responses on it directly (via the request and response helpers). Closing it deregisters the source and signals EOF to the kernel. Keep it open for the life of the source, and move on to the serve loop.

Next #

  • Serving requests — the loop that runs on the source fd.
  • rsi/source.h reference — every field and error.

Serving requests

Peios / Developing for Peios / Registry sources

With a source fd in hand, a source spends its life in one loop: read a request, decode it, do the work, reply. This guide builds that loop. The exhaustive per-op detail is in rsi/request.h and rsi/response.h.

The loop skeleton #

unsigned char buf[64 * 1024];   /* size generously — a short buffer is EMSGSIZE */

for (;;) {
    ssize_t n = rsi_read_request(src_fd, buf, sizeof buf);
    if (n == 0) break;                       /* EOF — the source is closing */
    if (n < 0) { perror("read_request"); break; }

    struct rsi_request req;
    if (rsi_parse_request(buf, n, &req) != 0) continue;   /* EBADMSG — skip */

    switch (req.op_code) {
        case RSI_LOOKUP:    handle_lookup(src_fd, &req);    break;
        case RSI_SET_VALUE: handle_set_value(src_fd, &req); break;
        /* … a case per op you support … */
        default:
            /* Unknown or unsupported op: reply with a non-OK status
             * (RSI_TXN_NOT_SUPPORTED for transaction ops you don't implement). */
            rsi_respond_status(src_fd, &req, RSI_INVALID);
            break;
    }
}

Three details in that skeleton matter:

  • rsi_read_request blocks until a request is queued, and returns 0 at EOF — that's your exit. Size buf generously; a frame larger than buf returns -1/EMSGSIZE.
  • rsi_parse_request gives you req.op_code to dispatch on, plus req.request_id (which every reply must echo — the helpers do this for you) and req.txn_id (nonzero when the request is inside a transaction).
  • Always reply. Every request expects exactly one response. If you can't handle an op, reply with a non-OK status rather than dropping it.

Decoding a request #

Inside a handler, decode the payload with the matching rsi_request_* parser. For a LOOKUP:

void handle_lookup(int fd, const struct rsi_request *req)
{
    struct rsi_lookup q;
    if (rsi_request_lookup(req, &q) != 0) {          /* EBADMSG */
        rsi_respond_status(fd, req, RSI_INVALID);
        return;
    }

    /* q.parent_guid (by value), q.child_name / q.child_name_len (borrowed).
       Resolve the child in your storage across its layers … */

    /* … then reply with the resolved path entries + metadata: */
    rsi_respond_lookup(fd, req, entries, entry_count, metadata, metadata_count);
}

And for a SET_VALUE, a status-only op:

void handle_set_value(int fd, const struct rsi_request *req)
{
    struct rsi_set_value v;
    if (rsi_request_set_value(req, &v) != 0) {
        rsi_respond_status(fd, req, RSI_INVALID);
        return;
    }

    /* Honour the CAS guard before storing. */
    if (v.expected_sequence != 0 && current_seq(&v) != v.expected_sequence) {
        rsi_respond_status(fd, req, RSI_CAS_FAILED);
        return;
    }
    store_value(&v);                                  /* your storage */
    rsi_respond_status(fd, req, RSI_OK);
}

The borrow rule #

Every decoded name and data field — q.child_name, v.value_name, v.data, and the rest — is a (ptr, len) pair that points into buf. Those pointers are valid only until the next rsi_read_request reuses the buffer. So:

  • Consume them within the handler (store the bytes, resolve the name) before the loop comes around again, or
  • Copy out anything you need to keep. Never stash a raw borrowed pointer across iterations.

GUIDs and scalars in the decoded struct are copied by value, so those are always safe to keep — it's only the borrowed (ptr, len) fields that have the lifetime.

Transactions #

When the kernel sends BEGIN_TRANSACTION, buffer subsequent writes tagged with that transaction_id instead of applying them; req.txn_id on each later request tells you which transaction it belongs to (0 = none). On COMMIT_TRANSACTION apply the buffered set atomically; on ABORT_TRANSACTION discard it. All three are status-only replies. The kernel coordinates transaction boundaries — your job is to buffer, then commit or discard on command.

Next #

  • Building responses — choosing and filling the right reply.
  • rsi/request.h reference — every op's decoder and struct.

Building responses

Peios / Developing for Peios / Registry sources

Every request gets exactly one response. Choosing the right one is simple once you know the rule; filling it in is a matter of handing librsi flat arrays and letting it encode the frame. The full contract is in rsi/response.h; this guide is the working version.

The rule #

On failure, always rsi_respond_status. On success, rsi_respond_status too — unless the op is one of the five that carry a payload.

Most operations (SET_VALUE, CREATE_KEY, the transaction ops, FLUSH, …) are status-only in both cases. And any op reports a non-OK outcome with rsi_respond_status, whatever it is:

/* Success for a status-only op: */
rsi_respond_status(fd, req, RSI_OK);

/* Any op reporting a problem: */
rsi_respond_status(fd, req, RSI_NOT_FOUND);   /* e.g. a missing key */
rsi_respond_status(fd, req, RSI_CAS_FAILED);  /* a failed CAS */

You never build the frame or echo the request id yourself — the helper reads what it needs from req and writes the framed reply to fd.

The five payload-bearing ops #

Exactly five ops return data on success, each with its own helper:

OpHelperYou supply
LOOKUPrsi_respond_lookuppath entries + the referenced keys' metadata
ENUM_CHILDRENrsi_respond_enum_childrenchildren (name + path entries) + metadata
READ_KEYrsi_respond_read_keyone key's non-layered metadata
QUERY_VALUESrsi_respond_query_valuesvalue entries + blanket tombstones
DELETE_LAYERrsi_respond_delete_layerthe orphaned keys' GUIDs

You pass the result as flat arrays; librsi validates and heap-encodes the wire frame. A QUERY_VALUES reply, for example:

struct rsi_value_entry values[] = {
    { .value_name = "Timeout", .value_name_len = 7,
      .layer_name = "base",    .layer_name_len = 4,
      .value_type = REG_DWORD, .data = &timeout, .data_len = 4,
      .sequence = 42 },
    /* … one per effective value you hold … */
};

rsi_respond_query_values(fd, req, values, 1, /*blankets=*/NULL, 0);

You report what you store, per layer; the kernel resolves precedence across the layers you return. (Note the (NULL, 0) for the empty blanket array — a NULL pointer is allowed only when its count is zero.)

The validation contract #

The helpers check their inputs before encoding and return -1/EINVAL if you break the contract — better a caught programming error than a malformed frame on the wire. The rules that apply across all of them:

  • (ptr, len) pairs: a pointer may be NULL only when its length/count is zero.
  • Booleans (volatile_key, symlink, target types) are strictly 0 or 1.
  • Hidden path targets (RSI_PATH_TARGET_HIDDEN) carry an all-zero target_guid.
  • LOOKUP / ENUM_CHILDREN metadata must exactly cover the GUID targets in your path entries — every referenced key present, none missing, no duplicates, nothing unreferenced.
  • DELETE_LAYER orphan GUIDs are nonzero and unique.

Beyond EINVAL, a helper can also fail with ENOMEM or EOVERFLOW (building the frame), EIO (a short write), or the raw write(2) errno — treat those as you would any I/O failure on the source fd.

Putting it together #

A source's handler for a payload-bearing op is: decode the request, gather the result from storage into the flat arrays the helper wants, call the helper. For a status-only op: decode, do the work, rsi_respond_status. On any error along the way — a decode failure, a missing key, an I/O problem — rsi_respond_status with the appropriate non-OK RSI_* code. That uniformity is what keeps a source's dispatch table readable no matter how many ops it supports.

Next #

  • rsi/response.h reference — every helper, struct, and error in full.
  • Serving requests — the loop these replies live in.

1.1 Library conventions

Peios / Developing for Peios / SDK Reference / Library Conventions

libpeios has a small number of conventions that hold across every function in every module. They are deliberately uniform: once you know how one function reports an error or returns a variable-length buffer, you know how all of them do. This page is the one to read slowly. Everything else in this documentation assumes it.

The conventions come in four groups: how results are returned, the two-call buffer protocol, memory ownership (builders and views), and the small stuff (file descriptors and constants).

1.2 How results are returned

Peios / Developing for Peios / SDK Reference / Library Conventions

Every entry point reports success or failure through its return type. There are three return shapes, and the shape tells you how to read the result.

1.2.0.1 int — a file descriptor, or zero #

A function returning int returns either:

  • a file descriptor (a non-negative int), when its job is to open something — a token, a registry key, an event stream; or
  • 0 on success, when it performs an action with no handle to hand back; and
  • -1 on failure, with the reason in errno.
int fd = peios_token_open_self(/* … */);
if (fd < 0) {
    /* errno is set — perror(), strerror(errno), etc. */
}

1.2.0.2 ssize_t — a byte length #

A function returning ssize_t produces a variable-length result — a SID, a serialised security descriptor, a formatted string, a registry value. It returns:

  • the length in bytes of the result on success (>= 0); or
  • -1 on failure, with the reason in errno.

These are the functions that use the two-call buffer protocol below. The returned length is always the full length of the result, which is what makes the protocol work.

For functions that format a string, the returned length excludes the terminating NUL — exactly like snprintf. So a return of 41 means "41 characters plus a NUL"; size your buffer as len + 1.

1.2.0.3 Structured results — out-parameters #

When a call produces more than one value, or a value that isn't naturally a length or an fd, it writes through out-parameters and returns int (0 / -1). The access check is the archetype: it returns 0 when access is granted and -1 with errno == EACCES when it is denied, and it writes the granted access mask through an out-parameter either way.

uint32_t granted = 0;
int rc = peios_access_check(/* … */, &granted);
/* rc == 0: granted; rc == -1 && errno == EACCES: denied.
   `granted` is populated in both cases. */

A denial is a normal, expected outcome, not a bug — which is why it is reported the same disciplined way as any other errno, rather than through a separate channel.

1.2.0.4 errno #

Failure is always reported through the standard C errno. The library sets errno on every -1 return and uses ordinary, portable errno values — there are no libpeios-specific or PKM-specific error numbers to learn. The ones you will see most:

errnoMeaning in libpeios
EINVALMalformed input — a bad SID, an unparseable SDDL string, an argument out of range.
ERANGEYour output buffer was non-zero but too small. Nothing was written. (See the protocol below.)
EACCESAn access check denied the request.
ENOMEMAn allocation failed (for the heap-backed builders).
EBADF, ESRCH, EFAULTThe usual Linux meanings — a bad fd or pidfd, a vanished process, a bad pointer.

Because the values are standard, strerror, perror, and your language's normal errno handling all work unchanged. Check the return value first, then read errno — like any POSIX call, errno is only meaningful after a call that signalled failure.

Nothing ever unwinds across the boundary. The library is compiled to abort rather than propagate a panic through the C ABI, so a call either returns a value you can inspect or the process dies — it never leaves you with a corrupt half-state to reason about.

1.3 The two-call buffer protocol

Peios / Developing for Peios / SDK Reference / Library Conventions

Every function that returns variable-length bytes — anything with an ssize_t return and an (out, cap) pair — follows the same getxattr-style protocol. It is the single most important convention in the library, so it is worth internalising.

The rule:

  • Call with cap == 0 (or a NULL buffer) to probe: the function writes nothing and returns the number of bytes the result needs.
  • Call with a buffer of at least that size to retrieve: the function fills the buffer and returns the number of bytes it wrote.
  • Call with a non-zero but too-small buffer and it fails with ERANGE and writes nothing — never a truncated or partial result.

That last point is the safety property that makes the protocol trustworthy: a too-small buffer is a clean, detectable error, not a silent truncation. You never have to wonder whether you got the whole thing.

The canonical two-call sequence:

/* 1. Probe for the size. */
ssize_t need = peios_sid_format(sid, sid_len, NULL, 0);
if (need < 0) { /* errno set */ }

/* 2. Allocate. For a string, add 1 for the NUL. */
char *buf = malloc(need + 1);

/* 3. Retrieve. */
ssize_t n = peios_sid_format(sid, sid_len, buf, need + 1);
if (n < 0) { /* errno set */ }
/* buf now holds the formatted SID; n is its length (excluding the NUL). */

When you already know a comfortable upper bound, you can skip the probe and call once with a big-enough buffer. Some results have a fixed maximum the library gives you a constant for — for example a SID is never larger than PEIOS_SID_MAX_BYTES, so a stack buffer of that size always fits and never needs a probe. Those shortcuts are called out where they apply; the two-call protocol is always available as the general fallback.

1.4 Memory ownership

Peios / Developing for Peios / SDK Reference / Library Conventions

libpeios never hands you an allocation to free(). Instead it uses two ownership patterns — builders for constructing byte buffers and views for reading them — and both keep the memory question simple: you own your buffers, the library borrows or copies, and the two never get confused.

1.4.0.1 Builders — constructing buffers #

Anything you assemble (an ACL, a security descriptor, a token specification) is built with a builder: an opaque, heap-backed object you create, feed, take the bytes from, and free.

Builders have three properties worth knowing up front:

  1. They are sticky-error. The incremental add/set calls return void — they never fail inline. If one hits a problem (a bad input, an allocation failure), the builder latches the error and every later call is a no-op. You do not have to check each step. Instead you check once, at the end: either call the builder's _error() accessor (it returns the latched errno, or 0 if all is well), or notice that taking the bytes fails. This lets you write a long, clean sequence of add calls without a conditional after every line.

  2. You free every builder you create. Each _new() is paired with a _free(). Builders also have a _reset() that drops the accumulated content and clears the sticky error, so you can reuse one builder across several objects instead of churning allocations.

  3. Taking the bytes: borrow (and sometimes copy). Every builder has a _bytes() that hands back a pointer into the builder — zero-copy, no allocation. That pointer is valid only until the next mutating call, _reset(), or _free() on that builder. Use it when you are going to consume the bytes immediately (for instance, pass them straight into a kernel call). The call comes in two shapes, and not every builder offers a copying counterpart:

    • The security builders (peios_acl_builder_bytes, peios_sd_builder_bytes) return the pointer — NULL if the sticky error is set — and write the length through an optional len_out pointer. Each is paired with a _finish() that copies the buffer into a caller-supplied buffer using the two-call protocol above, for when the bytes must outlive the builder.
    • peios_token_builder_bytes and peios_mp_writer_bytes are shaped the other way round: they return the length as an ssize_t (-1 with errno on a latched error) and write the borrowed pointer through an out-parameter (which may be NULL to get just the length). Neither has a _finish() — copy the borrowed bytes yourself if they need to outlive the builder.

A typical builder lifecycle:

peios_acl_builder *b = peios_acl_builder_new();   /* NULL on OOM */
peios_acl_builder_allow(b, sid, sid_len, mask, 0); /* void — no check */
peios_acl_builder_deny(b, other, other_len, mask, 0);

size_t len;
const void *acl = peios_acl_builder_bytes(b, &len); /* NULL if errored */
if (!acl) { int err = peios_acl_builder_error(b); /* handle */ }
/* … use `acl` before the next mutation … */

peios_acl_builder_free(b);

1.4.0.2 Views — reading buffers #

Anything you parse (a security descriptor, an ACL, a SID array from a token) is read through a view: a small, caller-allocated struct that you point at a buffer you already hold.

Views have their own two rules:

  1. You allocate the view; it is stack-friendly. A view type such as peios_sd_view is an opaque fixed-size struct — you declare one as a local variable and pass its address to the parse call. No heap, no free. The struct's fields are opaque: never read them directly; use the accessor functions.

  2. A view borrows the buffer it parses — zero-copy. The parse call does not copy the data; the view points into your buffer, and every accessor that yields a SID, a nested ACL, or a blob hands back a pointer into that same buffer. So the buffer must stay alive and unmodified for as long as the view — and anything you derived from it — is in use. Free or mutate the underlying buffer and every pointer the view gave you dangles.

peios_sd_view sd;                        /* on the stack */
if (peios_sd_parse(buf, buf_len, &sd) != 0) { /* EINVAL */ }

const void *owner; size_t owner_len;
if (peios_sd_view_owner(&sd, &owner, &owner_len) == 0) {
    /* `owner` points INTO `buf` — valid only while `buf` lives. */
}

Views compose: parsing a security descriptor gives you a peios_sd_view, from which you obtain a peios_acl_view for its DACL, from which you obtain each peios_ace_view. Every one of them borrows the same original buffer, so keeping that one buffer alive keeps the whole tree valid.

The symmetry is the thing to remember: builders own heap and must be freed; views own nothing and borrow your buffer. Constructing is builders, reading is views, and neither ever asks you to free something the library allocated.

1.5 File descriptors

Peios / Developing for Peios / SDK Reference / Library Conventions

Handles that libpeios opens — tokens, registry keys, event streams — are raw int file descriptors, the same kind open() gives you. You close them with close(), poll them, and pass them across exec (or not) with the usual fd machinery.

They are created O_CLOEXEC by default: a handle does not leak across an exec unless you deliberately clear the flag with fcntl. This is the safe default for security-sensitive handles — a token or key fd will not silently end up in a child process you launch.

1.6 Constants

Peios / Developing for Peios / SDK Reference / Library Conventions

libpeios does not invent its own names for the kernel's wire constants. The access-right bits, ACE types, control flags, and mapping structs all come straight from the <pkm/*.h> UAPI headers, and you use those published names directly: KACS_ACCESS_*, KACS_ACE_TYPE_*, KACS_SD_*, struct kacs_generic_mapping, and so on. There is no parallel PEIOS_* aliasing to translate in your head — the name in the PSD, the name in the kernel header, and the name you write in your code are the same name.

The handful of constants that are libpeios's own — buffer-size ceilings like PEIOS_SID_MAX_BYTES, and enums for convenience selectors like enum peios_wks (well-known SIDs) — are prefixed PEIOS_ and documented with the module that defines them.

1.7 The conventions at a glance

Peios / Developing for Peios / SDK Reference / Library Conventions

ConventionThe rule
int returnfd or 0 on success; -1 + errno on failure.
ssize_t returnbyte length on success; -1 + errno on failure. Strings exclude the NUL.
Two-call protocolcap == 0 / NULL probes for the size; too-small non-zero buffer → ERANGE, nothing written.
errnostandard values only; check the return first, then errno.
Access denial-1 + EACCES, with the granted mask still written to the out-param.
Buildersheap-backed, sticky-error, void adders; check _error() at the end; _free() every one; _bytes() borrows, and the security builders add a _finish() that copies.
Viewscaller-allocated (stack), opaque, borrow the parsed buffer; keep that buffer alive and unmodified.
File descriptorsraw int, O_CLOEXEC by default, closed with close().
Constantsuse the <pkm/*.h> KACS_* names directly; only libpeios's own additions are PEIOS_*.

With these in hand, the module documentation reads as just "what does this function do?" — the how of memory and errors is answered here, once, for all of them. Next: your first program, which puts the protocol and the error model to work in something you can compile.

2.1 security.h — Security descriptors

Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors

<peios/security.h> is the shared vocabulary of the whole access-control surface. SIDs, security descriptors, ACLs, and ACEs are the currency every KACS interface trades in — tokens carry them, files are protected by them, access checks evaluate them, and the registry secures keys with them. They cross the kernel boundary as variable-length, self-relative byte buffers in the MS-DTYP wire formats, and this module is the one place libpeios lifts that raw wire form into something safe to handle from C.

Everything here assumes the library conventions: ssize_t returns are byte lengths using the two-call protocol, builders are heap-backed and sticky-error, and views borrow the buffer they parse. This page does not repeat those rules per function — read that page first.

The module has four parts:

  • SIDs — build, parse, format, and compare security identifiers.
  • ACLs and security descriptors — assemble them with builders.
  • Parsing — read them back with zero-copy views.
  • SDDL and inheritance — the text form and the userspace-only inheritance helpers.

The wire constants (KACS_SID_*, KACS_SD_*, KACS_ACE_*, and struct kacs_generic_mapping) come straight from <pkm/sid.h> and <pkm/sd.h>. libpeios does not re-alias them — you use the published ABI names directly.

2.1.1 See also #

  • Library conventions — the error, buffer, builder, and view rules this page builds on.
  • SIDs and Security descriptors — the operator-side concepts behind this vocabulary.
  • <peios/token.h>, <peios/file.h>, <peios/access.h> — the KACS interfaces that consume this vocabulary, including the generic-mapping tables peios_access_map_generic expects.

2.2 SIDs

Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors

A SID (Security Identifier) is the unique binary name of a principal. For the full account of what a SID is — its string and binary forms, the mixed endianness, the equality rule — see the operator-side page on SIDs. This section is the API for handling them.

A SID is small and bounded. The largest possible encoding is PEIOS_SID_MAX_BYTES (68) bytes, so a buffer of that size holds any valid SID and the SID builders below never need a two-call probe — you can always pass a PEIOS_SID_MAX_BYTES stack buffer and skip straight to the retrieve call.

#define PEIOS_SID_MAX_BYTES 68u

2.2.0.1 Constructing SIDs #

Each of these encodes a SID into your buffer and returns its length (or -1 with errno). Because a SID fits in PEIOS_SID_MAX_BYTES, the probe is optional — but these are still ssize_t/two-call functions, so passing cap == 0 to probe works too.

FunctionBuilds
peios_sid_build(out, cap, id_authority, sub_auths, count)An arbitrary SID from its parts: a 48-bit identifier authority (numeric, encoded big-endian) and count sub-authorities (encoded little-endian). count is 0..KACS_SID_MAX_SUB_AUTHORITIES.
peios_sid_parse_string(out, cap, sddl)A binary SID from its SDDL string form ("S-1-5-21-…").
peios_sid_integrity(out, cap, level_rid)An integrity-label SID S-1-16-<rid> (see peios_integrity_level).
peios_sid_logon(out, cap, session_id)A logon SID S-1-5-5-<hi>-<lo> from a 64-bit session id.
peios_sid_well_known(out, cap, which)A well-known SID selected by enum peios_wks.
ssize_t peios_sid_build(void *out, size_t cap, uint64_t id_authority,
                        const uint32_t *sub_auths, unsigned count);
ssize_t peios_sid_parse_string(void *out, size_t cap, const char *sddl);
ssize_t peios_sid_integrity(void *out, size_t cap, uint32_t level_rid);
ssize_t peios_sid_logon(void *out, size_t cap, uint64_t session_id);
ssize_t peios_sid_well_known(void *out, size_t cap, enum peios_wks which);

peios_sid_build fails with EINVAL if count exceeds the maximum, and (like all of these) with ERANGE if a non-zero cap is too small.

2.2.0.2 Formatting and inspecting SIDs #

FunctionReturns
peios_sid_format(sid, len, out, cap)The SDDL string form ("S-1-…"), as a string length excluding the NUL — allocate len + 1.
peios_sid_valid(sid, len)true if sid is a structurally valid SID of exactly len bytes.
peios_sid_length(sid)The encoded length of sid, read from its sub-authority count. You must have already validated sid, or bounded it to PEIOS_SID_MAX_BYTES — this trusts the buffer.
peios_sid_equal(a, alen, b, blen)true for exact binary equality — the only equality KACS defines for SIDs.
peios_sid_rid(sid, len)The RID (last sub-authority), or 0 if the SID has none.
ssize_t  peios_sid_format(const void *sid, size_t len, char *out, size_t cap);
bool     peios_sid_valid(const void *sid, size_t len);
size_t   peios_sid_length(const void *sid);
bool     peios_sid_equal(const void *a, size_t alen, const void *b, size_t blen);
uint32_t peios_sid_rid(const void *sid, size_t len);

The split between peios_sid_valid and peios_sid_length is deliberate: validation is the safe check that bounds an untrusted buffer; peios_sid_length is the fast reader you use after you trust the bytes (or when you have already capped the buffer at PEIOS_SID_MAX_BYTES). When in doubt, validate first.

2.2.0.3 Well-known SIDs #

peios_sid_well_known constructs any of the standard system principals without you memorising their numbers:

enum peios_wks {
    PEIOS_WKS_NULL,                 /* S-1-0-0    Nobody */
    PEIOS_WKS_EVERYONE,             /* S-1-1-0    World */
    PEIOS_WKS_LOCAL,                /* S-1-2-0    Local */
    PEIOS_WKS_CREATOR_OWNER,        /* S-1-3-0 */
    PEIOS_WKS_CREATOR_GROUP,        /* S-1-3-1 */
    PEIOS_WKS_OWNER_RIGHTS,         /* S-1-3-4    suppresses owner WRITE_DAC */
    PEIOS_WKS_ANONYMOUS,            /* S-1-5-7 */
    PEIOS_WKS_SELF,                 /* S-1-5-10   PRINCIPAL_SELF */
    PEIOS_WKS_AUTHENTICATED_USERS,  /* S-1-5-11 */
    PEIOS_WKS_SYSTEM,               /* S-1-5-18   Local System */
    PEIOS_WKS_LOCAL_SERVICE,        /* S-1-5-19 */
    PEIOS_WKS_NETWORK_SERVICE,      /* S-1-5-20 */
    PEIOS_WKS_ADMINISTRATORS,       /* S-1-5-32-544 */
};

For the meaning of each principal, see Well-known principals.

2.2.0.4 Integrity levels #

Integrity-label SIDs have the form S-1-16-<rid>, where the RID names a level. peios_sid_integrity takes that RID; the standard levels are:

enum peios_integrity_level {
    PEIOS_IL_UNTRUSTED = 0,
    PEIOS_IL_LOW       = 4096,
    PEIOS_IL_MEDIUM    = 8192,
    PEIOS_IL_HIGH      = 12288,
    PEIOS_IL_SYSTEM    = 16384,
};

These are the labels that appear in a SACL as a SYSTEM_MANDATORY_LABEL ACE (see peios_acl_builder_label).

2.3 Access masks

Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors

An access mask is a 32-bit set of rights. Masks may contain four generic bits (KACS_ACCESS_GENERIC_READ/WRITE/EXECUTE/ALL) that stand in for object-specific rights until they are mapped to a concrete object class.

uint32_t peios_access_map_generic(uint32_t mask,
                                  const struct kacs_generic_mapping *m);

peios_access_map_generic folds the generic bits of mask into object-specific rights using the mapping m, and clears the generic bits from the result. Each object class publishes its canonical mapping as a data symbol you pass here — peios_file_generic_mapping (from <peios/file.h>) and peios_token_generic_mapping (from <peios/token.h>). Use it when you have a mask written in generic terms (say, from an SDDL string using GR/GW) and need the concrete rights for a specific object type.

2.4 Building ACLs

Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors

An ACL is an ordered list of ACEs. You assemble one with a peios_acl_builder — create it, add ACEs, take the serialised bytes, free it. Builders follow the sticky-error rules: the adders return void, the first error latches, and you check peios_acl_builder_error at the end.

typedef struct peios_acl_builder peios_acl_builder;

peios_acl_builder *peios_acl_builder_new(void);   /* NULL on OOM */
void               peios_acl_builder_free(peios_acl_builder *b);
void               peios_acl_builder_reset(peios_acl_builder *b);

peios_acl_builder_reset drops every accumulated ACE and clears the sticky error, so you can reuse one builder for several ACLs.

2.4.0.1 Adding ACEs #

The common single-SID families have convenience adders. flags is a mask of KACS_ACE_FLAG_* and is usually 0 — the flags carry inheritance semantics, which matter only for container/inheritable ACEs.

void peios_acl_builder_allow(peios_acl_builder *b, const void *sid, size_t len,
                             uint32_t mask, uint8_t flags);
void peios_acl_builder_deny (peios_acl_builder *b, const void *sid, size_t len,
                             uint32_t mask, uint8_t flags);
void peios_acl_builder_audit(peios_acl_builder *b, const void *sid, size_t len,
                             uint32_t mask, uint8_t flags);
AdderAppends
_allowAn ACCESS_ALLOWED ACE — grants mask to sid.
_denyAn ACCESS_DENIED ACE — denies mask to sid. Order matters: put denies before allows.
_auditA SYSTEM_AUDIT ACE — logs access by sid matching mask. Belongs in a SACL, not a DACL.

For an integrity label there is a dedicated adder:

void peios_acl_builder_label(peios_acl_builder *b, uint32_t integrity_rid,
                             uint32_t policy_mask);

It appends a SYSTEM_MANDATORY_LABEL ACE for integrity level S-1-16-<integrity_rid>. policy_mask is a mask of the KACS_SYSTEM_MANDATORY_LABEL_NO_{READ,WRITE,EXECUTE}_UP bits (from <pkm/sd.h>) that says which accesses a lower-integrity caller is denied. Like _audit, a label ACE belongs in a SACL.

For everything else — object ACEs, callback ACEs, resource-attribute ACEs — there is the general adder and a fully-specified ACE struct:

struct peios_ace_spec {
    uint8_t       type;      /* KACS_ACE_TYPE_* */
    uint8_t       flags;     /* KACS_ACE_FLAG_* */
    uint32_t      mask;
    const void   *sid;       /* trustee */
    size_t        sid_len;
    const uint8_t *object_type;            /* 16-byte GUID, or NULL */
    const uint8_t *inherited_object_type;  /* 16-byte GUID, or NULL */
    const void   *app_data;  /* trailing callback/resource data */
    size_t        app_data_len;
};

void peios_acl_builder_add(peios_acl_builder *b, const struct peios_ace_spec *ace);

Fill in only the fields the type uses; leave the rest NULL/0:

  • Object ACEs (KACS_ACE_TYPE_*_OBJECT) read object_type and inherited_object_type — each a 16-byte GUID, or NULL when absent.
  • Callback and resource-attribute ACEs carry trailing app_data (which is NULL only when app_data_len is 0). For callback ACEs this is the conditional-expression bytecode you can produce with peios_sddl_parse_condition.

The convenience adders are exactly peios_acl_builder_add with a pre-filled spec for the common cases; reach for _add when you need object, callback, or resource-attribute ACEs.

2.4.0.2 Taking the ACL bytes #

const void *peios_acl_builder_bytes(peios_acl_builder *b, size_t *len_out);
ssize_t     peios_acl_builder_finish(peios_acl_builder *b, void *buf, size_t cap);
int         peios_acl_builder_error(const peios_acl_builder *b);
  • peios_acl_builder_bytes borrows: it returns a pointer into the builder (valid until the next mutation, _reset, or _free), writing the length to len_out if non-NULL. It returns NULL if the sticky error is set.
  • peios_acl_builder_finish copies the serialised ACL out using the two-call protocol.
  • peios_acl_builder_error returns the latched errno, or 0 if the builder is healthy.

The usual next step is to hand these bytes to peios_sd_builder_dacl or _sacl.

2.5 Building security descriptors

Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors

A security descriptor binds an owner, a group, a DACL, a SACL, and control flags into one self-relative buffer. Its builder mirrors the ACL builder's shape.

typedef struct peios_sd_builder peios_sd_builder;

peios_sd_builder *peios_sd_builder_new(void);
void              peios_sd_builder_free(peios_sd_builder *b);
void              peios_sd_builder_reset(peios_sd_builder *b);

2.5.0.1 Setting components #

void peios_sd_builder_owner(peios_sd_builder *b, const void *sid, size_t len);
void peios_sd_builder_group(peios_sd_builder *b, const void *sid, size_t len);
void peios_sd_builder_control(peios_sd_builder *b, uint16_t set, uint16_t clear);
void peios_sd_builder_dacl(peios_sd_builder *b, const void *acl, size_t len);
void peios_sd_builder_dacl_null(peios_sd_builder *b);
void peios_sd_builder_sacl(peios_sd_builder *b, const void *acl, size_t len);
  • Owner / group. Omit the call to leave the component absent. That is exactly what you want when building a partial SD to set only some components via kacs_set_sd — the SD then carries only what you set.
  • Control bits. peios_sd_builder_control sets the bits in set and clears those in clear (KACS_SD_DACL_PROTECTED, and friends). You do not manage SELF_RELATIVE or the *_PRESENT bits — the builder maintains those for you as you add components.
  • DACL / SACL. Pass ACL bytes, typically straight from peios_acl_builder_bytes. An ACL with zero ACEs is a present-but-empty DACL, which grants only the owner's implicit rights.

The DACL has one subtlety worth stating plainly. KACS has no NULL-DACL encoding — there is no "DACL present, pointer null" form; the kernel's parser rejects it. So "grant everyone everything" is expressed as an absent DACL (the DACL_PRESENT control bit clear). peios_sd_builder_dacl_null requests exactly that: it clears any DACL you set earlier and produces the same bytes as never setting a DACL at all. It exists so you can state the grant-all intent explicitly rather than by omission — but be clear that it means grant all, not deny all.

2.5.0.2 Taking the SD bytes #

Identical in shape to the ACL builder:

const void *peios_sd_builder_bytes(peios_sd_builder *b, size_t *len_out);
ssize_t     peios_sd_builder_finish(peios_sd_builder *b, void *buf, size_t cap);
int         peios_sd_builder_error(const peios_sd_builder *b);

_bytes borrows (valid until the next mutation/reset/free, NULL if errored), _finish copies out getxattr-style, _error returns the latched errno.

2.6 Parsing — views

Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors

To read a security descriptor, ACL, or ACE you use zero-copy views. A view is a caller-allocated, opaque, stack-friendly struct that borrows the buffer you parse — see the view rules. Every accessor that yields a SID, a nested ACL, or a blob returns a pointer into the original buffer, so that buffer must outlive the view and everything derived from it.

typedef struct peios_sd_view        { uint64_t _opaque[8]; } peios_sd_view;
typedef struct peios_acl_view       { uint64_t _opaque[4]; } peios_acl_view;
typedef struct peios_ace_view       { uint64_t _opaque[4]; } peios_ace_view;
typedef struct peios_sid_array_view { uint64_t _opaque[4]; } peios_sid_array_view;

The _opaque arrays are sized for stack allocation with headroom — declare a view as a local and never read its fields.

2.6.0.1 Security-descriptor views #

int      peios_sd_parse(const void *sd, size_t len, peios_sd_view *out);
uint16_t peios_sd_view_control(const peios_sd_view *v);
int      peios_sd_view_owner(const peios_sd_view *v, const void **sid, size_t *len);
int      peios_sd_view_group(const peios_sd_view *v, const void **sid, size_t *len);
int      peios_sd_view_dacl(const peios_sd_view *v, peios_acl_view *out);
int      peios_sd_view_sacl(const peios_sd_view *v, peios_acl_view *out);

peios_sd_parse validates a self-relative SD and populates out, returning 0 or -1 (EINVAL). peios_sd_view_control returns the raw control-bit word.

The four component accessors return 0 with their out-params set on success, or -1 if the component is absent. For the DACL and SACL, -1 also covers the NULL-DACL case — since an absent DACL and a NULL DACL are the same thing in KACS, a -1 from peios_sd_view_dacl uniformly means "no DACL constrains this object."

2.6.0.2 ACL and ACE views #

You can also parse a bare ACL directly — a token's default DACL, for instance, arrives as an ACL, not wrapped in an SD:

int      peios_acl_parse(const void *acl, size_t len, peios_acl_view *out);
unsigned peios_acl_view_count(const peios_acl_view *a);
int      peios_acl_view_ace(const peios_acl_view *a, unsigned i, peios_ace_view *out);

peios_acl_view_count gives the number of ACEs; peios_acl_view_ace populates out for ACE i (0-based, in stored order), returning 0 or -1 (ERANGE for an out-of-range index). Iterate in the obvious way:

unsigned n = peios_acl_view_count(&dacl);
for (unsigned i = 0; i < n; i++) {
    peios_ace_view ace;
    peios_acl_view_ace(&dacl, i, &ace);
    /* inspect ace … */
}

Each ACE is read through its own accessors:

uint8_t  peios_ace_view_type(const peios_ace_view *e);
uint8_t  peios_ace_view_flags(const peios_ace_view *e);
uint32_t peios_ace_view_mask(const peios_ace_view *e);
int      peios_ace_view_sid(const peios_ace_view *e, const void **sid, size_t *len);
int      peios_ace_view_object_type(const peios_ace_view *e, const uint8_t **guid16);
int      peios_ace_view_inherited_object_type(const peios_ace_view *e,
                                              const uint8_t **guid16);
int      peios_ace_view_app_data(const peios_ace_view *e, const void **data,
                                 size_t *len);
AccessorYields
_type / _flags / _maskThe ACE's KACS_ACE_TYPE_* type, KACS_ACE_FLAG_* flags, and 32-bit access mask.
_sidThe trustee SID (a pointer into the buffer). 0 / -1.
_object_typeThe object GUID of an object ACE — 0 with *guid16 set to the 16 bytes, or -1 if not present / not an object ACE.
_inherited_object_typeThe inherited-object GUID, same convention.
_app_dataTrailing application data of a callback or resource-attribute ACE — for a callback ACE, this is the conditional-expression bytecode you can render with peios_sddl_format_condition.

2.6.0.3 SID-and-attributes arrays #

Several token classes — GROUPS, RESTRICTED_SIDS, DEVICE_GROUPS, CAPABILITIES — return a packed [count][sid_len][sid][attrs]… blob rather than an ACL. Parse those with the SID-array view:

int      peios_sid_array_parse(const void *blob, size_t len, peios_sid_array_view *out);
unsigned peios_sid_array_count(const peios_sid_array_view *a);
int      peios_sid_array_get(const peios_sid_array_view *a, unsigned i,
                             const void **sid, size_t *len, uint32_t *attrs);

peios_sid_array_get yields the i-th entry's SID (a pointer into the blob), its length, and its 32-bit attribute word (the KACS_SE_GROUP_* flags — enabled, mandatory, deny-only, and so on).

2.7 SDDL text codec

Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors

The SDDL codec converts between the binary wire forms above and their human-readable SDDL text (MS-DTYP §2.5.1). This is a pure-userspace facility — the kernel speaks only binary — so it lives entirely in libpeios. All four entries use the two-call protocol (cap == 0 to probe) and fail with EINVAL on malformed input.

ssize_t peios_sddl_parse_sd(void *out, size_t cap, const char *sddl);
ssize_t peios_sddl_format_sd(char *out, size_t cap, const void *sd, size_t sd_len);
  • peios_sddl_parse_sd parses SDDL text (e.g. "O:SYG:BAD:(A;;FA;;;BA)") into self-relative SD wire bytes.
  • peios_sddl_format_sd renders SD wire bytes back to a NUL-terminated SDDL string (length excludes the NUL, so allocate len + 1).

These are the friendliest way to construct a descriptor when you have one written down — parse the string rather than assembling ACEs by hand — and the friendliest way to log or display one.

2.7.0.1 Conditional expressions #

Callback ACEs carry a conditional expression as compiled "artx" bytecode. The codec converts between that bytecode and its SDDL expression text:

ssize_t peios_sddl_parse_condition(void *out, size_t cap, const char *expr);
ssize_t peios_sddl_format_condition(char *out, size_t cap, const void *artx, size_t len);
  • peios_sddl_parse_condition compiles an expression such as @User.Title == "PM" into the bytecode you place in a callback ACE's app_data.
  • peios_sddl_format_condition renders bytecode back to text (with no outer parentheses), length excluding the NUL.

So the round trip for a conditional ACE is: write the condition as text → peios_sddl_parse_condition → put the bytecode in peios_ace_spec.app_data with a callback ACE type → add it to an ACL builder.

2.8 SD inheritance

Peios / Developing for Peios / SDK Reference / security.h — SIDs and Descriptors

Inheritance — computing a child object's ACEs from its parent's inheritable ones — is also pure userspace (MS-DTYP §2.5.3.4). Both helpers take and produce self-relative SDs and use the two-call protocol.

ssize_t peios_sd_reinherit(void *out, size_t cap, const void *parent_sd,
                           size_t parent_len, const void *child_sd,
                           size_t child_len, int is_container);
ssize_t peios_sd_strip_inherited(void *out, size_t cap, const void *sd,
                                 size_t sd_len, uint32_t info);

peios_sd_reinherit recomputes a child SD's inherited ACEs from its parent. It strips the ACEs carrying ACE_FLAG_INHERITED from the child DACL, re-derives them from the parent DACL, and appends them after the child's explicit ACEs; the child's owner, group, SACL, and control bits pass through unchanged. is_container is non-zero if the child is itself a container (which determines how container-inherit and object-inherit flags propagate). This is what you call when a parent's ACL changed and you need to push the new inheritance down to a child.

peios_sd_strip_inherited drops the ACE_FLAG_INHERITED ACEs from the ACLs selected by info — a mask of *_SECURITY_INFORMATION bits, of which DACL_SECURITY_INFORMATION and SACL_SECURITY_INFORMATION are honoured and the rest ignored (selecting neither copies the input verbatim). Owner, group, and control bits pass through. Use it to reduce a descriptor to just its explicit ACEs — for example before storing a "protected" descriptor that should not carry inherited entries.

Both return the new SD's byte length, or -1 with EINVAL (malformed input) or ERANGE (a non-zero buffer too small).

3.1 token.h — Tokens and sessions

Peios / Developing for Peios / SDK Reference / token.h — Tokens

<peios/token.h> is the token surface of KACS. A token is the runtime object that carries an identity — a user SID, group SIDs, privileges, an integrity level, claims — and every access decision is made against one. This module lets you open the tokens that already exist (your own, another process's, a socket peer's), mint new ones, read their contents, transform them, and install or impersonate them.

A token handle is a file descriptor. Every open/create/duplicate call returns a raw int fd, O_CLOEXEC by default, that you close with close(). The access argument several calls take is the desired handle-right mask (KACS_TOKEN_*), access-checked against the token's own security descriptor and cached on the fd — a handle only lets you do what its rights allow.

The wire constants (KACS_TOKEN_*, KACS_IMLEVEL_*, KACS_SE_*_PRIVILEGE, KACS_TOKEN_CLASS_*, KACS_LOGON_TYPE_*) and the ioctl arg structs (kacs_priv_entry, kacs_group_entry) come from <pkm/token.h>. Query payloads that are SID arrays or ACLs are read with the views in <peios/security.h>.

The module divides into: opening & creating, the token-spec builder, query, adjust/transform, and logon sessions.

3.1.1 See also #

  • <peios/security.h> — the SID/ACL/SD vocabulary and the views used to parse group and privilege query payloads.
  • <peios/access.h> — checking access with a token fd.
  • Tokens and Impersonation — the operator-side model.

3.2 Opening and creating tokens

Peios / Developing for Peios / SDK Reference / token.h — Tokens

Each of these returns a token fd (or -1 with errno).

int peios_token_open_self(unsigned flags, uint32_t access);
int peios_token_open_process(int pidfd, uint32_t access);
int peios_token_open_thread(int pidfd, int tid, uint32_t access);
int peios_token_open_peer(int conn_fd);
int peios_token_create_raw(const void *spec, size_t len);
FunctionOpens
peios_token_open_selfThe calling thread's token. flags may be KACS_TOKEN_OPEN_REAL to get the primary token even while the thread is impersonating; otherwise you get the effective (impersonation-aware) token. access is the desired handle rights.
peios_token_open_processThe primary token of the process named by pidfd. Subject to a process-query access check and PIP dominance over the target.
peios_token_open_threadThread tid's impersonation token if it is impersonating, else the process primary token.
peios_token_open_peerThe peer-identity token captured at connect() on a connected Unix stream/seqpacket socket conn_fd — how a server learns who is on the other end of a socket. Reads the KACS_SO_PEER_TOKEN socket option (level SOL_KACS, <pkm/socket.h>). The handle carries fixed `QUERY
peios_token_create_rawMints a token from a pre-built token-spec buffer. This is the escape hatch — prefer the builder below. Requires SeCreateTokenPrivilege.

Errors, per call:

  • peios_token_open_self — EINVAL (unknown flags; empty or unknown access bits), EACCES (the token's own SD denies access).
  • peios_token_open_process — EACCES (any of the three checks failed — process-query right, PIP dominance, or the token SD; deliberately indistinguishable), EBADF (invalid pidfd), ESRCH (target exited), EINVAL (empty or unknown access bits).
  • peios_token_open_thread — the _open_process set, plus ESRCH (thread exited, or not in pidfd's process) and EINVAL (tid <= 0).
  • peios_token_open_peer — ENOTCONN (not connected), ENODATA (connected but no captured identity — a socketpair end), EOPNOTSUPP (a datagram or non-Unix socket), ENOTSOCK (not a socket), EBADF (invalid fd).
  • peios_token_create_raw — EPERM (privilege missing), EINVAL (spec failed kernel validation), EFAULT (bad spec pointer), ENOMEM (allocation failed).

peios_token_open_peer is the cornerstone of local authentication: accept a connection, open the peer token, and you have the caller's identity to query or impersonate — no password, no handshake, just the kernel's word for who connected.

3.2.1 Peer identity on sockets #

int peios_token_impersonate_peer(int conn_fd);
int peios_socket_set_impersonation_level(int sock_fd, uint32_t level);
int peios_socket_get_impersonation_level(int sock_fd, uint32_t *level);
int peios_socket_set_pass_token(int sock_fd, bool on);
int peios_socket_restamp(int sock_fd);
  • peios_token_impersonate_peer is peios_token_open_peer followed by peios_token_impersonate and a close — the one-call form for a handler that impersonates, works, and reverts on a single thread. It returns 0 or -1; the errno is whichever step failed.
  • peios_socket_set_impersonation_level is the client's side of the exchange: it sets the KACS_SO_IMPERSONATION_LEVEL socket option (KACS_IMLEVEL_*), bounding the level at which the peer may capture this socket's identity — Identification to be known but not acted as, Anonymous to withhold identity entirely. Usually set before connect(); it may change at any time and bounds every capture from then on. The default is KACS_IMLEVEL_IMPERSONATION. Errors: EINVAL (unknown level), EOPNOTSUPP (a non-Unix socket).
  • peios_socket_get_impersonation_level reads the level back into *level.
  • peios_socket_restamp replaces the identity a listening socket conveys to connecting clients — captured when listen() was called — with the caller's own (KACS_SO_RESTAMP; EINVAL if the socket is not listening). Capture is symmetric: a client's peios_token_open_peer on its own socket right after connect() returns the listener's identity at Identification level, which is how a client verifies the service it reached. A service that receives listeners it did not create (from the descriptor store after a restart, or from a broker) restamps them before accepting.
  • peios_socket_set_pass_token turns on per-message identity for a sending socket (KACS_SO_PASS_TOKEN): every send then carries the sender's effective identity as a KACS_SCM_TOKEN ancillary message, and the peer's peios_token_open_peer follows whoever was impersonating at each send. This is what makes a pooled or multiplexed connection convey the right identity per request without the client library knowing. To attach a specific token to one message instead — a router forwarding for many clients, or a submitter handing a job manager the identity a job should run as — use peios_socket_send_message below.

3.2.2 Per-message identity and descriptors #

A message on a Unix socket can carry two things beside its bytes: a token the kernel attests the sender could act as (KACS_SCM_TOKEN, level SOL_KACS) and a set of file descriptors (SCM_RIGHTS). Building the control buffer by hand and walking it back is the same twenty lines in every program that does it, and a wrong CMSG_ALIGN is silent; these calls do it once.

struct peios_socket_message {
    int      token_fd;   /* out: attached token (QUERY|IMPERSONATE|DUPLICATE, O_CLOEXEC), or -1 */
    int     *fds;        /* in:  SCM_RIGHTS descriptors land here, each O_CLOEXEC */
    unsigned fd_cap;     /* in:  capacity of fds */
    unsigned fd_count;   /* out: how many were filled */
    unsigned flags;      /* out: PEIOS_SOCKET_MSG_* */
};
#define PEIOS_SOCKET_MSG_TRUNCATED  0x1u   /* data did not fit (MSG_TRUNC) */
#define PEIOS_SOCKET_MSG_CTRUNCATED 0x2u   /* ancillary data did not fit (MSG_CTRUNC) */

ssize_t peios_socket_send_message(int sock_fd, const void *buf, size_t len,
                                  int token_fd, const int *fds, unsigned fd_count,
                                  int flags);
ssize_t peios_socket_recv_message(int sock_fd, void *buf, size_t cap,
                                  struct peios_socket_message *msg, int flags);
int     peios_socket_peer_pidfd(int sock_fd);
  • peios_socket_send_message sends len bytes from buf on sock_fd with one sendmsg(2), attaching token_fd as a KACS_SCM_TOKEN when it is >= 0 and fds[0..fd_count] as SCM_RIGHTS when fd_count > 0; flags are sendmsg flags. Returns the bytes sent. The kernel gates the token attach exactly as it gates impersonating it — the handle needs TOKEN_IMPERSONATE, and the token travels at the sending socket's impersonation level (peios_socket_set_impersonation_level, default Impersonation), which can only lower what the token already carries: if attaching would have to lower the token's level the send fails rather than downgrading. Ownership is unchanged by a send — the caller keeps token_fd and every entry of fds; the kernel duplicates them into the receiver on delivery.
  • peios_socket_recv_message receives one message with recvmsg(2) into buf/cap, with room for one attached token and msg->fd_cap descriptors written into the caller-owned msg->fds (pass NULL with fd_cap == 0 to accept none). MSG_CMSG_CLOEXEC is always added to flags, so every descriptor delivered is O_CLOEXEC. Returns the bytes received (0 at end of stream). On return the caller owns msg->token_fd (when >= 0) and msg->fds[0..fd_count] and closes each; the token handle carries QUERY | IMPERSONATE | DUPLICATE, the same fixed rights as peios_token_open_peer. Nothing leaks on any path: a second token in one message, or a descriptor past fd_cap, is closed by the library before it returns, and msg->flags reports the loss as PEIOS_SOCKET_MSG_CTRUNCATED; PEIOS_SOCKET_MSG_TRUNCATED means the bytes did not fit cap (on a SOCK_SEQPACKET socket the tail is gone). A receiver that cannot accept a truncated message closes what it was handed. On -1 nothing was consumed and *msg is untouched. fd_cap above the kernel's SCM_MAX_FD (253) is capped silently. The truncation flag also covers handles that fit in the syscall's ancillary buffer but exceed the caller's capacity. For example, fd_cap == 0 closes any received rights and reports control truncation. This flag is retained alongside payload truncation when both occur.
  • peios_socket_peer_pidfd returns a new pidfd (O_CLOEXEC) for the process on the other end of a connected Unix socket — getsockopt(SOL_SOCKET, SO_PEERPIDFD), a Linux ≥ 6.5 facility that libpeios only wraps; the option itself comes from the kernel UAPI and glibc. It is the kernel's handle on the peer, which is what peios_token_open_process takes to open the peer's primary token — the identity a process has, as distinct from the identity it conveyed at connect() (peios_token_open_peer) — and never a PID the peer named, so a recycled PID can never be opened by mistake.

Errors: peios_socket_send_message — EINVAL (NULL buffer with len > 0, NULL fds with fd_count > 0, or fd_count above SCM_MAX_FD), EACCES (token_fd lacks TOKEN_IMPERSONATE), EPERM (attaching would lower the token's level), EBADF (a descriptor in fds is not open), plus the sendmsg set (ENOTCONN, EPIPE, EMSGSIZE, EAGAIN). peios_socket_recv_message — EINVAL (NULL msg), plus the recvmsg set (ENOTCONN, EAGAIN, EBADF). peios_socket_peer_pidfd — ENODATA (no connected peer to name), ENOPROTOOPT (a kernel without SO_PEERPIDFD), ENOTSOCK, EBADF, EMFILE.

The client side of a job submission (PSPU §7) is the archetype — one record, one identity, two descriptors:

const char *request = "{\"command\":\"submit\",\"image_path\":\"/usr/bin/backup\","
                      "\"descriptors\":[\"control\"],\"output\":true}";
int fds[2] = { control_sock, STDOUT_FILENO };   /* named descriptors first, output sink last */

/* `job_token` is a token I hold with TOKEN_IMPERSONATE; the job runs as it. */
if (peios_socket_send_message(jobs_sock, request, strlen(request),
                              job_token, fds, 2, 0) < 0) { /* errno */ }
/* I still own job_token, control_sock and my stdout. */

and the manager's side, receiving it:

char buf[64 * 1024];
int fds[8];
struct peios_socket_message msg = { .fds = fds, .fd_cap = 8 };

ssize_t n = peios_socket_recv_message(conn, buf, sizeof buf, &msg, 0);
if (n < 0) { /* errno; nothing consumed */ }
if (n == 0) { /* end of stream */ }
if (msg.flags & (PEIOS_SOCKET_MSG_TRUNCATED | PEIOS_SOCKET_MSG_CTRUNCATED)) {
    /* not the message the sender meant: refuse it and close what arrived */
}
if (msg.token_fd >= 0) {
    /* the sender attested to this identity, at its socket's level */
    uint32_t level;
    peios_token_impersonation_level(msg.token_fd, &level);   /* e.g. refuse below Impersonation */
} else {
    /* no identity conveyed: fall back to the peer process's own primary token */
    int pidfd = peios_socket_peer_pidfd(conn);
    int primary = peios_token_open_process(pidfd, KACS_TOKEN_QUERY | KACS_TOKEN_DUPLICATE);
    close(pidfd);
}
/* msg.fds[0..msg.fd_count] and msg.token_fd are mine to use and close */

3.3 The token-spec builder

Peios / Developing for Peios / SDK Reference / token.h — Tokens

Minting a token means assembling a 192-byte-header wire format with many optional sections. The builder is the ergonomic path — typed setters, no hand-packed offsets — and follows the standard sticky-error builder rules: the setters return void, the first error latches, you check peios_token_builder_error at the end, and you _free every builder.

typedef struct peios_token_builder peios_token_builder;

peios_token_builder *peios_token_builder_new(void);
void                 peios_token_builder_free(peios_token_builder *b);
void                 peios_token_builder_reset(peios_token_builder *b);

3.3.0.1 The index convention #

Three fields — the owner, the primary group, and the restrict/deny indices — refer to SIDs by index into the token's own SID list rather than by value. The convention is fixed:

Index 0 is the user SID. Indices 1..N are the 1st..Nth group you added with peios_token_builder_add_group, in order.

So to make the second group the primary group, you set primary_group_index to 2. Do not add the logon SID yourself — the kernel injects it.

3.3.0.2 Core fields #

void peios_token_builder_user(peios_token_builder *b, const void *sid, size_t len);
void peios_token_builder_add_group(peios_token_builder *b, const void *sid,
                                   size_t len, uint32_t attrs);
void peios_token_builder_privileges(peios_token_builder *b, uint64_t present,
                                    uint64_t enabled);
void peios_token_builder_type(peios_token_builder *b, uint8_t type, uint8_t imp_level);
void peios_token_builder_integrity(peios_token_builder *b, uint32_t rid);
void peios_token_builder_session(peios_token_builder *b, uint64_t session_id);
void peios_token_builder_owner_index(peios_token_builder *b, uint32_t index);
void peios_token_builder_primary_group_index(peios_token_builder *b, uint32_t index);
void peios_token_builder_default_dacl(peios_token_builder *b, const void *acl, size_t len);
SetterSets
_userThe user SID (index 0).
_add_groupAppends a group SID with its KACS_SE_GROUP_* attribute word (enabled, mandatory, deny-only, …). Call once per group, in the order you want them indexed.
_privilegesThe privilege bitmasks: present (which privileges the token holds) and enabled (which are on). Bits are KACS_SE_*_PRIVILEGE.
_typeThe token type (KACS_TOKEN_TYPE_* — primary or impersonation) and the impersonation level imp_level (KACS_IMLEVEL_*). Choose the level deliberately: it caps every identity derived from the token, including a primary. CreateToken accepts all four levels; Identification permits peers to identify the process without acting as it, and Anonymous hides its identity from peers. The process still acts as its own user. Older kernels reject primary levels below Impersonation. DuplicateToken retains its separate Impersonation floor for primary results.
_integrityThe integrity level, as the RID of an S-1-16-<rid> label (see peios_integrity_level).
_sessionThe logon session id the token references.
_owner_index / _primary_group_indexWhich SID (by index) is the default owner / primary group.
_default_daclThe default DACL applied to new objects the token creates (ACL bytes, e.g. from a peios_acl_builder).

3.3.0.3 Advanced fields #

These cover the rest of the token-spec and can be left unset. They are marked [adv] in the header for a reason — most tokens need none of them.

void peios_token_builder_mandatory_policy(peios_token_builder *b, uint32_t bits);
void peios_token_builder_projected_ids(peios_token_builder *b, uint32_t uid, uint32_t gid);
void peios_token_builder_expiration(peios_token_builder *b, uint64_t when);
void peios_token_builder_source(peios_token_builder *b, const char name[8],
                                uint64_t source_id);
void peios_token_builder_audit_policy(peios_token_builder *b, uint32_t bits);
void peios_token_builder_add_restricted_sid(peios_token_builder *b, const void *sid,
                                            size_t len, uint32_t attrs);
void peios_token_builder_add_device_group(peios_token_builder *b, const void *sid,
                                          size_t len, uint32_t attrs);
void peios_token_builder_confinement(peios_token_builder *b, const void *sid, size_t len);
void peios_token_builder_supp_gids(peios_token_builder *b, const uint32_t *gids,
                                   unsigned count);
SetterSets
_mandatory_policyThe mandatory-integrity policy bits governing how the integrity label is enforced.
_projected_idsThe POSIX uid/gid this token projects into the Linux-compatibility layer.
_expirationAn absolute expiry time after which the token is no longer valid.
_sourceThe token's source: an 8-byte name and a source_id, recording who issued it (appears in audit).
_audit_policyPer-token audit policy bits.
_add_restricted_sidAppends a restricting SID (a write-restricted / restricted token intersects these against the normal SIDs).
_add_device_groupAppends a device group SID (the device/machine side of a claim-aware token).
_confinementThe confinement/AppContainer package SID that sandboxes the token.
_supp_gidsReplaces the projected supplementary GIDs (pass NULL, 0 to clear).

3.3.0.4 Token flags #

The four boolean token-spec flags are set together, so a designated initialiser reads clearly:

struct peios_token_flags {
    bool write_restricted;
    bool user_deny_only;
    bool isolation_boundary;
    bool confinement_exempt;
};
void peios_token_builder_flags(peios_token_builder *b, const struct peios_token_flags *f);
  • write_restricted — the token's restricting SIDs are checked only for write access.
  • user_deny_only — the user SID is usable for deny ACEs but not to grant access.
  • isolation_boundary — marks an isolation boundary for confinement.
  • confinement_exempt — the token is exempt from confinement checks.

3.3.0.5 Claims #

A claim is a named, typed, multi-valued security attribute — the input to conditional (callback) ACEs. Claims come in user and device flavours; both share the same shape.

struct peios_token_claim_value {
    uint64_t    scalar;   /* INT64 / UINT64 / BOOLEAN (0 or 1) */
    const void *bytes;    /* STRING (UTF-8) / SID / OCTET */
    size_t      len;
};

struct peios_token_claim {
    const char *name;         /* UTF-8; transcoded to UTF-16LE on the wire */
    uint16_t    value_type;   /* KACS_CLAIM_TYPE_* */
    uint32_t    flags;        /* KACS_CLAIM_ATTR_* */
    const struct peios_token_claim_value *values;
    unsigned    value_count;
};

void peios_token_builder_add_user_claim(peios_token_builder *b,
                                        const struct peios_token_claim *claim);
void peios_token_builder_add_device_claim(peios_token_builder *b,
                                          const struct peios_token_claim *claim);

The value_type selects which member of each value carries the data:

value_typeValue member
KACS_CLAIM_TYPE_INT64 / _UINT64 / _BOOLEANscalar (a boolean is 0 or 1).
KACS_CLAIM_TYPE_STRINGbytes/len — a UTF-8 string (transcoded to UTF-16LE on the wire).
KACS_CLAIM_TYPE_SIDbytes/len — a binary SID.
KACS_CLAIM_TYPE_OCTETbytes/len — an opaque blob.

Each claim you add is round-tripped through the kernel's own claim parser before acceptance, so a malformed claim latches EINVAL on the builder immediately — you find out at build time, not at token-create time.

3.3.0.6 LCS registry credentials #

The final optional section grants the token registry-layer powers: which layer scopes it may resolve and which private layers it owns.

struct peios_token_lcs_credentials {
    const uint8_t (*scope_guids)[16];    /* array of 16-byte GUIDs, each non-nil & unique */
    unsigned    scope_count;             /* <= KACS_TOKEN_LCS_MAX_SCOPE_GUIDS */
    const char *const *private_layers;   /* UTF-8 names, 1..255 bytes, no '/' or '\\', unique */
    unsigned    private_layer_count;     /* <= KACS_TOKEN_LCS_MAX_PRIVATE_LAYERS */
};
void peios_token_builder_lcs_credentials(peios_token_builder *b,
                                         const struct peios_token_lcs_credentials *creds);

Setting it replaces any prior credentials; it is emitted as the last token-spec section. See <peios/registry.h> for what layers and scopes mean.

3.3.0.7 Finishing the builder #

ssize_t peios_token_builder_bytes(peios_token_builder *b, const void **out);
int     peios_token_builder_create(peios_token_builder *b);
int     peios_token_builder_error(const peios_token_builder *b);
  • peios_token_builder_bytes returns the serialised length and, if out is non-NULL, writes a pointer into the builder (valid until the next reset/free) through it. Use this if you want the raw token-spec bytes.
  • peios_token_builder_create does it in one step: serialise and mint, returning the new token fd. This is the usual call. It requires SeCreateTokenPrivilege.
  • peios_token_builder_error returns the latched errno, or 0.

Errors: _bytes and _create first surface any latched builder error — EINVAL (malformed field, SID, claim, or index) or ENOMEM (allocation failed). A clean _create then adds the peios_token_create_raw set: EPERM (privilege missing), EINVAL (spec failed kernel validation), ENOMEM.

peios_token_builder *tb = peios_token_builder_new();
peios_token_builder_user(tb, user_sid, user_len);
peios_token_builder_add_group(tb, admins_sid, admins_len, KACS_SE_GROUP_ENABLED);
peios_token_builder_type(tb, KACS_TOKEN_TYPE_PRIMARY, 0);
peios_token_builder_integrity(tb, PEIOS_IL_MEDIUM);
peios_token_builder_session(tb, session_id);

int tok = peios_token_builder_create(tb);       /* -1 on failure */
if (tok < 0) { int e = peios_token_builder_error(tb); /* or errno */ }
peios_token_builder_free(tb);

3.4 Query

Peios / Developing for Peios / SDK Reference / token.h — Tokens

You read a token's contents by information class. The generic reader handles any class getxattr-style; typed convenience wrappers cover the common ones.

ssize_t peios_token_query(int fd, uint32_t info_class, void *buf, size_t cap);
ssize_t peios_token_user(int fd, void *sid_buf, size_t cap);   /* CLASS_USER */
  • peios_token_query reads the class info_class (KACS_TOKEN_CLASS_*) into buf using the two-call protocol. Classes that return SID arrays or ACLs are parsed afterward with the <peios/security.h> views — e.g. read CLASS_GROUPS into a buffer, then peios_sid_array_parse it.
  • peios_token_user is the same two-call read specialised to the user SID (CLASS_USER): probe with sid_buf == NULL, cap == 0, then retrieve.

For the common scalar classes there are typed helpers that write through a mandatory non-NULL out-pointer and return 0 / -1:

struct peios_privilege_set {
    uint64_t present;
    uint64_t enabled;
    uint64_t enabled_by_default;
    uint64_t used;
};

struct peios_token_statistics {
    uint64_t token_id;
    uint64_t auth_id;       /* LogonSession LUID */
    uint64_t modified_id;
    uint32_t token_type;
    uint32_t reserved;
    uint64_t expiration;
};

int peios_token_type(int fd, uint32_t *out);            /* CLASS_TYPE */
int peios_token_impersonation_level(int fd, uint32_t *out); /* CLASS_IMPERSONATION_LEVEL */
int peios_token_interactivity_scope(int fd, uint32_t *out); /* CLASS_INTERACTIVITY_SCOPE */
int peios_token_guid(int fd, uint8_t guid_out[16]);     /* CLASS_GUID */
int peios_token_statistics(int fd, struct peios_token_statistics *out); /* CLASS_STATISTICS */
int peios_token_integrity(int fd, uint32_t *level_rid_out); /* CLASS_INTEGRITY_LEVEL */
int peios_token_privileges(int fd, struct peios_privilege_set *out); /* CLASS_PRIVILEGES */

peios_token_impersonation_level reads the token's impersonation level (KACS_IMLEVEL_*): the ceiling on everything captured from, conveyed by, or duplicated out of it — on a primary token as much as an impersonation one, since the level only ever ratchets down (Kernel TRM §3.5.1). A manager handed a token over a socket reads this before deciding what the token may become; a token below Impersonation cannot be turned into a primary token for a new process.

peios_token_interactivity_scope reads the token's interactive-environment scope. It is not a logon-session identifier. To read the token's uint64_t LogonSession LUID, use peios_token_statistics and inspect auth_id; that helper returns the complete fixed-width statistics record.

peios_token_guid writes the immutable 16-byte token GUID used by KMES. The caller supplies 16 writable bytes; the helper leaves them unchanged on failure. Rust exposes Token::guid() -> Result<[u8; 16]> and Go exposes (*Token).GUID() ([16]byte, error). All return raw bytes in KMES order. They require TOKEN_QUERY; kernels predating KACS_TOKEN_CLASS_GUID return EINVAL. Deploy the supporting kernel before consumers. The statistics record and its LUID remain unchanged.

peios_token_privileges returns all four privilege words at once: which privileges are present, which are enabled, which are enabled_by_default, and which have been used (the audit trail of privilege use).

Errors (all query calls): EACCES (handle lacks QUERY), EINVAL (unknown class), ERANGE (non-probe buffer too small), EFAULT (bad buffer pointer). The typed helpers add EINVAL (NULL out-pointer, or an unexpected payload shape).

3.5 Adjust and transform

Peios / Developing for Peios / SDK Reference / token.h — Tokens

These change a token or derive a new one from it. Deriving calls return a new fd; in-place adjustments return 0 / -1.

3.5.0.1 Privileges and groups #

int peios_token_adjust_privileges(int fd, const struct kacs_priv_entry *entries,
                                  unsigned count, uint64_t *prev_enabled);
int peios_token_reset_privileges(int fd);
int peios_token_adjust_groups(int fd, const struct kacs_group_entry *entries,
                              unsigned count, uint64_t *prev_state);
int peios_token_reset_groups(int fd);
  • peios_token_adjust_privileges enables/disables the privileges named in entries (each a kacs_priv_entry); if prev_enabled is non-NULL it receives the prior enabled mask, so you can restore it later. peios_token_reset_privileges restores enabled := enabled_by_default. Errors: EACCES (handle lacks ADJUST_PRIVILEGES), EINVAL (empty or oversized batch, duplicate entry, enabling an absent privilege, unknown attribute bits), EFAULT (bad entries pointer).
  • peios_token_adjust_groups is the group analogue. prev_state, if non-NULL, points at a caller array of KACS_TOKEN_GROUP_MASK_WORDS uint64_t words that receives the prior enabled bitmask. peios_token_reset_groups restores the default group state. Errors: EACCES (handle lacks ADJUST_GROUPS), EINVAL (mandatory, deny-only, or logon-SID group targeted; duplicate or out-of-range index; empty batch), EFAULT (bad entries pointer).

3.5.0.2 Duplicate and restrict #

int peios_token_duplicate(int fd, uint32_t access, uint8_t type, uint8_t imp_level);

struct peios_token_restrict {
    uint64_t           privs_to_delete;
    const uint32_t    *deny_group_indices;   /* groups demoted to deny-only */
    unsigned           deny_count;
    const void *const *restrict_sids;        /* added restricting SIDs */
    const size_t      *restrict_sid_lens;
    unsigned           restrict_count;
    uint32_t           flags;                /* KACS_TOKEN_RESTRICT_WRITE_RESTRICTED */
};
int peios_token_restrict(int fd, const struct peios_token_restrict *spec);
  • peios_token_duplicate copies the token, returning a new fd with handle rights access, token type (KACS_TOKEN_TYPE_*), and impersonation level imp_level (KACS_IMLEVEL_*). This is how you turn a primary token into an impersonation token, an impersonation token into a primary one for a process launched as that identity, or narrow a handle's rights. imp_level is a ratchet: it may equal or lower the source's level, never raise it, whichever direction the type goes. Errors: EACCES (handle lacks DUPLICATE, or the new token's SD denies access), EINVAL (unknown type/imp_level, a level above the source's, a primary requested below KACS_IMLEVEL_IMPERSONATION, empty or unknown access bits), ENOMEM (allocation failed).
  • peios_token_restrict creates a filtered token — the sandboxing primitive. It can delete privileges (privs_to_delete), demote groups to deny-only (deny_group_indices, by index), add restricting SIDs (restrict_sids/restrict_sid_lens), and set KACS_TOKEN_RESTRICT_WRITE_RESTRICTED. The result is a strictly less-powerful token you can hand to less-trusted code. Errors: EACCES (handle lacks DUPLICATE), EINVAL (duplicate or out-of-range deny index, malformed restricting SID, unknown flags, NULL spec or arrays), ENOMEM (allocation failed).

3.5.0.3 Impersonation and installation #

int peios_token_install(int fd);
int peios_token_impersonate(int fd);
int peios_token_revert(void);
  • peios_token_install makes this primary token the calling process's primary token. Errors: EACCES (handle lacks ASSIGN_PRIMARY, or SeAssignPrimaryTokenPrivilege missing), EINVAL (not a primary token), EAGAIN (thread set changed mid-install — retry), ENOMEM (allocation failed).
  • peios_token_impersonate makes this impersonation token the calling thread's effective identity — subsequent access checks on that thread run as the impersonated identity. Errors: EACCES (handle lacks IMPERSONATE), EINVAL (not an impersonation token), EPERM (restricted→unrestricted same-user — the one hard deny), ENOMEM (allocation failed).
  • peios_token_revert undoes it: it clears the thread's impersonation token so checks run as the thread's real (primary) identity again. It takes no argument and is a no-op (reported as success) if the thread was not impersonating. This is the inverse of peios_token_impersonate — always pair them, ideally with revert in the cleanup path. Errors: none in normal operation.

The archetypal server flow: peios_token_open_peer the caller → peios_token_impersonate it → do the work as them → peios_token_revert. peios_token_impersonate_peer (see Opening and creating tokens) fuses the first two steps and the close.

3.5.0.4 Linked tokens and defaults #

int peios_token_link(int elevated_fd, int filtered_fd, uint64_t logon_session_id);
int peios_token_get_linked(int fd);
int peios_token_adjust_default(int fd, const void *dacl, size_t len,
                               uint16_t owner_index, uint16_t group_index);
int peios_token_set_interactivity_scope(int fd, uint32_t scope);
  • peios_token_link links an elevated + filtered primary-token pair in logon_session_id — the UAC-style split-token model, where a filtered token is the everyday identity and its elevated linked token is available on demand. peios_token_get_linked opens the linked token of fd, returning a new fd. Errors (_link): EACCES (SeTcbPrivilege missing, or either handle lacks DUPLICATE), EINVAL (self-link, role/logon-session/user-SID mismatch, not primary tokens, unknown logon_session_id, or an fd that is not a token fd), EBADF (invalid fd). Errors (_get_linked): EACCES (handle lacks QUERY), ENOENT (not part of a linked pair, or the pair was destroyed), ENOMEM (allocation failed).
  • peios_token_adjust_default replaces the token's default DACL and/or owner/primary-group indices. dacl == NULL leaves the DACL unchanged (and ignores len); dacl != NULL with len == 0 clears it; an index of 0xFFFF leaves that index unchanged. Errors: EACCES (handle lacks ADJUST_DEFAULT), EINVAL (out-of-range index; malformed or oversized DACL), EFAULT (bad DACL pointer).
  • peios_token_set_interactivity_scope changes the token's uint32_t interactive-environment scope (requires SeTcbPrivilege). This is deliberately separate from the token's uint64_t LogonSession LUID (auth_id) and does not move the token between logon sessions. Errors: EACCES (handle lacks ADJUST_INTERACTIVITY_SCOPE, or SeTcbPrivilege missing).

3.6 Logon sessions

Peios / Developing for Peios / SDK Reference / token.h — Tokens

A logon session is the lightweight kernel bookkeeping a token references — the "login" a token belongs to. Creating and destroying them requires SeTcbPrivilege.

struct peios_session_spec {
    uint8_t     logon_type;     /* KACS_LOGON_TYPE_* */
    const char *auth_package;   /* UTF-8; may be "" */
    const void *user_sid;
    size_t      user_sid_len;
};

int peios_session_create(const struct peios_session_spec *spec, uint64_t *id_out);
int peios_session_destroy_empty(uint64_t session_id);
  • peios_session_create creates a logon session of type logon_type (KACS_LOGON_TYPE_* — interactive, network, service, …) for user_sid, attributing it to auth_package. id_out is mandatory and receives the new session id, which you then pass to peios_token_builder_session. Errors: EPERM (SeTcbPrivilege missing), EINVAL (NULL spec, id_out, or field; malformed SID; oversized spec), EFAULT (bad pointer), ENOMEM (allocation failed).
  • peios_session_destroy_empty destroys a session that has no live tokens — it fails rather than orphaning tokens. Clean up sessions only after every token referencing them is closed. Errors: EPERM (SeTcbPrivilege missing), ENOENT (no such session), EBUSY (live tokens, linked-pair state, or in-flight references).

3.6.1 Listing sessions #

struct peios_logon_session {
    uint64_t       logon_session_id;  /* a token's auth_id */
    uint64_t       created_at;        /* seconds since the Unix epoch */
    uint32_t       logon_type;        /* KACS_LOGON_TYPE_* */
    uint32_t       user_sid_len;
    const uint8_t *user_sid;          /* binary SID */
    const char    *auth_package;      /* UTF-8, not NUL-terminated */
    uint32_t       auth_package_len;
    uint32_t       reserved;
};

peios_logon_sessions *peios_logon_sessions_open(void);
int  peios_logon_sessions_next(peios_logon_sessions *s, struct peios_logon_session *out);
void peios_logon_sessions_close(peios_logon_sessions *s);

The kernel's own list of every live logon session, from /sys/kernel/security/kacs/sessions. Reading it needs BUILTIN\Administrators or SYSTEM.

  • peios_logon_sessions_open takes the whole listing at once, so a walk sees one moment. NULL with errno on failure: EACCES (not an administrator), ENOENT (securityfs not mounted), ENOMEM.
  • peios_logon_sessions_next fills out with the next session and returns 1, or 0 when there are no more. The pointers in out stay valid until the next call. -1 with EPROTO means a line it could not read; call again to step past it.
  • peios_logon_sessions_close frees the reader.

A session is listed while anything holds it, which includes a session nothing has used yet, so a listing can hold sessions with no process in them. SYSTEM's session is 999. To find a session's processes, read each process's token's auth_id (TokenStatistics) and match it.

peios_logon_sessions *s = peios_logon_sessions_open();
struct peios_logon_session one;
while (s && peios_logon_sessions_next(s, &one) == 1)
    printf("%llu type %u\n", (unsigned long long)one.logon_session_id, one.logon_type);
peios_logon_sessions_close(s);

3.7 The generic mapping

Peios / Developing for Peios / SDK Reference / token.h — Tokens

extern const struct kacs_generic_mapping peios_token_generic_mapping;

The canonical generic→specific rights mapping for the token object class. Pass it to peios_access_map_generic or as the mapping in a peios_access_request when the object under check is a token.

4.1 access.h — Access checks

Peios / Developing for Peios / SDK Reference / access.h — Access Checks

<peios/access.h> answers the central question of the whole access-control model: may this subject perform this access on this object? You hand it a token, a security descriptor, and a desired access mask, and it runs the full KACS AccessCheck pipeline and tells you whether access is granted and exactly which rights were granted.

Two things are worth saying up front:

  • These calls are advisory. They evaluate, they do not enforce. peios_access_check tells you what the answer would be; enforcement of a real operation always runs inside the kernel against the subject's own process security block. Use these when your code is the resource manager — you hold an object, you have its security descriptor, and you need to make the grant/deny decision yourself.
  • A denial is a normal result, not an error. Per the library conventions, a denied check returns -1 with errno == EACCES, and the granted mask is still written out. Only a genuine failure (a bad token fd, a malformed SD) is an error in the usual sense.

4.1.1 See also #

  • <peios/security.h> — building the security descriptors and reading the generic-mapping tables this check consumes.
  • <peios/token.h> — obtaining the token_fd to check, and peios_token_generic_mapping.
  • Access decisions — the operator-side account of how KACS reaches a grant/deny decision.

4.2 The request

Peios / Developing for Peios / SDK Reference / access.h — Access Checks

Every check is described by a single struct peios_access_request. Only the first block is needed for an ordinary check; everything below the divider is advanced and may be left zero/NULL. For every pointer/length pair, NULL is valid only when the matching length or count is zero.

struct peios_access_request {
    int      token_fd;   /* -1 = the caller's effective token */
    const void *sd;      /* the object's security descriptor (wire bytes) */
    size_t   sd_len;
    uint32_t desired;    /* desired access mask */
    struct kacs_generic_mapping mapping;   /* the object class's mapping */

    /* ---- [adv] ---- */
    const void *self_sid;        /* PRINCIPAL_SELF substitution; NULL to omit */
    size_t   self_sid_len;
    uint32_t privilege_intent;   /* backup/restore intent bits */
    const struct kacs_object_type_entry *object_tree;
    uint32_t object_tree_count;
    const void *local_claims;    /* @Local claim array */
    size_t   local_claims_len;
    uint32_t pip_type;           /* 0 = use the subject's PSB */
    uint32_t pip_trust;
    const void *audit_context;   /* the guarded object, a PGSS §6.7 map */
    size_t   audit_context_len;
};

4.2.0.1 The core fields #

FieldMeaning
token_fdThe subject token to evaluate. -1 means the caller's own effective token — the common case when you are checking access for yourself. Otherwise pass a token fd from <peios/token.h>.
sd / sd_lenThe object's security descriptor, as self-relative wire bytes — typically from a peios_sd_builder or read off the object.
desiredThe access mask you want checked. May contain generic bits; the mapping resolves them.
mappingThe object class's generic mapping (a struct kacs_generic_mapping), so generic rights in desired and in the SD's ACEs fold to the right object-specific bits. Use the class's published table — e.g. peios_file_generic_mapping or peios_token_generic_mapping.

4.2.0.2 The advanced fields #

Leave these zero/NULL unless you need them:

FieldMeaning
self_sid / self_sid_lenThe SID to substitute for PRINCIPAL_SELF (S-1-5-10) in ACEs — the "self" the object belongs to.
privilege_intentBackup/restore intent bits, letting SeBackupPrivilege / SeRestorePrivilege widen the granted mask as they would for a real backup or restore.
object_tree / object_tree_countAn object-type tree for a per-property check (object ACEs with type GUIDs). Mandatory for peios_access_check_list.
local_claims / local_claims_lenAn @Local claim array to evaluate conditional ACEs against, beyond the claims already on the token.
pip_type / pip_trustProcess-integrity-protection trust label to evaluate against; pip_type == 0 uses the subject's own PSB.
audit_context / audit_context_lenThe identity of the object a daemon is guarding, for the audit record of the check: one MessagePack map holding kind and, under a key equal to the kind, the object's identifying fields — {kind: "service", service: {name: "jellyfin"}} — as PGSS §6.7 defines. The kernel accepts only that shape and fails the check with EINVAL otherwise. It copies the map into the kacs.audit.access.checked record as object.kind and object.<kind>.*, and marks the record fields.attestation.userspace, because the values are the caller's claim. Build it with peios_audit_context_encode. Both check calls validate a non-NULL context before the syscall and fail with EINVAL if the kernel would refuse it.

4.3 The check

Peios / Developing for Peios / SDK Reference / access.h — Access Checks

int peios_access_check(const struct peios_access_request *req,
                       uint32_t *granted, struct peios_access_audit *audit);

Runs the full AccessCheck pipeline. Returns:

  • 0 if every right in desired is granted;
  • -1 with errno == EACCES if any desired right is denied;
  • -1 with another errno on a real error (e.g. EBADF for a bad token_fd, EINVAL for a malformed SD).

granted, if non-NULL, always receives the granted access mask — even on denial. This is the useful part: you can request a broad desired and read back exactly which subset was granted, rather than probing one right at a time. audit, if non-NULL, receives the audit outputs.

struct peios_access_request req = {
    .token_fd = -1,                      /* my own effective token */
    .sd = sd_bytes, .sd_len = sd_len,
    .desired = KACS_ACCESS_READ | KACS_ACCESS_WRITE,
    .mapping = peios_file_generic_mapping,
};

uint32_t granted = 0;
int rc = peios_access_check(&req, &granted, NULL);
if (rc == 0) {
    /* both READ and WRITE granted */
} else if (errno == EACCES) {
    /* denied; `granted` shows what WAS allowed (maybe READ only) */
} else {
    /* error: perror("access_check") */
}

libpeios owns the versioned struct kacs_access_check_args under the hood — it sets caller_size and zeroes the reserved fields so the request stays forward-compatible across kernel versions. You only ever fill in the peios_access_request above.

4.4 Audit outputs

Peios / Developing for Peios / SDK Reference / access.h — Access Checks

struct peios_access_audit {
    uint32_t continuous_audit;   /* OR of matching alarm masks */
    int      staging_mismatch;   /* 1 if the staged CAAP result differs */
};

When you pass a non-NULL audit, the check reports:

  • continuous_audit — the OR of the masks of any SYSTEM_ALARM ACEs that matched. An enforcement point that keeps this mask on a handle emits a kacs.audit.handle.used event for each later operation whose required access overlaps it.
  • staging_mismatch — 1 if evaluating the staged central access policy would have produced a different result than the active one. This is the signal you watch when rolling out a central access policy change: a non-zero value means the pending policy would decide this access differently. The kernel records the same difference as a kacs.caap.staging.diverged event.

These are the outputs the check hands back to you. The audit records it writes to the event stream — kacs.audit.access.checked and kacs.audit.privilege.used — go to KMES, not to this struct. They name the object you checked only if the request carried an audit context, the map {kind: "<kind>", "<kind>": {…}}; anything else in that field fails the check with EINVAL.

4.5 The object-type-list variant

Peios / Developing for Peios / SDK Reference / access.h — Access Checks

int peios_access_check_list(const struct peios_access_request *req,
                            struct kacs_node_result *results, uint32_t count);

peios_access_check_list is the AccessCheckByTypeResultList form — a per-node check over an object-type tree, for objects whose properties or property sets carry their own object ACEs (a directory-service-style object, say). It evaluates the whole tree in one call and reports a separate result for each node.

  • req->object_tree / object_tree_count are mandatory here — they describe the tree of kacs_object_type_entry nodes to evaluate.
  • results receives one kacs_node_result per node, in preorder, and count must equal req->object_tree_count.
  • Returns 0 / -1 (EINVAL if count doesn't match, and the usual errors otherwise).

Each kacs_node_result carries that node's granted mask and status, so you can discover, for example, that a caller may read most of an object but not one protected property — in a single check rather than one per property.

4.6 The audit context

Peios / Developing for Peios / SDK Reference / access.h — Access Checks

A daemon that guards objects of its own (a service manager's services, an event store's namespaces) makes each access decision through peios_access_check. The kernel records that decision in a kacs.audit.access.checked event when the descriptor's SACL asks for one, but the kernel cannot tell what the descriptor belongs to. The audit context tells it. It is the request's audit_context field: one MessagePack map holding kind and, under a key equal to the kind, the fields that identify the object.

{kind: "service", service: {name: "jellyfin"}}

The kernel copies it into the record as object.kind and object.service.name, and marks the record fields.attestation.userspace, because the kernel did not observe those values: they are the daemon's claim. It refuses any other shape with EINVAL. PGSS §6.7 is the specification. A component that guards objects must pass one, and each field it names must be defined, as asserted, in that component's catalogue fragment.

4.6.1 Encoding one #

enum peios_audit_value_type {
    PEIOS_AUDIT_STR  = 0,   /* bytes/len: a UTF-8 string, not NUL-terminated */
    PEIOS_AUDIT_UINT = 1,   /* scalar */
    PEIOS_AUDIT_INT  = 2,   /* scalar, read as a two's-complement int64_t */
    PEIOS_AUDIT_BOOL = 3,   /* scalar: 0 or 1 */
    PEIOS_AUDIT_BIN  = 4,   /* bytes/len: binary, e.g. a SID or GUID */
};

struct peios_audit_field {
    const char *key;         /* NUL-terminated; one kebab-case segment */
    uint32_t    value_type;  /* enum peios_audit_value_type */
    uint64_t    scalar;
    const void *bytes;
    size_t      len;
};

ssize_t peios_audit_context_encode(const char *kind,
                                   const struct peios_audit_field *fields,
                                   size_t count, void *buf, size_t cap);

peios_audit_context_encode writes the map for an object of kind kind, identified by count fields, using the two-call protocol: pass cap == 0 to learn the size. With no fields (fields may then be NULL), the context is {kind: <kind>}.

kind and every key must be a single segment of an event name: kebab-case, [a-z][a-z0-9]*(-[a-z0-9]+)*, so service, log-namespace or unit-name. A value is a scalar in one of the event wire forms: a string, an unsigned or signed integer, a boolean, or binary. A SID goes in as PEIOS_AUDIT_BIN with its binary bytes, never as SDDL text. There is no nil: a field with no value is left out of the array.

struct peios_audit_field fields[] = {
    { .key = "name", .value_type = PEIOS_AUDIT_STR,
      .bytes = "jellyfin", .len = strlen("jellyfin") },
};
uint8_t ctx[128];
ssize_t n = peios_audit_context_encode("service", fields, 1, ctx, sizeof ctx);
if (n < 0) { perror("audit context"); return -1; }

struct peios_access_request req = {
    .token_fd = caller_fd,
    .sd = sd_bytes, .sd_len = sd_len,
    .desired = SERVICE_START,
    .mapping = service_mapping,
    .audit_context = ctx, .audit_context_len = (size_t)n,
};
uint32_t granted = 0;
int rc = peios_access_check(&req, &granted, NULL);

The encoder fails with EINVAL when:

  • the kind or a key is not a segment, or a key is NULL;
  • a key appears twice;
  • value_type is not one of the five, or a boolean's scalar is not 0 or 1;
  • a string is not valid UTF-8, or bytes is NULL while len is not zero;
  • the encoded map would be longer than KACS_ACCESS_CHECK_MAX_AUDIT_CONTEXT_LEN (4096 bytes).

The kernel itself allows a nil value, a nested value, or a key that appears twice inside the body. The encoder refuses all three. A field with no value is left out (PGSS §6.5), an identifying field is a scalar, and a repeated key would make object.<kind>.<key> ambiguous.

4.6.2 Validating one built another way #

int peios_audit_context_validate(const void *buf, size_t len);

If you build the map yourself, for example with the msgpack writer, peios_audit_context_validate checks it against the kernel's own rules. It returns 0 if the kernel would accept it, and -1 with EINVAL otherwise, including for NULL or empty input. The rules are:

  • the top level is a map holding a string kind that is one segment, and at most one other key;
  • that key, if present, equals the kind, and its value is a non-empty map whose keys are segments;
  • nothing follows the map;
  • the bytes are well-formed MessagePack (UTF-8 strings, no reserved 0xc1 byte), nested no deeper than 31 levels. That is the event nesting limit less one, because the context lands one level below the record's root;
  • the map is at most 4096 bytes.

peios_access_check and peios_access_check_list apply the same check to any non-NULL audit_context before they make the syscall. A malformed context therefore fails there with EINVAL, without reaching the kernel. A non-NULL pointer with a zero length is refused too, as the kernel refuses it.

4.6.3 From Rust and Go #

The peios crate builds the same map with an AuditContext and hands it to the AccessCheck builder. Field values convert from &str, the integer types, bool, &[u8] and &SidRef:

use peios::access::{AccessCheck, AuditContext};

let ctx = AuditContext::new("service", &[("name", "jellyfin".into())])?;
let decision = AccessCheck::new(&sd, desired, mapping)
    .token(caller.as_fd())
    .audit_context(&ctx)
    .check()?;

AuditContext::new fails with EINVAL for the same reasons as the C encoder. AuditContext::from_bytes adopts a map you encoded yourself, after validating it.

In Go, libp-go's sd package encodes the map for CheckRequest.AuditContext:

ctx, err := sd.EncodeAuditContext("service", sd.AuditString("name", "jellyfin"))
if err != nil {
    return err
}
dec, err := sd.Check(sd.CheckRequest{
    Token:         caller,
    SD:            sdBytes,
    DesiredAccess: serviceStart,
    Mapping:       serviceMapping,
    AuditContext:  ctx,
})

The field constructors are AuditString, AuditUint, AuditInt, AuditBool, AuditBytes and AuditSID. A refusal wraps sd.ErrBadAuditContext. sd.ValidateAuditContext checks a map built by hand, and Check and CheckList run that check on a non-empty AuditContext before the syscall.

5.1 file.h — File security

Peios / Developing for Peios / SDK Reference / file.h — File Security

<peios/file.h> is the file surface of KACS. Where ordinary POSIX open() gives you a file descriptor governed by mode bits, peios_file_open performs a native KACS open — an NtCreateFile-shaped call carrying a desired access mask, a create disposition, create options, and an optional creator security descriptor — and hands back an ordinary Linux file fd whose granted access mask is fixed for the fd's lifetime. Because the grant is baked into the fd, it can be delegated safely by dup, SCM_RIGHTS, or across exec: whoever holds the fd holds exactly the access it was opened with, no more.

Alongside the open, this module reads and writes a file's security descriptor (by path or by fd) and governs how a superblock without native SD storage is treated.

The wire constants (KACS_DISPOSITION_*, KACS_CREATE_OPT_*, KACS_FILE_*, KACS_SECINFO_*, KACS_MOUNT_POLICY_*, KACS_STATUS_*) come from <pkm/file.h> and <pkm/sd.h>. The security descriptors these calls exchange are built and parsed with <peios/security.h>.

5.1.1 See also #

  • <peios/security.h> — building the creator SDs and parsing the SDs these calls return.
  • <peios/access.h> — evaluating a file SD with peios_file_generic_mapping.
  • File access and Mount policies — the operator-side model of native file security.

5.2 Opening a file

Peios / Developing for Peios / SDK Reference / file.h — File Security

Important

The open-interface comparison records unresolved differences and the limited pinned-source findings applied here. Creator-SD rejection, the accepted create-option bits, and native-open versus SD-query no-follow behavior were checked in source; the other differences remain unresolved. No runtime test or historical release boundary is established.

struct peios_open_params {
    uint32_t    desired_access; /* KACS_FILE_* | standard | generic (strict-mode) */
    uint32_t    disposition;    /* KACS_DISPOSITION_* */
    uint32_t    options;        /* KACS_CREATE_OPT_* */
    uint32_t    flags;          /* AT_SYMLINK_NOFOLLOW | KACS_BACKUP_INTENT | KACS_RESTORE_INTENT */
    const void *sd;             /* creator SD on create, else NULL */
    size_t      sd_len;
};

int peios_file_open(int dirfd, const char *path,
                    const struct peios_open_params *p, uint32_t *status_out);

peios_file_open opens path relative to dirfd (the usual *at convention — an absolute path ignores dirfd, and AT_FDCWD means the current directory). It returns a file fd, or -1 with errno.

The parameters:

FieldMeaning
desired_accessThe access mask you are requesting — KACS_FILE_* object rights, standard rights, or (in strict mode) generic bits the file class maps. The granted subset is what the returned fd is fixed at.
dispositionWhat to do about existence: KACS_DISPOSITION_* — open-existing, create-new, open-or-create, supersede, overwrite, and so on. This is the create/open decision open() splits across O_CREAT/O_EXCL/O_TRUNC.
optionsAt kernel 8e0e22de3a59cad506bbbf8873de456e16ad272d, only KACS_CREATE_OPT_DIRECTORY and KACS_CREATE_OPT_DELETE_ON_CLOSE are accepted; other bits fail with EINVAL. No-follow belongs in flags, not options.
flagsAT_SYMLINK_NOFOLLOW, plus the privilege-intent flags KACS_BACKUP_INTENT / KACS_RESTORE_INTENT that let SeBackupPrivilege / SeRestorePrivilege widen the access the open is granted.
sd / sd_lenThe creator security descriptor — the SD to stamp on a newly created file. Pass NULL when opening an existing file (or to let the parent's inheritance decide the new file's SD).

status_out, if non-NULL, receives a KACS_STATUS_* code telling you what happened — whether the file was opened, created, superseded, overwritten. For OPEN_IF with no creator SD (sd = NULL, sd_len = 0), this distinguishes "created a new file" from "opened the existing one" without a separate stat race. Supplying a creator SD changes the existing-file behavior described below.

Errors include EACCES (a requested right denied — strict mode), EEXIST (create-new and the file exists), ENOENT (open-existing and it doesn't), ENOTDIR (directory option, non-directory target), ELOOP (native open with no-follow and a final symlink), EINVAL (MAXIMUM_ALLOWED without a concrete data/execute bit, unsupported create-option bits, malformed creator SD, NULL path/p, sd == NULL with sd_len != 0), and EBADF (bad dirfd).

At the pinned kernel revision, supplying a creator SD with OPEN fails with EOPNOTSUPP, and with OVERWRITE fails with EINVAL. With OPEN_IF or OVERWRITE_IF, it fails with EINVAL when the path resolves to an existing object. The SDK forwards the disposition and SD; it does not silently drop the SD on that branch. See the source findings. Use create-only when an explicit creator SD is required:

struct peios_open_params p = {
    .desired_access = KACS_FILE_READ_DATA | KACS_FILE_WRITE_DATA,
    .disposition    = KACS_DISPOSITION_CREATE,    /* fail if the path exists */
    .options        = 0,
    .sd             = creator_sd, .sd_len = creator_sd_len,
};
uint32_t status = 0;
int fd = peios_file_open(AT_FDCWD, "data.bin", &p, &status);
if (fd < 0) { /* errno */ }
/* On success, status == KACS_STATUS_CREATED. */

To open an existing object, use KACS_DISPOSITION_OPEN with sd = NULL and sd_len = 0. If create-only returns EEXIST, decide whether opening the existing object is appropriate before issuing that separate call. A create/open retry is not an atomic transaction; neither is a stat-then-open check.

At libpeios de0018bdcaed14abb796aeccec9c8387fd30e452, the wrapper copies options to kacs_open_how.create_options and flags to kacs_open_how.flags, forwards the creator SD, zeroes reserved fields, and passes the struct size as a separate syscall argument. This does not make unsupported option bits valid on another kernel.

5.3 Reading and writing a file's security descriptor

Peios / Developing for Peios / SDK Reference / file.h — File Security

A file's SD can be accessed by path or by fd. In both cases secinfo is a mask of KACS_SECINFO_* bits selecting which components (owner, group, DACL, SACL, …) the operation touches — you read or write just the parts you name and leave the rest alone.

The rights required scale with the components you touch (see Managing file security):

Component (KACS_SECINFO_*)Reading needsWriting needs
OWNER / GROUPREAD_CONTROLWRITE_OWNER (plus owner-SID validation)
DACLREAD_CONTROLWRITE_DAC
SACLACCESS_SYSTEM_SECURITYACCESS_SYSTEM_SECURITY
LABELREAD_CONTROLWRITE_OWNER (the label cannot rise above the caller's integrity without SeRelabelPrivilege)

ACCESS_SYSTEM_SECURITY is itself gated by SeSecurityPrivilege; READ_CONTROL and WRITE_DAC are implicitly granted to the owner. SACL and LABEL cannot be combined in one call (EINVAL). The check is all-or-nothing: if any requested component fails its check, the whole call fails.

5.3.0.1 By path #

ssize_t peios_file_get_sd(int dirfd, const char *path, uint32_t secinfo,
                          void *buf, size_t cap, uint32_t at_flags);
int     peios_file_set_sd(int dirfd, const char *path, uint32_t secinfo,
                          const void *sd, size_t len, uint32_t at_flags);
  • peios_file_get_sd reads the secinfo-selected components of path's SD into buf, getxattr-style (two-call protocol — probe with cap == 0, and a too-small non-zero buffer fails ERANGE without truncating). at_flags accepts AT_SYMLINK_NOFOLLOW; at the pinned source below, it queries the terminal symlink's own SD instead of following it. Errors: EACCES (component right missing), EINVAL (SACL + LABEL together; NULL path, or NULL buffer with non-zero cap), ERANGE (non-probe buffer too small), ENOENT (path doesn't exist), ELOOP (a path-resolution failure, not a blanket rejection of a terminal symlink under no-follow).
  • peios_file_set_sd writes the secinfo components of sd onto path, preserving the components you did not select. So to change only the DACL, build an SD with a DACL, pass secinfo = KACS_SECINFO_DACL, and the owner/group/SACL are untouched. Errors: EACCES (component right missing), EPERM (owner-SID validation failed without SeRestorePrivilege; label raised without SeRelabelPrivilege; MANDATORY attribute removed without SeTcbPrivilege), EINVAL (malformed SD, SACL + LABEL together, NULL or zero-length sd), ENOENT, ELOOP.

Important

No-follow has different meanings for these two operations: native open rejects a terminal symlink with ELOOP, while the by-path SD query selects the link itself. This is verified in kernel 8e0e22de3a59cad506bbbf8873de456e16ad272d and its path resolver. The libpeios 0.5.8 query wrapper at de0018bdcaed14abb796aeccec9c8387fd30e452 forwards at_flags; it does not turn no-follow into a link rejection. The link-query test definition is supporting source evidence, not a test run. This query finding does not establish a successful link-SD write: set-security has separate rights, validation and storage requirements. No deployed version or historical release boundary was verified.

5.3.0.2 By fd #

ssize_t peios_fd_get_sd(int fd, uint32_t secinfo, void *buf, size_t cap);
int     peios_fd_set_sd(int fd, uint32_t secinfo, const void *sd, size_t len);

The same operations against the object fd already refers to. The access check they perform depends on the fd type: a normal file fd is checked against its cached granted mask (the one baked in at open), while an O_PATH, pidfd, or token fd triggers a live check. That distinction — cached for the fixed-grant file fd, live for the others — is documented in the Peios Kernel TRM §3.9, FACS; the practical upshot is that a file fd already opened with the right access can get/set its SD without a second path resolution.

The required rights and errors match the by-path calls, minus the path-resolution failures (ENOENT/ELOOP), plus EBADF (bad fd).

5.3.0.3 By System V IPC kind and id #

ssize_t peios_sysv_get_sd(uint32_t kind, int id, uint32_t secinfo,
                          void *buf, size_t cap);
int     peios_sysv_set_sd(uint32_t kind, int id, uint32_t secinfo,
                          const void *sd, size_t len);

System V message queues, shared memory segments and semaphore arrays carry security descriptors too (Peios Kernel TRM §3.11), but have neither a path nor an fd. kind is one of KACS_SD_AT_SYSV_SHM, KACS_SD_AT_SYSV_MSG or KACS_SD_AT_SYSV_SEM from <pkm/ipc.h>, and id is what shmget, msgget or semget returned. The object is looked up in the caller's IPC namespace and the check is always live against its descriptor: READ_CONTROL (or ACCESS_SYSTEM_SECURITY for the SACL) to read, WRITE_DAC / WRITE_OWNER to write, with the same preserve-unselected-components merge as the file calls. Errors as for the by-fd calls, with EINVAL for an id that names nothing and EIDRM for an object that has been removed. The Rust crate exposes the same pair as file::sysv_get_sd / file::sysv_set_sd with a SysvKind enum.

5.4 Mount policy

Peios / Developing for Peios / SDK Reference / file.h — File Security

Not every filesystem can store native security descriptors. The mount policy governs how KACS treats a superblock that has no native SD storage — whether files there get a synthesised SD, a template SD, or are denied. These calls target the superblock the object fd lives on. At the pinned kernel revision below, both require enabled SeManageVolumePrivilege or SeTcbPrivilege, with successful privilege-use marking.

Important

The gate is verified in kernel 8e0e22de3a59cad506bbbf8873de456e16ad272d, for both reads and writes. The libpeios 0.5.8 wrappers at de0018bdcaed14abb796aeccec9c8387fd30e452 pass through to those syscalls; their TCB-only comments do not enforce an additional privilege gate. No runtime test or release boundary is established here. See operator policy-access checks before changing privileges.

struct peios_mount_policy {
    uint32_t    policy;      /* KACS_MOUNT_POLICY_* */
    uint32_t    flags;
    uint32_t    generation;
    const void *template_sd;
    size_t      template_sd_len;
};

int peios_mount_get_policy(int fd, struct peios_mount_policy *out,
                           void *tmpl_buf, size_t tmpl_cap);
int peios_mount_set_policy(int fd, const struct peios_mount_policy *p);
  • peios_mount_get_policy reads the policy for fd's superblock into out. The template SD is returned into your tmpl_buf getxattr-style: on success out->template_sd points into tmpl_buf when that buffer was large enough, or is NULL if the superblock has no template. A NULL template buffer (or tmpl_cap == 0) is valid only when you don't need the template bytes. A too-small template buffer is not an error — the call still succeeds, reports the true length in out->template_sd_len, and leaves out->template_sd NULL so you can size a retry. Errors: EPERM (neither accepted privilege enabled, or privilege-use marking failed), EBADF (bad fd), EINVAL (NULL out, or NULL tmpl_buf with non-zero tmpl_cap), EFAULT (bad buffer pointer), ENOMEM (allocation failed).
  • peios_mount_set_policy installs p as the superblock's policy. policy is a KACS_MOUNT_POLICY_* value; template_sd/template_sd_len supply the template SD when the policy calls for one. flags and generation must be zero on set — the kernel manages the generation counter itself and rejects a non-zero input. Errors: EPERM (neither accepted privilege enabled, or privilege-use marking failed), EINVAL (unknown or unmanaged policy, non-zero flags/generation, malformed or oversized template, NULL template with non-zero length), EOPNOTSUPP (superblock not KACS-managed), EBADF (bad fd), EFAULT (bad pointer).

5.5 The generic mapping

Peios / Developing for Peios / SDK Reference / file.h — File Security

extern const struct kacs_generic_mapping peios_file_generic_mapping;

The canonical generic→specific rights mapping for the file object class. Pass it to peios_access_map_generic, or as the mapping in a peios_access_request when checking access against a file's SD — for example to pre-flight whether a caller could open a file before you actually open it.

6.1 process.h — Process security

Peios / Developing for Peios / SDK Reference / process.h — Process Security

<peios/process.h> is the process-security surface of KACS. Today it is a small module with two jobs: turning on process mitigations — the hardening controls that live on a process's security block (PSB) — and reading a process's PSB back. More process-security surface will land here as it appears.

The mitigation bits are the KACS_MIT_* flags from <pkm/psb.h> (KACS_MIT_WXP through KACS_MIT_SML, with KACS_MIT_ALL as the mask of all valid bits). KACS_MIT_CFI is a legacy alias that expands to KACS_MIT_CFIF | KACS_MIT_CFIB. The full catalogue and semantics are in the Peios Kernel TRM §3.3, the Process Security Block.

6.1.1 Setting mitigations #

int peios_process_set_mitigations(int pidfd, uint32_t mitigations);

Turns on the mitigation bits named in mitigations (a mask of KACS_MIT_*). Returns 0 on success, or -1 with errno.

Three properties define how this call behaves, and each matters:

  • It is one-way. Mitigation bits can only be set, never cleared. Once a protection is on, it stays on for the life of the process. This is deliberate — a mitigation you could turn off is a mitigation an attacker could turn off — so treat each call as a permanent, additive commitment.
  • It targets a process by pidfd. pidfd == -1 targets the calling process, which is the common case: a program hardens itself early in startup. Targeting another process requires PROCESS_SET_INFORMATION on it plus PIP dominance over it — you cannot harden (or interfere with) a process you don't already dominate.
  • It is activation-backed and fails closed. If a requested protection cannot actually be activated, the call fails without mutating anything — you never end up believing a mitigation is on when it isn't. Either every requested bit is activated and the call succeeds, or nothing changes and it returns -1.
/* Harden the current process: enforce W^X and shadow-stack, refuse to
   proceed if either can't be activated. */
if (peios_process_set_mitigations(-1, KACS_MIT_WXP | KACS_MIT_SML) != 0) {
    perror("set_mitigations");
    /* nothing was changed; decide whether to continue unhardened or abort */
}

Because the call is all-or-nothing, request the bits you require together and check the result once: a success means the whole set is active, a failure means none of this call's bits were applied (bits set by earlier successful calls remain on).

6.1.2 Reading a PSB #

struct peios_psb {
    uint32_t pip_type;         /* 0 none, 512 Protected */
    uint32_t pip_trust;        /* 8192 PeiosTcb */
    uint32_t mitigations;      /* committed KACS_MIT_* bits */
    uint8_t  process_guid[16];
};

int peios_process_psb(int pid, struct peios_psb *out);

Reads process pid's PSB, from /proc/<pid>/psb, into *out. pid <= 0 reads your own. Returns 0, or -1 with errno.

  • Your own is always readable.
  • Another process's needs PROCESS_QUERY_LIMITED on its descriptor — which the default process descriptor gives Everyone — and not PIP dominance. A protected process's PIP type and trust are therefore readable when nothing else about it is, which is what lets a tool explain why it cannot see more.

mitigations is the committed set: bits a call asked for but could not activate are not in it, and neither is the KACS_MIT_CFI alias. process_guid identifies the process for its whole life, unlike its pid, and is what events carry.

Errors: ENOENT (no such process, or it has exited), EACCES (refused), EPROTO (a line this library cannot read), EINVAL (NULL out).

struct peios_psb psb;
if (peios_process_psb(pid, &psb) == 0 && psb.pip_type != 0)
    printf("%d is protected (trust %u)\n", pid, psb.pip_trust);

6.1.3 See also #

  • <peios/token.h> — PIP dominance is determined by the subject's token; process targeting other than self depends on it.
  • Process mitigations — the operator-side account of each mitigation and what it defends against.

7.1 registry.h — The registry (LCS)

Peios / Developing for Peios / SDK Reference / registry.h — The Registry

<peios/registry.h> is the client surface of LCS — the Layered Configuration Subsystem, Peios's kernel-mediated registry. LCS is modelled on the Windows registry: a hierarchy of keys (each with an immutable GUID identity and secured by its own KACS security descriptor) holding typed values. Its distinguishing feature is layers: every write is tagged with a precedence-ordered layer, and the effective view of a value resolves to the highest-precedence entry. That is what lets a base configuration, a site overlay, and a machine-local override coexist on one key and resolve deterministically.

This header is the registry client: open keys, read and write values, enumerate, watch, secure, back up, and run transactions. It does not cover the registry source (the storage backend) side — REG_SRC_REGISTER and the RSI framed protocol — which is a separate library, librsi. A client speaks only the syscalls and ioctls here.

Handles are fds. Three calls create file descriptors — peios_reg_open_key, peios_reg_create_key, and peios_reg_begin_transaction; everything else is an operation on a key fd or transaction fd, gated on the access right granted when the key was opened. The wire constants — value types (REG_SZ … REG_QWORD), key access rights (KEY_*), open/create flags, transaction states (REG_TXN_*), watch filters (REG_NOTIFY_*), and security-info bits — come from <pkm/lcs.h>.

7.1.1 See also #

  • <peios/security.h> — building and parsing the SDs that secure keys.
  • Library conventions — the base error and buffer rules the descriptor reads specialise.
  • The registry — the operator-side model of layers, hives, and precedence.

7.2 The buffer convention here

Peios / Developing for Peios / SDK Reference / registry.h — The Registry

Most of libpeios returns variable-length data with an ssize_t and the two-call protocol. The registry's reads use the same idea but express it through descriptor structs rather than a return value, because a single read often fills more than one buffer (a value's data and its layer name, say). The pattern:

  • Each read takes a descriptor struct with *_cap fields (in) and *_len fields (out), plus buffer pointers.
  • On success it returns 0 and writes the actual length into each *_len.
  • If a buffer is too small it returns -1 with errno == ERANGE and writes the required length into the matching *_len — so a zero-capacity buffer probes the size.
  • A NULL buffer is valid only with zero capacity; NULL with a nonzero capacity is EINVAL.
  • For a read with two buffers, ERANGE is returned if either is too small, and both required lengths are reported, so one probe sizes everything.

Everything else follows the usual Linux convention: 0 / -1 + errno.

7.3 Opening and creating keys

Peios / Developing for Peios / SDK Reference / registry.h — The Registry

int peios_reg_open_key(int parent_fd, const char *path, uint32_t desired_access,
                       uint32_t flags);
int peios_reg_create_key(int parent_fd, const char *path, uint32_t desired_access,
                         uint32_t flags, const char *layer, int txn_fd,
                         uint32_t *disposition_out);

Both resolve path (NUL-terminated) against parent_fd — a key fd for a relative path, or < 0 for an absolute path — and return a key fd whose granted access mask is fixed for its lifetime (like a file fd, so it can be delegated). desired_access is the requested KEY_* rights, checked against the key's SD.

  • peios_reg_open_key opens an existing key. flags may be REG_OPEN_LINK to open a symlink key itself rather than following it. Errors: ENOENT, EACCES, EINVAL, ELOOP, ENAMETOOLONG, ETIMEDOUT, EIO, ENOMEM.
  • peios_reg_create_key opens an existing key or creates a new one. flags may combine REG_OPTION_VOLATILE (a key that does not survive reboot) and REG_OPTION_CREATE_LINK (create a symlink key). layer names the target layer to create in (NUL-terminated), or NULL for the base layer. txn_fd enlists the create in a transaction, or -1 to auto-commit. disposition_out, if non-NULL, receives REG_CREATED_NEW or REG_OPENED_EXISTING. Errors add ENOSPC and EPERM (privileged symlink creation) to the set above.

7.4 Values

Peios / Developing for Peios / SDK Reference / registry.h — The Registry

A value is named (length-counted; an empty name is the key's default value), typed (REG_*), and written into a layer. A base-layer target is layer == NULL with layer_len == 0; a non-NULL pointer with a zero length is rejected EINVAL.

7.4.0.1 Reading a value #

struct peios_reg_value {
    uint64_t sequence;  /* out: effective entry's sequence number */
    void    *data;      /* in:  buffer for the value data (NULL to probe) */
    void    *layer;     /* in:  buffer for the layer name (NULL to probe/skip) */
    uint32_t type;      /* out: value type (REG_*) */
    uint32_t data_cap;  /* in */   uint32_t data_len;  /* out: actual/required */
    uint32_t layer_cap; /* in */   uint32_t layer_len; /* out: actual/required */
};

int peios_reg_query_value(int key_fd, const void *name, uint32_t name_len, int txn_fd,
                          struct peios_reg_value *v);

peios_reg_query_value reads the effective value name on key_fd — the winner of the layer precedence resolution. name_len == 0 reads the default value; txn_fd reads within a transaction, or -1 for none. It fills v->data with the value bytes and v->layer with the name of the layer that won, and reports the resolved type and sequence. Pass a NULL layer buffer if you don't care which layer won. Errors: ENOENT (no effective value, or a tombstone masks it), ERANGE, EACCES, EINVAL.

7.4.0.2 Writing, deleting, tombstoning #

int peios_reg_set_value(int key_fd, const void *name, uint32_t name_len, uint32_t type,
                        const void *data, uint32_t data_len, const void *layer,
                        uint32_t layer_len, int txn_fd, uint64_t expected_seq);
int peios_reg_delete_value(int key_fd, const void *name, uint32_t name_len,
                           const void *layer, uint32_t layer_len, int txn_fd);
int peios_reg_blanket_tombstone(int key_fd, const void *layer, uint32_t layer_len,
                                int set, int txn_fd);
  • peios_reg_set_value writes value name of type into a specific layer (NULL/0 = base). type may be REG_TOMBSTONE to place a per-value tombstone that masks lower layers. expected_seq is a compare-and-swap guard: 0 disables it; otherwise the write applies only if the value's current sequence matches, else EAGAIN. This is how you do lost-update-safe read-modify-write — read the sequence from peios_reg_query_value, then set with expected_seq set to it. Errors: EINVAL, EAGAIN, ENOSPC, ENAMETOOLONG, EPERM, EACCES.
  • peios_reg_delete_value removes a layer's entry for name (NULL/0 = base). It is idempotent, and removing a layer's entry lets any lower-layer value re-emerge — deletion is per-layer, not global.
  • peios_reg_blanket_tombstone sets (set != 0) or clears (set == 0) a blanket tombstone on a layer, masking all lower-precedence values of this key on that layer at once — the wholesale version of a per-value tombstone. set must be 0 or 1 (else EINVAL).

7.4.0.3 Enumerating values #

int peios_reg_query_values_batch(int key_fd, int txn_fd, void *buf, uint32_t cap,
                                 uint32_t *len_out, uint32_t *count_out);

struct peios_reg_enum_value {
    void    *name;      /* in:  buffer for the value name (NULL to probe) */
    void    *data;      /* in:  buffer for the value data (NULL to probe) */
    uint32_t type;      /* out */
    uint32_t name_cap;  /* in */  uint32_t name_len;  /* out: actual/required */
    uint32_t data_cap;  /* in */  uint32_t data_len;  /* out: actual/required */
};
int peios_reg_enum_value(int key_fd, uint32_t index, int txn_fd,
                         struct peios_reg_enum_value *v);

Two ways to read every effective value of a key:

  • peios_reg_query_values_batch reads them all into one buf in a single call — the efficient path. Each record is packed little-endian, back to back: [name_len: u32][name][type: u32][data_len: u32][data], for count records. len_out receives the bytes written (or the required size on ERANGE); count_out receives the record count. Both may be NULL.
  • peios_reg_enum_value reads one value at a time by index, dense over the key's tombstone-resolved values — walk from 0 until ENOENT. Use it when you want to process values incrementally rather than buffer them all.

7.5 Subkeys, metadata, and watches

Peios / Developing for Peios / SDK Reference / registry.h — The Registry

7.5.0.1 Enumerating subkeys #

struct peios_reg_subkey {
    void    *name;             /* in:  buffer for the child's name (NULL to probe) */
    uint64_t last_write_time;  /* out: ns since the Unix epoch */
    uint32_t name_cap;         /* in */  uint32_t name_len;    /* out */
    uint32_t subkey_count;     /* out: the child's subkey count */
    uint32_t value_count;      /* out: the child's value count */
};
int peios_reg_enum_subkey(int key_fd, uint32_t index, int txn_fd,
                          struct peios_reg_subkey *v);

peios_reg_enum_subkey reads the child key at index, dense over visible children — walk from 0 until ENOENT. There is no per-child access check during enumeration (you see the names and counts; opening a child still checks its SD).

7.5.0.2 Key metadata #

struct peios_reg_key_info {
    void    *name;                 /* in:  buffer for the key's leaf name (NULL to probe) */
    uint64_t last_write_time;      /* out */
    uint64_t hive_generation;      /* out: per-hive change epoch */
    uint32_t name_cap;  uint32_t name_len;
    uint32_t subkey_count;         /* out */
    uint32_t value_count;          /* out */
    uint32_t max_subkey_name_len;  /* out */
    uint32_t max_value_name_len;   /* out */
    uint32_t max_value_data_size;  /* out */
    uint32_t sd_size;              /* out: security-descriptor size */
    uint8_t  volatile_key;         /* out: 1 if volatile */
    uint8_t  symlink;              /* out: 1 if a symlink */
};
int peios_reg_query_key_info(int key_fd, struct peios_reg_key_info *v);

peios_reg_query_key_info reads the key's leaf name and its metadata (needs READ_CONTROL). Note the ordering wrinkle: the kernel reports the metadata only once the name fits, so a too-small (or zero-capacity) name buffer returns ERANGE with the required name_len and no metadata — size the name buffer from that, then call again to get everything. The max_* fields are sizing hints for enumerations; hive_generation is a per-hive change epoch you can watch to detect that anything under the hive changed.

7.5.0.3 Deleting and hiding keys #

int peios_reg_delete_key(int key_fd, const void *layer, uint32_t layer_len, int txn_fd);
int peios_reg_hide_key(int key_fd, const void *layer, uint32_t layer_len, int txn_fd);

Both need DELETE access, take a layer (NULL/0 = base) and an optional txn_fd, and cannot target a hive root (EINVAL).

  • peios_reg_delete_key removes this key's path entry in a layer; lower-layer entries re-emerge. It fails with ENOTEMPTY if the key has visible children.
  • peios_reg_hide_key creates a HIDDEN path entry that masks the key in a layer; removing that layer makes the key reappear. This is the key-level analogue of a tombstone — hide rather than destroy.

7.5.0.4 Watching for changes #

int peios_reg_notify(int key_fd, uint32_t filter, int subtree);
int peios_reg_flush(int key_fd);
  • peios_reg_notify arms change watches on key_fd (needs KEY_NOTIFY). filter is a mask of REG_NOTIFY_VALUE / REG_NOTIFY_SUBKEY / REG_NOTIFY_SD (or REG_NOTIFY_ALL); subtree (0/1) extends the watch to descendants. filter == 0 disarms. Once armed, the key fd itself becomes pollable — EPOLLIN signals pending events, and read() on the fd returns the change records. So a watch integrates directly into an epoll loop with no side channel. Errors: ENOENT (orphaned key), EINVAL, EACCES.
  • peios_reg_flush forces the source to persist this key's hive's pending writes (needs KEY_SET_VALUE) and returns once persistence is confirmed — the durability barrier.

The change records. A read() on an armed key fd returns as many complete records as fit in your buffer — records are never split across reads. If the buffer is too small for even the next record the read fails EINVAL (so size it generously — a few KiB), and a non-blocking fd with nothing pending fails EAGAIN. Each record is a little-endian, possibly unaligned byte stream (Peios Kernel TRM §5.6, Watches, with the header offsets in §5.A):

OffsetSizeFieldMeaning
04total_lenRecord size in bytes — advance by this to the next record (future versions may append fields).
42event_typeREG_WATCH_VALUE_SET / _VALUE_DELETED / _SUBKEY_CREATED / _SUBKEY_DELETED / _SD_CHANGED / _KEY_DELETED / _OVERFLOW.
62name_lenByte length of name; 0 for the no-name events (SD_CHANGED, KEY_DELETED, OVERFLOW).
8name_lennameThe changed value or subkey name (UTF-8, not NUL-terminated).

A subtree watch appends two further fields after name: path_depth (u16) and that many length-prefixed path components (u16 length + UTF-8 bytes), locating the changed key relative to the watched key — depth 0 means the watched key itself.

Delivery is best-effort with an overflow fallback: if records accumulate faster than you read them, the oldest are dropped and a REG_WATCH_OVERFLOW record is queued — on seeing one, re-read the watched key (and subtree) to recover current state rather than trusting the stream. Records describe effective (layer-resolved) changes, and uncommitted transactions produce none — events fire at commit.

7.6 Key security descriptors

Peios / Developing for Peios / SDK Reference / registry.h — The Registry

int peios_reg_get_security(int key_fd, uint32_t security_info, void *sd, uint32_t cap,
                           uint32_t *sd_len_out);
int peios_reg_set_security(int key_fd, uint32_t security_info, const void *sd,
                           uint32_t sd_len, int txn_fd);

Keys are KACS-secured, so their SDs are read and written with the same <peios/security.h> vocabulary as files and tokens; security_info selects components (owner/group/DACL/SACL).

  • peios_reg_get_security reads the selected components into sd (KACS binary form), writing the length to *sd_len_out (may be NULL); a too-small buffer returns ERANGE with the required size there, and a zero cap probes. Owner/group/DACL need READ_CONTROL; the SACL needs ACCESS_SYSTEM_SECURITY.
  • peios_reg_set_security applies the selected components of sd, merging with the rest (the kernel parses and validates). The DACL needs WRITE_DAC, the owner WRITE_OWNER, the SACL ACCESS_SYSTEM_SECURITY. Here txn_fd gives atomicity, not layer qualification (SDs are not layered), or -1 to apply immediately. SD changes affect only future opens — handles already open keep their fixed grant.

7.7 Backup and restore

Peios / Developing for Peios / SDK Reference / registry.h — The Registry

int peios_reg_backup(int key_fd, int output_fd);
int peios_reg_restore(int key_fd, int input_fd);
  • peios_reg_backup exports the key and its entire subtree to output_fd (needs SeBackupPrivilege). It takes a read-only snapshot and performs no per-key access check — the privilege is the gate. Errors: EPERM/EACCES, EBADF (output not writable), ENOENT, ENOTSUP, EBUSY.
  • peios_reg_restore replaces the key and its entire subtree from input_fd (needs SeRestorePrivilege), applied in one transaction. Errors: EPERM/EACCES, EBADF (input not readable), EINVAL (malformed stream), EEXIST (GUID collision), EOVERFLOW.

7.8 Transactions

Peios / Developing for Peios / SDK Reference / registry.h — The Registry

A transaction batches key creates and mutating value/key operations into an atomic unit.

int peios_reg_begin_transaction(void);
int peios_reg_commit(int txn_fd);
int peios_reg_txn_status(int txn_fd, uint32_t *state_out, int *terminal_errno_out);
  • peios_reg_begin_transaction starts one and returns a transaction fd (initially unbound; it binds to a source on first use), or -1/ENOMEM. Pass this fd as the txn_fd argument to the create and mutating calls to enlist them. Closing the fd without committing aborts the transaction — so a transaction is abort-by-default, which makes error paths safe.
  • peios_reg_commit atomically applies everything enlisted. On success the fd is terminal — close it. Errors tell you what to do: EINVAL (already committed / never bound), EBUSY (write-lock contention — the transaction stays active, retry the commit), EIO (source failure — stays active), ETIMEDOUT.
  • peios_reg_txn_status reads a transaction's state: state_out receives the REG_TXN_* state, and terminal_errno_out receives the errno that ended it (0 while active or after a clean commit). Both may be NULL.

The lifecycle: begin → enlist operations by passing txn_fd → commit (retry on EBUSY/EIO) → close, or just close to abort.

8.1 event.h — Events (KMES)

Peios / Developing for Peios / SDK Reference / event.h — Events

<peios/event.h> is the client surface of KMES — Peios's sole event path. The kernel stamps every event with trusted metadata (timestamp, per-CPU sequence, CPU id, identity GUIDs) and writes it into a per-CPU lock-free ring buffer. There is no other way to emit or observe events: audit records, subsystem events, and your own application events all flow through the same rings. Producers emit; consumers attach to the rings and drain them.

Each event payload is a single MessagePack value — build and parse it with <peios/msgpack.h>.

Two privileges gate the module: emitting requires SeAuditPrivilege, and consuming (attaching to a ring) requires SeSecurityPrivilege.

8.1.1 See also #

  • <peios/msgpack.h> — building and parsing the payloads events carry.
  • Auditing — the operator-side view of the event and audit stream.

8.2 Emitting events

Peios / Developing for Peios / SDK Reference / event.h — Events

int peios_event_emit(const char *event_type, uint16_t event_type_len,
                     const void *payload, uint32_t payload_len);

Emits a single event. event_type is a length-counted UTF-8 event type such as "org.example.backup.snapshot.created", named as PGSS §6.3 requires — not NUL-terminated, and its length must be non-zero. payload is payload_len bytes of MessagePack (one well-formed value). The kernel validates the payload (one well-formed MessagePack value within the configured size and nesting limits) and stamps origin_class = userspace. Returns 0, or -1 with errno:

errnoCause
EPERMNo SeAuditPrivilege.
EINVALZero-length type, or a malformed payload.
ENOSPCPayload exceeds the size caps.
EAGAINRate-limited.
EFAULTBad pointer.

Since the kernel's payload check matches peios_mp_validate, you can validate in userspace first and turn a would-be EINVAL into a check you control.

/* Build a payload, then emit. */
peios_mp_writer *w = peios_mp_writer_new();
peios_mp_write_map(w, 1);                      /* {outcome: {success: true}} */
peios_mp_write_str(w, "outcome", 7);
peios_mp_write_map(w, 1);
peios_mp_write_str(w, "success", 7); peios_mp_write_bool(w, true);

static const char type[] = "org.example.backup.snapshot.created";
const void *buf; ssize_t n = peios_mp_writer_bytes(w, &buf);
if (n >= 0)
    peios_event_emit(type, sizeof type - 1, buf, (uint32_t)n);
peios_mp_writer_free(w);

8.2.0.1 The emission policy #

#define PEIOS_EVENT_TIER_ESSENTIAL 0u
#define PEIOS_EVENT_TIER_STANDARD  1u
#define PEIOS_EVENT_TIER_VERBOSE   2u
#define PEIOS_EVENT_TIER_DEBUG     3u

typedef struct peios_event_policy peios_event_policy;

peios_event_policy *peios_event_policy_open(void);
void peios_event_policy_close(peios_event_policy *policy);
int  peios_event_policy_enabled(peios_event_policy *policy, const char *event_type,
                                uint16_t event_type_len, uint32_t tier);

An emitter must not write an event whose type the emission policy has switched off (PGSS §6.9), and should find that out before it builds the payload. peios_event_policy_enabled answers it: 1 means build and emit, 0 means build nothing. tier is the tier the type declares in its fragment. An essential type is always 1, without reading anything.

peios_event_emit does not check the policy itself. By the time it is called the payload is built, and skipping that work is the point of asking.

The handle caches each type's answer and watches Machine\Generic\Events, so a change an administrator commits applies to your next decision. If that key does not exist yet, it watches for its creation; if nothing can be watched, it reads the policy again at least once a second. If the registry cannot be read at all, the answer is the tier's default: on for standard, off for verbose and debug.

peios_event_policy_open fails only with ENOMEM; it does not need the registry to be there. peios_event_policy_enabled returns -1 with EINVAL only for caller error: a NULL handle or type, a malformed type (empty, not UTF-8, an empty segment, or a \, / or NUL in a segment), or an unknown tier. Registry failures never make it fail.

Use a handle from one thread at a time, and don't use it across fork(): the child shares the parent's watch descriptor and would take the parent's notifications. A child opens its own. The Rust binding's peios::event::EventPolicy adds a lock so it can be shared between threads. libp-go has its own implementation, event.Policy.

8.2.0.2 Batch emit #

struct peios_event_entry {
    const char *event_type;      /* length-counted UTF-8; not NUL-terminated */
    uint16_t    event_type_len;
    const void *payload;         /* MessagePack bytes */
    uint32_t    payload_len;
};

int peios_event_emit_batch(const struct peios_event_entry *entries,
                           uint32_t count, uint32_t *emitted_out);

peios_event_emit_batch emits several events in one call, amortising the per-call overhead — a single timestamp capture, identity capture, and consumer wake cover the whole batch. count is in [1, KMES_BATCH_MAX_ENTRIES]. It returns 0 if all count were emitted, or -1 with the errno of the first entry that failed, with *emitted_out (if non-NULL) set to how many entries preceded the failure — so you know exactly where to resume. Rate-limiting is all-or-nothing here: an EAGAIN emits none of the batch.

8.3 Consuming events

Peios / Developing for Peios / SDK Reference / event.h — Events

A consumed event is described by struct peios_event. The kernel-stamped header is copied to you by value; the two variable parts point into the ring mapping.

struct peios_event {
    uint64_t timestamp;                  /* ns since the Unix epoch (CLOCK_REALTIME) */
    uint64_t sequence;                   /* per-CPU, per-boot monotonic (gap = lost events) */
    uint16_t cpu_id;
    uint8_t  origin_class;               /* 0 = userspace, 1 = KMES, 2 = KACS, 3 = LCS */
    uint8_t  effective_token_guid[16];
    uint8_t  true_token_guid[16];
    uint8_t  process_guid[16];
    const char *event_type;              /* not NUL-terminated; use event_type_len */
    uint16_t    event_type_len;
    const void *payload;                 /* a MessagePack value */
    uint32_t    payload_len;
};

The trusted metadata is the point of KMES: the timestamp, the identity GUIDs (the effective and true tokens, and the process), and the origin_class are stamped by the kernel and cannot be forged by the emitter. sequence is per-CPU, per-boot monotonic — a gap in it means events were lost (overwritten before you drained them).

Lifetime: event_type and payload point into the ring mapping and are valid only until the next read advance, and only while the slot has not been overwritten. Copy out whatever you need before continuing to the next event.

8.3.0.1 Attaching to a ring #

int peios_event_attach(uint32_t cpu_id, uint64_t *capacity_out);

The low-level primitive: attach to CPU cpu_id's ring buffer, returning a fd and writing the data-region capacity to *capacity_out. Discover the CPU count by counting up from 0 until peios_event_attach returns -1 with errno == EINVAL. Requires SeSecurityPrivilege (EPERM otherwise). You then mmap the fd via peios_event_ring_map. Most callers should use the high-level reader instead, which does the attach and mmap for you.

8.3.0.2 The high-level reader #

typedef struct peios_event_reader peios_event_reader;

peios_event_reader *peios_event_reader_open(uint32_t cpu_id);
void                peios_event_reader_close(peios_event_reader *r);
int      peios_event_reader_next(peios_event_reader *r, struct peios_event *out);
int      peios_event_reader_wait(peios_event_reader *r, int timeout_ms);
uint64_t peios_event_reader_lost(const peios_event_reader *r);

The reader owns the attach + mmap and hides the whole lock-free drain — memory barriers, lapping recovery, sequence-gap (lost-event) accounting, buffer resize/generation handling, and the futex wait. You just loop next/wait.

  • peios_event_reader_open attaches to cpu_id and maps its ring, ready to drain (NULL with errno on failure). peios_event_reader_close tears it down.
  • peios_event_reader_next fetches the next event into out (non-NULL). Returns 1 (event filled), 0 (none available right now — consider wait), or -1 with errno. The out pointers are valid only until the next call.
  • peios_event_reader_wait blocks until events are available or timeout_ms elapses (negative = forever). Returns 1 (call next), 0 (timeout/interrupted), or -1.
  • peios_event_reader_lost returns the cumulative count of lost events (from sequence gaps) — poll it to monitor whether you're draining fast enough.

The canonical consume loop, per CPU:

peios_event_reader *r = peios_event_reader_open(cpu);
for (;;) {
    struct peios_event ev;
    int rc = peios_event_reader_next(r, &ev);
    if (rc == 1) {
        /* handle ev — copy out event_type/payload before the next call */
    } else if (rc == 0) {
        peios_event_reader_wait(r, -1);   /* sleep until more arrive */
    } else {
        break;                            /* error */
    }
}
peios_event_reader_close(r);

To consume the whole machine, run one reader per CPU (discover the count as above), each typically on its own thread.

8.3.0.3 The low-level ring #

For callers that want to drive the drain themselves — integrating the rings into a custom event loop, say — the ring API exposes the mapping directly. The accessors apply the correct memory barriers; you own the read position and the empty/lapping/generation checks.

struct peios_event_ring { uint64_t _opaque[4]; };   /* opaque */

int  peios_event_ring_map(int fd, uint64_t capacity, struct peios_event_ring *ring);
void peios_event_ring_unmap(struct peios_event_ring *ring);

uint64_t peios_event_ring_capacity(const struct peios_event_ring *ring);
uint64_t peios_event_ring_write_pos(const struct peios_event_ring *ring);  /* acquire */
uint64_t peios_event_ring_tail_pos(const struct peios_event_ring *ring);   /* acquire */
uint64_t peios_event_ring_generation(const struct peios_event_ring *ring);
void     peios_event_ring_set_need_wake(const struct peios_event_ring *ring, int set);

ssize_t peios_event_ring_event_at(const struct peios_event_ring *ring,
                                  uint64_t read_pos, struct peios_event *out);
int     peios_event_ring_wait(const struct peios_event_ring *ring,
                              uint64_t read_pos, int timeout_ms);
  • peios_event_ring_map maps and validates a ring fd from peios_event_attach; ring must be zeroed or previously unmapped (remapping an active ring fails EBUSY). peios_event_ring_unmap releases it.
  • Positions are free-running byte counters. write_pos is where the producer will write next (acquire-loaded); tail_pos is the oldest still-live byte (advances as the ring laps); an event lives at (read_pos & (capacity - 1)). You drain by walking read_pos from tail_pos toward write_pos. generation changes when the buffer is resized — re-read capacity when it does.
  • peios_event_ring_event_at parses the event at read_pos into out and returns its byte size (advance read_pos by that), or -1 if the slot is corrupt. You must have confirmed read_pos is in [tail_pos, write_pos) first. Pass out == NULL to validate a slot and get its size without borrowing the event_type/payload pointers.
  • Before sleeping, arm the advisory wake flag with peios_event_ring_set_need_wake(ring, 1), then peios_event_ring_wait futex-waits until events past read_pos may be available or timeout_ms elapses (negative = forever): 1 (drain now), 0 (timeout/interrupted), -1.

The low-level loop mirrors the high-level one but with the position bookkeeping in your hands:

uint64_t rp = peios_event_ring_tail_pos(&ring);
for (;;) {
    uint64_t wp = peios_event_ring_write_pos(&ring);
    while (rp < wp) {
        struct peios_event ev;
        ssize_t sz = peios_event_ring_event_at(&ring, rp, &ev);
        if (sz < 0) { /* corrupt slot — resync from tail_pos */ break; }
        /* handle ev */
        rp += (uint64_t)sz;
    }
    peios_event_ring_set_need_wake(&ring, 1);
    peios_event_ring_wait(&ring, rp, -1);
}

Reach for this only when the high-level reader's loop doesn't fit your event model; for almost everything, peios_event_reader_* is the right tool.

9.1 msgpack.h — MessagePack codec

Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding

<peios/msgpack.h> is a small, self-contained MessagePack codec. It exists because KMES event payloads are MessagePack: the kernel only structurally validates a payload on emit — it does not build or interpret it — so userspace owns the encode and decode. This codec is that path, and its validator's acceptance is deliberately matched to the kernel's emit-time check, so a payload this codec produces and validates is guaranteed to be accepted by peios_event_emit.

You can use it as a general MessagePack codec, but its reason for being is events.

It has three parts: a heap-backed writer, a stack-allocatable reader, and a validator.

9.1.1 See also #

  • <peios/event.h> — the KMES events these payloads travel in.
  • Library conventions — the sticky-error builder model the writer follows.

9.2 Conventions

Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding

A few rules hold across the codec:

  • Integers are written in their smallest MessagePack form automatically — you write an int64/uint64 and the encoder picks the compact encoding.
  • str values must be valid UTF-8. Use bin for arbitrary bytes. The reader enforces this on str reads too.
  • A valid payload is exactly one top-level value, and an empty buffer is not valid. (A map or array at the top counts as that one value.)
  • The writer is sticky-error, exactly like the <peios/security.h> builders: the write calls cannot fail individually; the first error latches and surfaces at peios_mp_writer_bytes / peios_mp_writer_error.

9.3 Writer

Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding

typedef struct peios_mp_writer peios_mp_writer;

peios_mp_writer *peios_mp_writer_new(void);
void             peios_mp_writer_free(peios_mp_writer *w);
void             peios_mp_writer_reset(peios_mp_writer *w);

Create a writer, append values, take the bytes, free it (or reset to reuse). All the append calls return void — errors latch.

9.3.0.1 Scalars #

void peios_mp_write_nil(peios_mp_writer *w);
void peios_mp_write_bool(peios_mp_writer *w, bool v);
void peios_mp_write_int(peios_mp_writer *w, int64_t v);
void peios_mp_write_uint(peios_mp_writer *w, uint64_t v);
void peios_mp_write_float(peios_mp_writer *w, double v);
void peios_mp_write_str(peios_mp_writer *w, const char *s, size_t len);  /* UTF-8 */
void peios_mp_write_bin(peios_mp_writer *w, const void *b, size_t len);

Use peios_mp_write_int for signed and peios_mp_write_uint for unsigned values; both are stored in the smallest form. peios_mp_write_str takes UTF-8 with an explicit length (no NUL needed); peios_mp_write_bin takes arbitrary bytes.

9.3.0.2 Containers #

void peios_mp_write_array(peios_mp_writer *w, uint32_t count);
void peios_mp_write_map(peios_mp_writer *w, uint32_t count);

Write the header, then exactly the promised number of values. A map of count needs 2 * count values — count key/value pairs — written key, value, key, value…. An under- or over-filled container is not caught at the write call; it surfaces at peios_mp_writer_bytes, when the whole structure is validated.

/* {"user": "alice", "ok": true} */
peios_mp_write_map(w, 2);
peios_mp_write_str(w, "user", 4);  peios_mp_write_str(w, "alice", 5);
peios_mp_write_str(w, "ok", 2);    peios_mp_write_bool(w, true);

9.3.0.3 Extensions and raw bytes #

void peios_mp_write_ext(peios_mp_writer *w, int8_t ext_type, const void *b, size_t len);
void peios_mp_write_raw(peios_mp_writer *w, const void *b, size_t len);
  • peios_mp_write_ext writes a MessagePack extension value with a signed type id.
  • peios_mp_write_raw appends pre-encoded MessagePack bytes verbatim — the escape hatch for splicing in a value you already have encoded. The result is still structurally validated as a whole at peios_mp_writer_bytes, so you can't smuggle malformed bytes through it.

9.3.0.4 Taking the bytes #

ssize_t peios_mp_writer_bytes(peios_mp_writer *w, const void **out);
int     peios_mp_writer_error(const peios_mp_writer *w);

peios_mp_writer_bytes confirms the buffer is exactly one well-formed top-level value, then borrows it: it writes a pointer to the encoded bytes through out (valid until the next mutating call on w) and returns the length. Pass out == NULL to validate and get the length without borrowing. It returns -1 with errno — EINVAL on a latched error or a malformed/under-filled structure, ENOMEM on a prior allocation failure. peios_mp_writer_error returns the latched errno directly, or 0.

Because this call validates, a successful peios_mp_writer_bytes is your guarantee the bytes are emit-ready.

9.4 Reader

Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding

The reader is a cursor over a borrowed buffer — stack-allocatable, no heap, no free. It decodes one value at a time, advancing the cursor.

struct peios_mp_reader { uint64_t _opaque[4]; };   /* opaque — do not inspect */

void   peios_mp_reader_init(struct peios_mp_reader *r, const void *buf, size_t len);
size_t peios_mp_reader_remaining(const struct peios_mp_reader *r);

Declare a struct peios_mp_reader locally and peios_mp_reader_init it over your buffer before use. buf may be NULL only when len is zero. Borrowed str/bin/ext pointers the reader hands back point into the original buffer and are valid for as long as it lives. peios_mp_reader_remaining reports the unconsumed byte count.

9.4.0.1 Peeking #

enum peios_mp_type {
    PEIOS_MP_NIL, PEIOS_MP_BOOL, PEIOS_MP_INT, PEIOS_MP_FLOAT,
    PEIOS_MP_STR, PEIOS_MP_BIN, PEIOS_MP_ARRAY, PEIOS_MP_MAP, PEIOS_MP_EXT,
};

int peios_mp_peek(const struct peios_mp_reader *r);

peios_mp_peek returns the peios_mp_type of the next value without consuming it, or -1 at end-of-input or on an invalid lead byte. Note that integers of every width and sign report as PEIOS_MP_INT — read them with peios_mp_read_int or peios_mp_read_uint as you prefer. Peek is how you drive a dispatch over a value whose type you don't know ahead of time.

9.4.0.2 Reading scalars #

int peios_mp_read_nil(struct peios_mp_reader *r);
int peios_mp_read_bool(struct peios_mp_reader *r, bool *out);
int peios_mp_read_int(struct peios_mp_reader *r, int64_t *out);
int peios_mp_read_uint(struct peios_mp_reader *r, uint64_t *out);
int peios_mp_read_float(struct peios_mp_reader *r, double *out);

Each consumes one value on success (returns 0) and leaves the cursor untouched on a type mismatch or truncation (-1 with errno == EINVAL) — so a failed read is safe to follow with a different-typed read or a peek. The out pointer is optional: pass NULL to consume/type-check a value without receiving its payload.

9.4.0.3 Reading strings, bytes, containers, extensions #

ssize_t peios_mp_read_str(struct peios_mp_reader *r, const char **out);
ssize_t peios_mp_read_bin(struct peios_mp_reader *r, const void **out);
ssize_t peios_mp_read_array(struct peios_mp_reader *r);
ssize_t peios_mp_read_map(struct peios_mp_reader *r);
ssize_t peios_mp_read_ext(struct peios_mp_reader *r, int8_t *type_out, const void **out);
int     peios_mp_skip(struct peios_mp_reader *r);
  • peios_mp_read_str / peios_mp_read_bin borrow the bytes (a pointer into the reader's buffer via out) and return the length, or -1. Strings are not NUL-terminated — use the length — and peios_mp_read_str rejects invalid UTF-8.
  • peios_mp_read_array returns the element count; peios_mp_read_map returns the key/value pair count (so read 2 * count values). After the header you read that many values yourself.
  • peios_mp_read_ext borrows an extension value's bytes, reporting its signed type id through type_out (both type_out and out are independently optional), and returns the data length.
  • peios_mp_skip consumes exactly one complete value, descending into nested containers — the way to ignore a value (or a whole subtree) you don't care about. 0 / -1.
struct peios_mp_reader r;
peios_mp_reader_init(&r, payload, payload_len);

ssize_t pairs = peios_mp_read_map(&r);          /* top-level map */
for (ssize_t i = 0; i < pairs; i++) {
    const char *key; ssize_t klen = peios_mp_read_str(&r, &key);
    /* dispatch on key… then read or skip the value */
    peios_mp_skip(&r);
}

9.5 Validator

Peios / Developing for Peios / SDK Reference / msgpack.h — Encoding

int peios_mp_validate(const void *buf, size_t len, uint32_t max_depth);

peios_mp_validate confirms buf/len is exactly one well-formed MessagePack value: UTF-8 strings, nesting bounded by max_depth, no trailing bytes, non-empty. Returns 0 if valid, -1 with errno == EINVAL otherwise.

Crucially, its acceptance matches the kernel's emit-time check, so a 0 return means the event emit calls will accept the payload — at this depth bound. Pass KMES_CONFIG_MAX_NESTING_DEPTH_DEFAULT (32) for the default emit limit; the top-level value is depth 1. Validate before emitting when a payload comes from an untrusted or dynamic source, so you turn a would-be EINVAL from the kernel into a check you control.

10.1 rsi/source.h — Becoming a source

Peios / Developing for Peios / SDK Reference / rsi/source.h — Becoming a Source

<rsi/source.h> is where a registry source begins. A source is a storage backend for the LCS registry — the provider counterpart to libpeios's registry client. Where a client opens keys and reads values, a source is what actually holds those keys and values and answers the kernel's requests for them.

This header has one job: registration. You declare which hives your process backs, register with the kernel, and get back a source fd. From that point on you serve the RSI (Registry Source Interface) protocol on that fd — reading requests and writing responses. Registration requires SeTcbPrivilege. The RSI wire constants (RSI_HIVE_PRIVATE, RSI_*) come from <pkm/lcs.h>.

This is part of librsi, a separate library from libpeios — link -lrsi and include <rsi.h> (or the individual <rsi/*.h>). It follows the same library conventions: raw fds, int returning 0/-1+errno, and the errno passed straight through from the kernel.

10.1.1 See also #

  • Registry sources overview — what a source is and how the RSI protocol flows.
  • The registry — the operator-side model of hives, layers, and sources.

10.2 Describing a hive

Peios / Developing for Peios / SDK Reference / rsi/source.h — Becoming a Source

A hive is a subtree of the registry with its own root key. A source declares one struct rsi_hive per hive it backs:

struct rsi_hive {
    const void *name;         /* hive name (not NUL-terminated) */
    uint32_t    name_len;
    uint32_t    flags;        /* RSI_HIVE_PRIVATE, or 0 for a global hive */
    uint8_t     root_guid[16];/* root key GUID */
    uint8_t     scope_guid[16];/* private hives; zero for a global hive */
};
FieldMeaning
name / name_lenThe hive's name, length-counted (not NUL-terminated).
flagsRSI_HIVE_PRIVATE for a private (scoped) hive, or 0 for a global one.
root_guidThe GUID of the hive's root key — the anchor every path in the hive resolves from.
scope_guidFor a private hive, the scope GUID that bounds who can resolve it; zero for a global hive.

A global hive is visible system-wide; a private hive is scoped by scope_guid and resolvable only by tokens holding that scope (see the token LCS credentials). Set RSI_HIVE_PRIVATE and a non-zero scope_guid together for a private hive; leave both clear for a global one.

10.3 Registering

Peios / Developing for Peios / SDK Reference / rsi/source.h — Becoming a Source

int rsi_register(const struct rsi_hive *hives, uint32_t count, uint64_t max_sequence);

Opens /dev/pkm_registry and registers all count hives in one call, returning the source fd — the descriptor you then read(2) requests and write(2) responses on — or -1 with errno.

ArgumentMeaning
hives / countThe hives this source serves. count must be >= 1; the kernel enforces its configured MaxHivesPerSource limit.
max_sequenceThe highest sequence number this source has already persisted. The kernel resumes its global sequence counter past this value, so a source that has durable state from a previous run must report it here to avoid reusing sequence numbers. A fresh source with no persisted state passes 0.

Errors include EPERM (no SeTcbPrivilege), EINVAL, ENOSPC (over the hive limit), ENOMEM, EFAULT, and any error from the underlying /dev/pkm_registry open(2).

struct rsi_hive hive = {
    .name = "MyStore", .name_len = 7,
    .flags = 0,                                  /* global hive */
    .root_guid = { /* … 16 bytes … */ },
};

int src = rsi_register(&hive, 1, /*max_sequence=*/0);
if (src < 0) { perror("rsi_register"); return -1; }
/* `src` is now the source fd — serve the RSI protocol on it. */

The max_sequence parameter is the one piece of state a durable source must get right: on restart, scan your persisted data for the highest sequence you ever wrote and pass it, so the kernel never hands out a sequence number you've already used.

10.4 What comes next

Peios / Developing for Peios / SDK Reference / rsi/source.h — Becoming a Source

Registration is the whole of this header. Once you hold the source fd, the serve loop lives in the other two:

  • <rsi/request.h> — read and decode the requests the kernel sends.
  • <rsi/response.h> — build and send the replies.

The serving requests guide ties them together into a working serve loop.

11.1 rsi/request.h — Decoding requests

Peios / Developing for Peios / SDK Reference / rsi/request.h — Decoding Requests

<rsi/request.h> is the receiving half of a registry source's serve loop. The kernel sends your source RSI requests — "look up this child", "store this value", "begin this transaction" — as framed messages on the source fd. This header reads one frame, splits its header from its payload, and decodes the payload into a flat, typed struct you can act on.

The shape of the loop is always: read a frame → parse the header → dispatch on the op-code → decode the payload with the matching parser. The decoders are thin wrappers over the kernel's own RSI parsers, so your wire handling is guaranteed compatible with what the kernel sent.

Borrowing: every decoded name/data field is a (ptr, len) pair that borrows into your frame buffer. The pointers are valid only until you reuse that buffer for the next rsi_read_request. Copy out anything you need to keep across iterations. This is the same borrow discipline as libpeios's views.

Op-code and field constants (RSI_LOOKUP, RSI_WRITE_KEY_FIELD_*, RSI_TXN_*) come from <pkm/lcs.h>.

11.1.1 See also #

  • <rsi/response.h> — building the reply each op expects.
  • Serving requests — the read/parse/dispatch/respond loop in full.

11.2 Reading and parsing a frame

Peios / Developing for Peios / SDK Reference / rsi/request.h — Decoding Requests

struct rsi_request {
    uint64_t    request_id;   /* echo this in the response */
    uint64_t    txn_id;       /* transaction id (0 outside a transaction) */
    const void *payload;      /* borrowed; valid until the frame is reused */
    uint32_t    payload_len;
    uint16_t    op_code;      /* RSI_LOOKUP, RSI_SET_VALUE, … — dispatch on this */
};

ssize_t rsi_read_request(int fd, void *buf, size_t cap);
int     rsi_parse_request(const void *frame, size_t len, struct rsi_request *out);
  • rsi_read_request reads one framed request from the source fd into buf — a thin read(2) wrapper that blocks until a request is queued, then returns the frame length (pass it to rsi_parse_request). It returns 0 at EOF (the source is closing — leave the loop) or -1 with errno, notably EMSGSIZE if cap is smaller than the pending frame (size buf generously, or grow and retry).
  • rsi_parse_request splits a frame into its header and payload view, filling out with the request_id (which you must echo in the response), the txn_id (0 when the request is not inside a transaction), the op_code to dispatch on, and a borrowed payload pointer. Returns 0, or -1 with errno (EINVAL on NULL args, EBADMSG on a malformed frame).

11.3 The decoders

Peios / Developing for Peios / SDK Reference / rsi/request.h — Decoding Requests

Each decoder takes the parsed req and fills a flat struct: GUIDs by value, names and data as borrowed (ptr, len) pairs. All return 0, or -1 with errno — EINVAL if the arguments are NULL or the decoder doesn't match req->op_code (so calling the wrong decoder for an op is a clean error), and EBADMSG on a malformed payload. You dispatch on req.op_code and call the matching one.

11.3.0.1 Path and entry operations #

These operate on the name→GUID bindings that make up the key hierarchy. A child is named under a parent GUID, and entries live in layers.

/* LOOKUP — is child_name visible under parent_guid? */
struct rsi_lookup {
    uint8_t     parent_guid[16];
    const void *child_name;  uint32_t child_name_len;
};
int rsi_request_lookup(const struct rsi_request *req, struct rsi_lookup *out);

/* CREATE_ENTRY — bind child_name → child_guid in layer_name. */
struct rsi_create_entry {
    uint8_t     parent_guid[16];
    uint8_t     child_guid[16];
    const void *child_name;  uint32_t child_name_len;
    const void *layer_name;  uint32_t layer_name_len;
    uint64_t    sequence;
};
int rsi_request_create_entry(const struct rsi_request *req, struct rsi_create_entry *out);

/* HIDE_ENTRY — tombstone child_name in layer_name. */
struct rsi_hide_entry {
    uint8_t     parent_guid[16];
    const void *child_name;  uint32_t child_name_len;
    const void *layer_name;  uint32_t layer_name_len;
    uint64_t    sequence;
};
int rsi_request_hide_entry(const struct rsi_request *req, struct rsi_hide_entry *out);

/* DELETE_ENTRY — remove child_name's entry in layer_name. */
struct rsi_delete_entry {
    uint8_t     parent_guid[16];
    const void *child_name;  uint32_t child_name_len;
    const void *layer_name;  uint32_t layer_name_len;
};
int rsi_request_delete_entry(const struct rsi_request *req, struct rsi_delete_entry *out);

/* ENUM_CHILDREN — list the children of parent_guid. */
struct rsi_enum_children { uint8_t parent_guid[16]; };
int rsi_request_enum_children(const struct rsi_request *req, struct rsi_enum_children *out);
OpYou mustReply with
LOOKUPResolve child_name under parent_guid across your layers.rsi_respond_lookup
CREATE_ENTRYBind child_name → child_guid in layer_name at sequence.status
HIDE_ENTRYPlace a tombstone for child_name in layer_name.status
DELETE_ENTRYRemove child_name's entry in layer_name.status
ENUM_CHILDRENList every child of parent_guid.rsi_respond_enum_children

11.3.0.2 Key operations #

These operate on key metadata records — the non-layered facts about a key (its name, parent, security descriptor, flags).

/* CREATE_KEY — create the metadata record guid under parent_guid. */
struct rsi_create_key {
    uint8_t     guid[16];
    uint8_t     parent_guid[16];
    const void *name;  uint32_t name_len;
    const void *sd;    uint32_t sd_len;
    uint8_t     volatile_key;  /* 1 if volatile */
    uint8_t     symlink;       /* 1 if a symlink */
};
int rsi_request_create_key(const struct rsi_request *req, struct rsi_create_key *out);

/* READ_KEY / DROP_KEY — a request carrying just a key GUID. */
struct rsi_key_guid { uint8_t guid[16]; };
int rsi_request_read_key(const struct rsi_request *req, struct rsi_key_guid *out);
int rsi_request_drop_key(const struct rsi_request *req, struct rsi_key_guid *out);

/* WRITE_KEY — update the mutable fields of guid named by field_mask. */
struct rsi_write_key {
    uint8_t     guid[16];
    uint32_t    field_mask;        /* RSI_WRITE_KEY_FIELD_SD | …_LAST_WRITE_TIME */
    const void *sd;  uint32_t sd_len;/* NULL when the SD bit is clear */
    uint64_t    last_write_time;   /* valid only when the time bit is set */
};
int rsi_request_write_key(const struct rsi_request *req, struct rsi_write_key *out);
OpYou mustReply with
CREATE_KEYStore the metadata record for guid (its name, parent, sd, and the volatile_key/symlink flags).status
READ_KEYReturn the metadata of guid.rsi_respond_read_key
DROP_KEYDelete the metadata record for guid.status
WRITE_KEYUpdate only the fields selected in field_mask — the SD when RSI_WRITE_KEY_FIELD_SD is set, the last_write_time when its bit is set — leaving the rest untouched.status

WRITE_KEY's field_mask is the important detail: sd is NULL unless the SD bit is set, and last_write_time is meaningful only when the time bit is set, so consult the mask before reading either.

11.3.0.3 Value operations #

These operate on the typed values stored on a key, each written into a layer.

/* QUERY_VALUES — read value_name (or all values when query_all) of guid. */
struct rsi_query_values {
    uint8_t     guid[16];
    const void *value_name;  uint32_t value_name_len;
    uint8_t     query_all;   /* 1 = every value (then value_name is ignored) */
};
int rsi_request_query_values(const struct rsi_request *req, struct rsi_query_values *out);

/* SET_VALUE — store value_name in layer_name with the given type/data. */
struct rsi_set_value {
    uint8_t     guid[16];
    const void *value_name;  uint32_t value_name_len;
    const void *layer_name;  uint32_t layer_name_len;
    uint32_t    value_type;
    const void *data;  uint32_t data_len;
    uint64_t    sequence;
    uint64_t    expected_sequence;  /* CAS guard (0 disables) */
};
int rsi_request_set_value(const struct rsi_request *req, struct rsi_set_value *out);

/* DELETE_VALUE_ENTRY — remove value_name's entry in layer_name. */
struct rsi_delete_value_entry {
    uint8_t     guid[16];
    const void *value_name;  uint32_t value_name_len;
    const void *layer_name;  uint32_t layer_name_len;
};
int rsi_request_delete_value_entry(const struct rsi_request *req,
                                   struct rsi_delete_value_entry *out);

/* SET_BLANKET_TOMBSTONE — set or clear a blanket tombstone on layer_name. */
struct rsi_set_blanket_tombstone {
    uint8_t     guid[16];
    const void *layer_name;  uint32_t layer_name_len;
    uint8_t     set;         /* 1 = set, 0 = clear */
    uint64_t    sequence;
};
int rsi_request_set_blanket_tombstone(const struct rsi_request *req,
                                      struct rsi_set_blanket_tombstone *out);
OpYou mustReply with
QUERY_VALUESReturn value_name — or every value when query_all is 1 (then value_name is ignored) — plus any blanket tombstones.rsi_respond_query_values
SET_VALUEStore value_name of value_type in layer_name. Honour expected_sequence as a compare-and-swap guard (0 disables it) — reject with a non-OK status if the current sequence differs.status
DELETE_VALUE_ENTRYRemove value_name's entry in layer_name.status
SET_BLANKET_TOMBSTONESet (set == 1) or clear a blanket tombstone on layer_name, masking all lower values at once.status

11.3.0.4 Transaction operations #

The kernel drives transaction boundaries; your source honours them so a group of writes commits or aborts atomically.

/* BEGIN_TRANSACTION — open transaction_id in mode. */
struct rsi_begin_transaction {
    uint64_t    transaction_id;
    uint32_t    mode;   /* RSI_TXN_READ_WRITE (0) or RSI_TXN_READ_ONLY (1) */
};
int rsi_request_begin_transaction(const struct rsi_request *req,
                                  struct rsi_begin_transaction *out);

/* COMMIT_TRANSACTION / ABORT_TRANSACTION — a request carrying just a transaction id. */
struct rsi_transaction { uint64_t transaction_id; };
int rsi_request_commit_transaction(const struct rsi_request *req, struct rsi_transaction *out);
int rsi_request_abort_transaction(const struct rsi_request *req, struct rsi_transaction *out);
OpYou mustReply with
BEGIN_TRANSACTIONOpen transaction_id in mode (RSI_TXN_READ_WRITE or RSI_TXN_READ_ONLY); buffer subsequent writes tagged with this id.status
COMMIT_TRANSACTIONAtomically apply everything buffered under transaction_id.status
ABORT_TRANSACTIONDiscard everything buffered under transaction_id.status

Requests that belong to a transaction carry its id in req.txn_id; a txn_id of 0 means the request is outside any transaction.

11.3.0.5 Layer operations #

/* DELETE_LAYER / FLUSH — a request carrying just a length-prefixed name. */
struct rsi_name { const void *name;  uint32_t name_len; };
int rsi_request_delete_layer(const struct rsi_request *req, struct rsi_name *out);
int rsi_request_flush(const struct rsi_request *req, struct rsi_name *out);
OpYou mustReply with
DELETE_LAYERRemove the entire named layer, reporting the GUIDs of any keys it orphaned.rsi_respond_delete_layer
FLUSHDurably persist pending writes for the named hive, replying only once persistence is confirmed.status

12.1 rsi/response.h — Building responses

Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses

<rsi/response.h> is the sending half of a source's serve loop. After you handle a request, you reply on the source fd with a framed response. This header builds those frames for you: you pass the result as flat arrays, and librsi validates and heap-encodes the wire frame — you never hand-pack a byte.

Every response echoes the request's id and its op-code (OR'd with the response bit) and carries an RSI_* status. Most operations are status-only; five carry a payload on success. Any operation can report a non-OK status with the status-only helper.

For the wire, a response is a 14-byte header (echoed request id, op-code | RSI_RESPONSE_BIT) plus a 4-byte RSI_* status, followed by an op-specific payload for payload-bearing successes; multi-byte integers are little-endian and names/data are length-prefixed. You don't assemble any of that — the helpers do. Status and target-type constants (RSI_OK, RSI_PATH_TARGET_GUID, …) come from <pkm/lcs.h>.

12.1.1 See also #

  • <rsi/request.h> — decoding the request each of these replies to.
  • Building responses — choosing and filling the right responder.

12.2 Status codes

Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses

Every response carries exactly one of these statuses. The kernel translates a non-OK status into the errno the registry client sees, so send the code that matches what actually happened:

CodeWhen to send it
RSI_OKThe operation succeeded. Status-only ops report it via rsi_respond_status; the five payload-bearing ops must use their own helper.
RSI_NOT_FOUNDThe requested key, entry, value, or layer does not exist in your store (client sees ENOENT).
RSI_ALREADY_EXISTSA create collided with something that already exists (client sees EEXIST).
RSI_STORAGE_ERRORYour backing store failed — I/O error, corruption, anything the client can't fix (client sees EIO).
RSI_NOT_EMPTYThe operation needs the key to have no children, and it has some (client sees ENOTEMPTY).
RSI_TOO_LARGEThe data exceeds what the source is willing or able to store (client sees ENOSPC).
RSI_TXN_BUSYA transaction can't proceed right now — e.g. write-lock contention; the operation may be retried (client sees EBUSY).
RSI_INVALIDThe request is well-formed RSI but violates the source's rules or refers to something malformed (client sees EINVAL).
RSI_CAS_FAILEDA sequence-guarded write's expected_sequence did not match the current entry — the compare-and-swap lost (client sees EAGAIN and retries).
RSI_TXN_NOT_SUPPORTEDReply to BEGIN_TRANSACTION from a source that does not implement transactions (client sees ENOTSUP).

12.3 The response contract

Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses

All rsi_respond_* helpers return 0, or -1 with errno. A set of rules applies to every helper, and violating one is an EINVAL caller-contract error:

  • (ptr, len) pairs: a pointer may be NULL only when its length/count is zero.
  • Boolean fields (volatile_key, symlink, target types) must be exactly 0 or 1.
  • Hidden path targets (RSI_PATH_TARGET_HIDDEN) must carry an all-zero target_guid.
  • LOOKUP/ENUM_CHILDREN metadata must exactly cover the GUID path targets referenced — no missing metadata, no duplicates, no unreferenced entries.
  • DELETE_LAYER orphan GUIDs must be nonzero and unique.

Beyond EINVAL, any helper can also fail with ENOMEM (during validation or frame allocation), EOVERFLOW (validation arithmetic or the assembled frame too large), EIO (a short write), or the raw write(2) errno. Per-helper EINVAL additions are noted below.

12.4 Sending a pre-built frame

Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses

ssize_t rsi_write_response(int fd, const void *frame, size_t len);

Writes one already-built response frame to the source fd — a thin write(2) wrapper returning the bytes written, or -1 with errno. Most callers never need this; the rsi_respond_* helpers build and send. It exists for callers assembling frames by other means.

12.5 Status-only responses

Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses

int rsi_respond_status(int fd, const struct rsi_request *req, uint32_t status);

The workhorse. Use it for:

  • status-only ops on success — pass status = RSI_OK; and
  • any op reporting a non-OK status — a LOOKUP that found nothing, a SET_VALUE that failed a compare-and-swap, a permission error: reply with the appropriate RSI_* status here, whatever the op.

It fails with EINVAL on a bad req, an unknown status, or RSI_OK given for a payload-bearing op (those must use their own helper on success), plus EIO / the write error.

The rule of thumb: on failure, always rsi_respond_status; on success, rsi_respond_status unless the op is one of the five below.

12.6 Payload-bearing responses

Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses

Five operations return data on success. Each takes the result as flat arrays and encodes the frame for you.

12.6.0.1 LOOKUP #

struct rsi_path_entry {
    const void *layer;  uint32_t layer_len;
    uint8_t     target_type;   /* RSI_PATH_TARGET_GUID (0) / RSI_PATH_TARGET_HIDDEN (1) */
    uint8_t     target_guid[16];
    uint64_t    sequence;
};
struct rsi_key_metadata {
    uint8_t     guid[16];
    const void *sd;  uint32_t sd_len;
    uint8_t     volatile_key;
    uint8_t     symlink;
    uint64_t    last_write_time;
};

int rsi_respond_lookup(int fd, const struct rsi_request *req,
                       const struct rsi_path_entry *entries, uint32_t entry_count,
                       const struct rsi_key_metadata *metadata, uint32_t metadata_count);

Answers a LOOKUP with the resolved path entries for the child — one per layer that has a view of it, each either a GUID target or a HIDDEN (tombstone) target — plus the metadata for every key the entries reference. A RSI_PATH_TARGET_HIDDEN entry must carry an all-zero target_guid; the metadata must exactly cover the GUID targets. EINVAL if req is not a LOOKUP, a nonzero count has a NULL array, or an entry has invalid target/boolean fields, missing/duplicate metadata, or unreferenced metadata.

12.6.0.2 ENUM_CHILDREN #

struct rsi_child_entry {
    const void            *child_name;  uint32_t child_name_len;
    const struct rsi_path_entry *entries;  uint32_t entry_count;
};

int rsi_respond_enum_children(int fd, const struct rsi_request *req,
                              const struct rsi_child_entry *children, uint32_t child_count,
                              const struct rsi_key_metadata *metadata, uint32_t metadata_count);

Answers an ENUM_CHILDREN with each child — its name and the path entries that resolve it — plus the metadata for every referenced key. The same target/boolean/metadata-coverage rules as LOOKUP apply. EINVAL on the same conditions, scoped to ENUM_CHILDREN.

12.6.0.3 READ_KEY #

int rsi_respond_read_key(int fd, const struct rsi_request *req, const void *name,
                         uint32_t name_len, const uint8_t *parent_guid, const void *sd,
                         uint32_t sd_len, uint8_t volatile_key, uint8_t symlink,
                         uint64_t last_write_time);

Answers a READ_KEY with the key's non-layered metadata: its name, parent_guid, security descriptor (sd), the volatile_key/symlink flags, and last_write_time. EINVAL if req is not a READ_KEY, parent_guid is NULL, or a boolean field is invalid.

12.6.0.4 QUERY_VALUES #

struct rsi_value_entry {
    const void *value_name;  uint32_t value_name_len;
    const void *layer_name;  uint32_t layer_name_len;
    uint32_t    value_type;
    const void *data;  uint32_t data_len;
    uint64_t    sequence;
};
struct rsi_blanket_entry {
    const void *layer_name;  uint32_t layer_name_len;
    uint64_t    sequence;
};

int rsi_respond_query_values(int fd, const struct rsi_request *req,
                             const struct rsi_value_entry *entries, uint32_t entry_count,
                             const struct rsi_blanket_entry *blankets, uint32_t blanket_count);

Answers a QUERY_VALUES with the value entries — each value's name, the layer it lives in, its type, data, and sequence — plus the blankets (the blanket tombstones on this key, each a layer and sequence). The kernel resolves precedence across the layers you report. EINVAL if req is not a QUERY_VALUES or a nonzero count has a NULL array.

12.6.0.5 DELETE_LAYER #

int rsi_respond_delete_layer(int fd, const struct rsi_request *req,
                             const uint8_t *orphaned_guids, uint32_t orphaned_count);

Answers a DELETE_LAYER with the GUIDs of the keys the deleted layer orphaned — a flat orphaned_count * 16-byte array. The GUIDs must be nonzero and unique. EINVAL if req is not a DELETE_LAYER or a nonzero count has a NULL array, a nil GUID, or a duplicate.

12.7 The five at a glance

Peios / Developing for Peios / SDK Reference / rsi/response.h — Building Responses

Success responseOpPayload
rsi_respond_lookupLOOKUPpath entries + referenced key metadata
rsi_respond_enum_childrenENUM_CHILDRENchildren (name + path entries) + metadata
rsi_respond_read_keyREAD_KEYone key's non-layered metadata
rsi_respond_query_valuesQUERY_VALUESvalue entries + blanket tombstones
rsi_respond_delete_layerDELETE_LAYERorphaned key GUIDs

Every other op — and every failure of these — is rsi_respond_status.

1.1 Overview

Peios / Developing for Peios / Go SDK Reference / Go SDK conventions

github.com/peios/peios-go is the independent, pure-Go Peios SDK. Applications do not need cgo, libpeios, or libp-go at runtime. The API uses Go values and standard interfaces rather than reproducing each C function's calling convention.

This book documents the Go API. The source repository contains package documentation and a per-symbol conformance inventory. Start with security descriptors and selectors for reading or changing an object's security metadata.

1.1.1 Packages #

PackagePurpose
securityPortable SIDs, GUIDs, ACLs, descriptors, SDDL, conditions, claims, and inheritance.
msgpackPortable MessagePack encoding, decoding, cursors, and validation.
accessAudit contexts and native access checks.
fileNative file creation/opening, security descriptors, System V IPC descriptors, and mount policy.
tokenToken specifications and native identity, sessions, adjustments, and impersonation.
socketPeer identity, message identity, and descriptor transfer.
processProcess security-block queries and mitigation activation; ordinary process creation uses Go's os/exec.
registryKeys, values, descriptors, notifications, transactions, layers, and backup/restore.
registry/sourcePortable registry-source codecs and native source hosting.
eventEvent emission, policy, record decoding, rings, and readers.

Native operations target Linux/amd64 with the Peios kernel ABI. Compiling an application on ordinary Linux does not supply that ABI: unsupported system calls return syscall.ENOSYS. Portable functionality, including security and msgpack, can also run on other operating systems.

1.1.2 Errors and ownership #

Native errors use syscall.Errno and support errors.Is. Check the error before using a result; parsing a descriptor verifies its binary structure, not whether the caller may install it on a particular object. The kernel still enforces authorization on native operations.

Descriptor parsers and encoders return Go-owned values and bytes. Close owned native handles when finished. File operations accepting syscall.Conn, including an *os.File, protect the descriptor against concurrent close and reuse during the call. Raw dirfd arguments are borrowed: the caller must keep them open for the entire operation.

Binary descriptors preserve information that SDDL cannot always express. Use binary parsing and encoding when preserving unknown ACEs or descriptor control metadata matters; SDDL formatting can reject an ACE or payload it cannot represent.

2.1 Security descriptors and selectors

Peios / Developing for Peios / Go SDK Reference / Go SDK security

security.SecurityInformation is the bitmask that selects components of a security descriptor. Use its named constants with file and registry descriptor operations:

ConstantValueSelected component
security.InfoOwner0x01Owner SID.
security.InfoGroup0x02Group SID.
security.InfoDACL0x04Discretionary ACL.
security.InfoSACL0x08Full system ACL.
security.InfoLabel0x10Mandatory-integrity label within the SACL.

Combine selectors with |, for example security.InfoOwner | security.InfoDACL. InfoLabel can accompany owner, group, or DACL selection, but must not be combined with InfoSACL; the kernel rejects that combination with EINVAL.

InfoLabel reads or writes a descriptor containing the label ACE in its SACL. Its payload is not a raw integrity SID. Reading the label requires READ_CONTROL; writing it requires WRITE_OWNER. Raising a label above the caller's integrity also requires SeRelabelPrivilege. Selecting the full SACL instead requires ACCESS_SYSTEM_SECURITY, gated by SeSecurityPrivilege.

Updates preserve components that were not selected. In particular, selecting only InfoLabel changes the mandatory label without replacing the other SACL entries. All selected components must pass their applicable authorization checks.

2.1.1 File descriptors #

file.GetDescriptor(f, info) returns owned descriptor bytes; file.SetDescriptor(f, info, sd) applies selected components from binary descriptor bytes. Both accept syscall.Conn, including *os.File. The path-relative variants are file.GetDescriptorAt(dirfd, path, info, flags) and file.SetDescriptorAt(dirfd, path, info, sd, flags).

For example, read and parse just the mandatory label:

package example

import (
    "os"

    "github.com/peios/peios-go/file"
    "github.com/peios/peios-go/security"
)

func readLabel(f *os.File) (security.Descriptor, error) {
    wire, err := file.GetDescriptor(f, security.InfoLabel)
    if err != nil {
        return security.Descriptor{}, err
    }
    return security.ParseDescriptor(wire)
}

To request a medium-integrity, no-write-up label, build the descriptor and select only its label:

func setMediumLabel(f *os.File) error {
    sd, err := security.ParseSDDL("S:(ML;;NW;;;ME)")
    if err != nil {
        return err
    }
    wire, err := sd.MarshalBinary()
    if err != nil {
        return err
    }
    return file.SetDescriptor(f, security.InfoLabel, wire)
}

The second example uses the first example's imports. These functions perform native Peios operations and still depend on the caller's authority; constructing valid SDDL does not grant permission to set it.

2.1.2 Registry keys #

(*registry.Key).GetDescriptor(info) returns binary descriptor bytes, and (*registry.Key).SetDescriptor(info, sd, tx) writes selected components. They use the same security.Info* selectors, including InfoLabel, and the same prohibition on combining label and full-SACL selection. Pass a *registry.Transaction for a transactional update, or nil for an update without an explicit transaction.

Use security.ParseDescriptor to decode returned bytes and Descriptor.MarshalBinary to encode a descriptor for an update. security.ParseSDDL is available when constructing a descriptor from text. An absent DACL and an empty DACL are different: preserve that distinction when building or rewriting descriptors.

What DWE is

Peios / Developing for Peios / DWE

Developer Workflow Embeddings are dedicated development paths built into core Peios software: the tools carry first-class support for developing against them, rather than being poked at from the outside by whatever a developer can improvise.

Its first and most general component is dwed, a service that gives you a persistent, maximally-privileged way to talk to a machine that is already running. Everything below is about that.

Caution

dwed performs no authentication of any kind. Anything that can reach its socket owns that machine as SYSTEM. It exists only on a dedicated development ISO and must never be present on a machine you would not hand to a stranger.

This is the design, not a gap to be closed later. See The security posture.

The problem it solves #

Debugging a live system means asking it one question, reading the answer, and asking a better one. The loop is only as good as how quickly you can go round it.

Without something like dwed, driving a Peios machine from outside means a serial console: boot the machine, feed it a script, read what comes back, and start again. Three things about that hurt more than they look:

  • Nothing survives. Each run is a fresh boot, so every question has to be planned in advance and packed into one script. A question that occurs to you halfway through the output cannot be asked without starting over.
  • The output is one stream. A console interleaves what you typed, what the shell echoed, and what the program wrote to stdout and stderr — with no reliable way to pull them apart afterwards. Exit codes are not carried at all unless the script prints them itself.
  • You cannot be SYSTEM. A console session is a logon session belonging to a person. The most privileged thing a machine has to offer is not reachable through it at all.

dwed exists because the machine is already persistent. What was missing was anything willing to talk to it in between questions.

What it gives you #

A socket into a running machine that answers structured requests as SYSTEM:

  • stdout and stderr come back separately, as raw bytes, with a real exit status.
  • Commands run directly, not through a shell, so there is no quoting layer between what you meant and what ran.
  • Work can be detached — started now, collected several connections later. The job outlives the connection that started it, which is what makes an investigation spanning hours possible.
  • Files move in and out whole and binary-safe, rather than through base64 improvised into a shell pipeline.

The privilege #

dwed is started by peinit as an ordinary service with Identity = SYSTEM. It constructs no tokens of its own; the privilege arrives entirely from that one line in its service definition. Asked on a running machine, its token reports:

user          Local System (S-1-5-18)
type          Primary
logon_type    5 (Service)
privileges    36, all enabled — including SeCreateToken, SeTcb,
              SeAssignPrimaryToken, SeDebug, SeBackup, SeRestore,
              SeImpersonate, SeLoadDriver, SeSecurity, SeAudit

This is the same privilege peinit itself holds, deliberately. A full SYSTEM account is not reachable from a console at all, and reaching one is the single capability that makes dwed worth having over a serial login.

It is also why the rest of this page is about containment.

The security posture #

dwed does not authenticate its peer, and cannot.

The transport is vsock, which crosses a hypervisor boundary between two separate kernels. A guest kernel can be told a peer's context id, but it cannot attest anything about who is behind it — those are claims, not attestation, and no amount of work inside the guest changes that. Peios' identity model rules AF_VSOCK out as a carrier of process identity for exactly this reason.

So authentication is the job of whatever surrounds the machine — the host it runs on, the network it sits behind — and never of dwed.

Three things follow, and all three are load-bearing:

It is never published as a package. The peios-dwe package does not exist in the public repository and never will. It reaches a machine only inside a dedicated peios-dwe ISO, so it cannot arrive anywhere by way of an ordinary install.

Distribution is the only real control. The usual advice — "do not install it in production" — does not apply cleanly, because the machines DWE is wanted on are production in every sense except intent. There is no honest way to enforce the distinction from inside the software. Keeping it out of the repository is what stops it turning up somewhere by accident.

Installing it is not enough to start it. A package may ship a service definition but may not start it: the definition sits inert in the vendor seed library until an image names it in [registry] autoapply. For dwed, that opt-in is the moment a machine becomes remotely ownable — so it is a decision the image makes explicitly, not a consequence of a package being present.

What DWE is not #

It is not a test harness. Provium covers deterministic, repeatable testing of a whole system, from initramfs through to network interaction, and does it far better than anything built on dwed could. Tests belong there.

DWE is for the case Provium cannot serve: a machine that is already running, already misbehaving, and needs to be asked questions nobody thought to write down in advance. When a Provium test fails for a reason that is not obvious, DWE is how you go and look.

It is not a general remote-administration tool. There is no session model, no pty, no terminal multiplexing, and no plan for any of them until something concrete needs one. dwed is a way to ask a running machine questions, and the machine — not the connection — is the thing that persists.

Note

dwed is a phase-2 service, so a boot that breaks before phase 2 has no DWE at all. It also goes down with a machine that wedges completely. Both are accepted limits: below that line, a serial console is still the tool.

Next #

  • Driving a machine — booting with a vsock device, and the dwe command.
  • The DWE protocol — the wire format, for building against it directly.

Driving a machine

Peios / Developing for Peios / DWE

This page assumes an image built with the peios-dwe package and its service seed applied. If you are not sure, What DWE is explains why both are needed and why neither is the default.

Give the machine a transport #

dwed binds a vsock listener at boot, but a guest cannot conjure the transport itself — the hypervisor has to give it a vsock device. Under QEMU that is one flag:

-device vhost-vsock-pci,guest-cid=3

The context id is how the host addresses this guest. Any value of 3 or above works; QEMU refuses to start if another running guest already holds the one you picked, so concurrent machines need distinct ids.

In the Peios tree, make boot-dwe is make boot with that device attached:

cd dist/release
make iso-dwe               # build the DWE medium (once; no root needed)
make boot-dwe              # or: make boot-dwe DWE_CID=4

Leave it running. Unlike a scripted boot, the point is that the machine stays up.

Note

/dev/vhost-vsock is owned by root:kvm, so membership of the kvm group is enough — this does not need root.

On a guest booted without a vsock device, dwed reports that it has no transport and exits cleanly. That is not an error and does not restart-loop.

Point the client at it #

The dwe client takes its target from --target or from DWE_TARGET:

export DWE_TARGET=vsock:3:4820        # a VM by context id
export DWE_TARGET=tcp:10.0.0.5:4820   # or over the network

The port defaults to 4820 and can be left off. Confirm you have the machine you think you have:

$ dwe info
dwed            0.1.0
protocol        1
boot id         79880a4b-e3d0-4992-bea2-eb56f3839711
uptime          193s

The boot id is worth reading. It changes on every boot, so it is how you tell "the machine rebooted under me" from "my connection dropped" — two situations that otherwise look identical and mean very different things.

Run something #

$ dwe exec -- ls -l /system
$ dwe exec --cwd /tmp -- ./probe

Everything after -- is the argument vector, executed directly. There is no shell, so nothing re-interprets your quoting, globs your arguments or splits them on spaces. When you want a shell, ask for one:

dwe exec -- sh -c 'for p in /proc/[0-9]*; do cat $p/comm; done'

Two behaviours matter more than they look:

The exit status is yours. dwe exec exits with the guest command's status, so ordinary shell chaining works:

dwe exec -- test -f /etc/hostname && echo "it is there"

A command killed by a signal exits 128+N, matching shell convention, so it is distinguishable from one that merely failed.

The streams stay apart. The guest's stdout and stderr are written to your stdout and stderr, still separated, as raw bytes:

$ dwe exec -- ls /nonexistent 2>errors.txt
$ cat errors.txt
ls: cannot access '/nonexistent': No such file or directory

Move files #

dwe pull /var/log/peinit.log ./peinit.log   # out of the guest
dwe pull /etc/hostname                      # or straight to stdout
dwe push ./probe.sh /tmp/probe.sh           # into the guest
echo "hello" | dwe push - /tmp/greeting     # from stdin

Transfers are binary-safe and whole-file. Encoding is handled inside the protocol, so you never have to improvise base64 through a shell pipeline to get a binary out intact.

Work that outlives the connection #

This is the part that makes a long investigation possible. --detach returns a job handle immediately, and the job keeps running when the connection closes:

$ dwe exec --detach -- make -C /src world
1

Come back whenever — a minute later, an hour later, over as many separate connections as you like:

$ dwe jobs
1      running      make -C /src world

$ dwe output 1
[... everything so far ...]

$ dwe output 1 --follow          # or watch it live
$ dwe signal 1 15                # or stop it

Reading only what is new #

Polling a job repeatedly with plain dwe output re-reads everything from the start. To pick up where you left off, ask for the offsets and pass them back:

$ dwe output 1 --offsets
[... output ...]
--since-stdout 4096 --since-stderr 128

$ dwe output 1 --since-stdout 4096 --since-stderr 128
[... only what arrived since ...]

The cursor is yours to keep rather than something dwed tracks. It has no sessions and cannot tell two callers apart, so a server-side cursor would have two people polling the same job eating each other's output.

Important

Job state lives in memory. A restart of dwed restores the listener but loses every job and everything it had captured. Output is capped at 16 MiB per stream, after which the tail is dropped and the reply says so — pipe a genuinely large job to a file in the guest and pull it instead.

When it does not answer #

cannot reach vsock:3:4820 — the machine is not running, has no vsock device, or is using a different context id. Check the QEMU command line for vhost-vsock-pci.

The connection opens but nothing answers — dwed is not running in the guest. Its service definition was shipped but never applied: an image has to name dwed-service.reg in [registry] autoapply for anything to start. On the console, look for peinit: service dwed started.

protocol mismatch — dwe and dwed are from different builds. The wire version is checked rather than guessed at, so this is reported instead of being allowed to misparse. Rebuild both.

Nothing at all, and the machine is wedged — dwed goes down with the machine it is debugging. Below that line the serial console is still the tool.

The DWE protocol

Peios / Developing for Peios / DWE

dwed speaks newline-delimited JSON: one JSON object per line in each direction, with each response carrying the id of the request it answers.

JSON rather than a compact binary encoding is a deliberate trade. When dwed itself is the thing misbehaving, the protocol has to stay drivable by hand — and being able to type at it and read what comes back is worth more than the bytes a binary framing would save:

$ nc 10.0.0.5 4820
{"id":1,"op":"info"}
{"id":1,"ok":{"reply":"info","protocol_version":1,"dwed_version":"0.1.0",...}}

Framing #

A request is an object with an id and an op, plus that op's arguments inline:

{"id": 7, "op": "exec", "argv": ["ls", "-l", "/system"]}

id is chosen by the client and echoed back untouched. Requests on one connection are answered in order; concurrency comes from opening more connections, or from detaching work.

A response carries the same id and exactly one of ok or error:

{"id": 7, "ok": {"reply": "exec", "exit": 0, "stdout": "…", "stderr": "", "truncated": false}}
{"id": 7, "error": {"code": "no_such_job", "message": "job 9 is unknown"}}

The reply field names the shape of the payload. It is there because several replies carry the same fields — exec and job.output both have stdout, stderr and an optional exit — and a reader should never have to guess which one it is holding from shape alone.

A request that does not parse is still answered, with id: 0 and a bad_request error. Silence would leave a client waiting on an id that is never coming, which presents as a hang — the one symptom hardest to tell apart from the bug being investigated.

Bytes #

Every field carrying payload bytes is base64: file contents, and captured stdout/stderr alike.

Output is base64 rather than a JSON string because a guest command's output is not guaranteed to be valid UTF-8, and a tool that mangles a binary is worse than one that refuses it. Clients decode and write raw bytes back out, so the encoding never reaches whoever is driving the tool.

Operations #

exec #

Run a command. Synchronous unless detach is set.

Field
argvrequiredProgram and arguments. Executed directly — not through a shell.
cwdoptionalWorking directory.
envoptionalExtra environment, as [name, value] pairs, on top of the service's own.
stdinoptionalBytes written to the child's stdin, which is then closed.
detachoptionalReturn a job handle immediately instead of waiting.

Replies exec with exit (null if signalled), signal, stdout, stderr and truncated; or job with a job handle when detach was set.

Both output streams are captured concurrently. A child that fills one pipe while the other goes undrained would otherwise deadlock, and "the command hung" is the least useful thing a debugging tool can report.

job.list #

No arguments. Replies job_list with a jobs array of {job, argv, running, exit, signal, started}.

Jobs outlive the connection that created them. They do not outlive dwed itself.

job.output #

Field
jobrequiredThe handle.
since_stdoutoptionalResume stdout from this byte offset.
since_stderroptionalResume stderr from this byte offset.

Replies job_output with stdout, stderr, stdout_next, stderr_next, running, exit, signal and truncated.

The *_next values are what to pass as since_* on the following call. Cursors are the caller's to keep: dwed has no sessions and cannot distinguish two callers, so a server-side cursor would have concurrent readers consuming each other's output. An offset past the end is clamped rather than rejected.

job.signal #

{job, signal} — deliver a signal to a running job. Replies done. A job that has already exited gives not_running.

file.read #

{path} — replies file_read with bytes and mode.

file.write #

{path, bytes, mode?} — replies done. mode is applied after writing.

info #

No arguments. Replies info:

Field
protocol_versionThe version dwed speaks.
dwed_versionIts own release.
boot_idDistinguishes one boot from the next.
uptimeSeconds since boot.

boot_id is the field worth using. It lets a client tell "the machine rebooted under me" from "my connection dropped" — two situations that look identical from the socket and mean entirely different things.

Errors #

code
bad_requestMalformed JSON, an unknown op, or empty argv.
ioAn underlying system call failed. Carries errno.
no_such_jobNo job with that handle.
not_runningThe job has already exited.

errno is carried separately from the message so a client can match on the cause rather than parse English.

Transports #

vsock is the default, on port 4820. It needs no networking in the guest — which matters, because a machine whose networking is part of what broke is squarely one of the cases DWE exists for. dwed binds VMADDR_CID_ANY: a guest does not reliably know its own context id, and does not need to.

TCP is available on the same port but is opt-in (dwed --tcp 0.0.0.0:4820). Listening by default would hand the machine to anyone who can route to it, which is more than starting a service should quietly do given there is nothing behind it.

The protocol is identical over either.

Caution

Neither transport authenticates anything. Whatever reaches the socket owns the machine as SYSTEM. See the security posture.

Versioning #

PROTOCOL_VERSION is bumped whenever any wire type changes shape. A client compares it against info and reports a mismatch rather than misparsing a response it half understands.

There is no negotiation and no compatibility window. Both halves ship from one repository and are built together; a version check is there to give a clear error, not to bridge a gap.

Building a settings app

Peios / Developing for Peios / Building Desktop Apps

System Settings, My Settings, Desktop Settings, SSH Settings, Security Policy, Package Manager, Feature Manager, Upgrade Peios and Disk Manager are built from one kit, libgxwi::settings, so they share a layout and behave alike. An app of your own can use it too. The kit is functions that return HTML for a GXWI live surface (libgxwi::Live) and a stylesheet that draws it; what a setting is and what changing it does stay your app's.

The window #

Serve the kit's stylesheet before your own:

libgxwi::settings::stylesheet(&mut app);
app.stylesheet("/my-app.css", include_str!("my-app.css"));

A window with sections is settings::window(nav, current, page, status):

  • nav is the side: Nav::Heading over runs of Nav::Section, each with an id, a title, what it is set to now in a few words, and a Glyph on a Tile colour. Choosing one sends the event section with section set to its id.
  • page is the chosen section.
  • status is settings::status(said, aside): the line along the bottom, for what the last change did or why it didn't. It is always there, so nothing moves when it fills.

A window with one page and no sections is settings::single(page, status). Below 720 pixels wide, as when a second window tiles yours to half the screen, the side keeps its icons and drops its words.

A section #

Start a section with settings::head(glyph, tile, title, about). Where the section is about one thing, put it large in a settings::hero, with hero_title or big on the left and a pill or a switch on the right.

Page-header icon tiles are 40×40 pixels. SSH Settings uses a hero as its single page's header and applies that size in its app stylesheet. Other hero tiles retain the kit's 44×44 size; navigation and row icons keep their own sizes. App-specific adjustments belong after the kit stylesheet.

Settings go in groups of rows: settings::group(title, rows, foot), with rows from

  • row(label, about, control): a setting, its name and what it means on the left, what changes it on the right;
  • fact(label, value, mono): something known rather than set;
  • item(icon, name, lines, control): one of a list, such as a key;
  • link(icon, name, lines, aside, event, values): one of a list that opens to more about it;
  • more(More::Form | More::Asking, html): what opens under the row before it, a form to fill in or a question to answer.

Controls are switch, select, text, button and submit. Above a long list, search is a field to narrow it, with buttons beside it; on a page opened from a list, back returns to it. For something that takes a while, progress(done, of, text) is a bar with what is being done over it; with of 0 it moves without saying how far.

How changes behave #

Peios's settings apps follow these rules, and the kit is shaped for them.

  • A switch or a choice applies at once. Both are fields: apply them in Live::input, and when a change can't be made, put the field back to what is set and say why on the status line.
  • What is typed applies when asked to, with an Apply button that appears only once the field differs from what is set, inside a <form fx-submit>.
  • What is hard to undo asks first, under its row (More::Asking), with the safe answer focused: focused_button gives the keyboard to it.
  • Ask the system what the person may change. Open the registry key, or ask the daemon, for what a change needs; never infer it from the groups they are in. Show what they can't change read-only, and say why once: banner under the section's heading, or locked under one group.
  • Name things plainly. Labels are short Title Case names of what they are, such as Machine Name or Time Zone; an inherited value is System Default; status messages are short and in the past tense, such as "Key removed."

Recording a shortcut #

A keyboard shortcut is chosen by pressing it, not typing it. An element with fx-record="bind", once clicked, waits for the next chord pressed while its page has the keyboard and sends the event bind, with its fx-value-* attributes and chord, such as Ctrl+Alt+J. It takes the chord before anything else hears it, even one the desktop has claimed, so a person can move a shortcut that is already in use.

Modifiers held alone are waited past. Escape, a click elsewhere or the keyboard leaving the page stops it and sends nothing. While it waits, the element carries data-fx-recording="true" for a stylesheet to show. The browser keeps a few chords for itself (Ctrl+W, Ctrl+T, F11), and those never arrive.

The kit's recorder(event, values, idle, enabled) is such a button, and chord and chords show the shortcuts already set as keys, each with a button to remove it.

Where to go next #

  • Keyboard shortcuts, where the recorder is used

Peios Learn — documentation for the Peios project.

Built with Trail.