Using Peios
Operating a Peios system — the registry, filesystems, and the everyday tools.
Single-page view · as markdown
Threads and processes
Peios / Using Peios / Threads and processes
A process is a program that is running. When a program starts, the system gives it a block of private memory that only it can see, a table of the files and other resources it holds open, and a name the system tracks it by. That running program, plus everything the system keeps for it, is a process. When the program finishes, its process goes away.
A process can also do several things at once by running more than one thread. A thread is a single line of execution — one sequence of steps the system is working through. A process always has at least one thread (its initial execution is its first thread), and it can start more. Every thread in a process shares the same private memory and the same open resources. What each thread keeps to itself is its own place in its sequence: each runs its own steps, at its own pace, possibly in parallel with the others.
The actors of the system #
Processes and threads are what carry out work on the system. Every action — opening a file, sending data over a network, starting another program — is performed by some thread. When the system decides whether an action is allowed, the question it answers is "is this thread allowed?" When it records that something happened, it records which thread did it.
A thread always acts as someone — a person, a service, or the system itself. Peios carries that identity along with the thread on an object called a token (see Tokens). Two facts matter here:
- Every thread is always acting as someone. There is no "nobody" state.
- When one process starts another, the new process begins acting as the same identity as the process that started it.
What a process has #
The things the system keeps for every process:
| A process has | What that means |
|---|---|
| Private memory | working space only this process can see; other processes cannot read it |
| Open resources | the files, connections, and other things it currently holds open |
| An identity | who it is acting as (carried on its token) |
| A place in a family tree | every process was started by another, so processes form a tree |
| A lifecycle | it is created, it runs, and it ends — and its end is always observed |
| One or more threads | the lines of execution doing its work |
This is not the full list — a process also carries other per-process state, such as its Process Security Block (PSB), which holds its security-related settings.
Where to start #
Continue with The process and thread model for how a thread and a process relate, and why the thread — not the process — is the more basic unit, with a "process" being one particular way of using them.
This topic also covers:
- Creating processes — how a process starts another, and what the new one begins with.
- Process lifecycle — how a process ends, and how the system cleans up after it.
- Process relationships and job control — the process tree, process groups, and sessions (distinct from the logon sessions in Logon sessions).
The process and thread model
Peios / Using Peios / Threads and processes
A running program has two separable parts: the threads, which run, and the process, which contains them.
The thread is the part that runs — one line of execution, a single sequence of steps the system works through. A machine with several cores runs that many threads genuinely in parallel, one per core; a single core runs many threads by switching between them. Which thread runs, on which core, and for how long is decided by the system's scheduler — a subject of its own. Some tools and logs call a thread a task; it is the same thing.
The process is the boundary around a group of threads. It does not run — it contains. A process holds the things its threads share: their private memory, their open files and connections, and their identity. Every thread inside one process sees the same memory and the same open resources; threads in two different processes do not. That separation is what stops one program from reaching into another's memory or files.
The thread does the work; the process is the shared space the work happens in. A program that does one thing at a time is a process with a single thread. A program that does several things at once is a process with several threads, all sharing one space.
Why the thread is the more basic idea #
Threads, not processes, are the fundamental unit: what the system runs is threads, and a "process" is the answer to the question "what does this thread share, and with whom?" Bundle some threads together so they share one memory, one set of open resources, and one identity, and that bundle is a process. Give a thread its own fresh, separate memory instead of sharing an existing one, and you have made a new process.
This matters because it explains creation. Making a new thread and making a new process are the same mechanism with a different answer to "how much does the new line of execution share with the one that made it?" Share everything, and the result is another thread in the same process. Share nothing, and the result is a new process. Everything in between is possible too. How the operation is actually performed is covered in Creating processes.
What threads share, and what they don't #
Within one process, every thread shares:
| Shared across threads | What that means in practice |
|---|---|
| The process's memory | One thread can read and change data another thread is using — cooperation is cheap, but concurrent access must be coordinated. |
| The open resources | A file one thread opens is usable by all of them. |
| The identity | By default every thread acts as the process's identity. |
Each thread keeps its own place in its sequence — which step it is on right now. That is the point of having more than one: each thread makes progress through its own work independently of the others.
There is one exception to the shared identity. A single thread can temporarily act as a different principal — for example, a service handling a request can act as the requester for that one piece of work, then revert. Only that one thread is affected, and only until it reverts. This is covered in Impersonation; here it is enough to know that "every thread in a process shares the process's identity" is the default, not an unbreakable rule.
Naming a process #
The system gives each process a number called its process ID, or PID, so that people and other programs can refer to it — to inspect it, signal it, or wait for it to finish. Threads are individually identifiable too, by a thread ID — see thread operations.
A PID is short and convenient, but it only names a process while that process exists. Once a process ends, its PID can later be reused for a completely different process. A PID tells you which process is which right now, but it is useless as a lasting record — two unrelated processes can hold the same PID at different times.
For that, every process also has a Process GUID (globally unique identifier): a name that is never reused, assigned to each process when it starts and fixed for the rest of its life. Where the PID is the convenient short handle for the moment, the Process GUID is the permanent one — it identifies that one specific process and no other, even long after it has ended. This is what lets a record of something that happened on the system name exactly the right process, with no chance of confusing two that happened to share a PID.
Both the PID and the GUID refer to a process. Neither says who the process is acting as — that is a separate question, answered by its identity rather than by any number or name.
Where to go next #
To see how a process or a thread is created — and what a new one starts out with — read Creating processes.
Creating processes
Peios / Using Peios / Threads and processes
Every process on the system was started by another process — a process that is already running has to create it. Creation is built from two operations, which combine to cover every case.
Splitting: one process becomes two #
A running process can split into a copy of itself. The original keeps going, and a second process appears beside it that begins as a near-identical copy. The original is called the parent and the copy the child.
The child starts with:
- Its own private memory, copied from the parent. From the moment of the split the two memories are separate — a change one process makes is invisible to the other.
- Copies of the parent's open resources — the files and connections the parent had open, the child has open too.
- The same identity — the child begins acting as the same principal as its parent. (It inherits the parent's token; how the token carries across a split is in Token lifecycle.)
It also gets things of its own that are not copied: a new PID, and a new Process GUID — the child is its own distinct process, not a continuation of the parent. A few pieces of per-process state start fresh rather than being inherited, too.
This operation is called fork.
Replacing: same process, different program #
A process can also replace the program it is running with a different one. It remains the same process — same PID, same Process GUID, same identity — but from that point on it runs entirely different code. Whatever the previous program was doing is discarded, replaced wholesale by the new one.
This operation is called exec.
Putting them together #
The two operations combine to launch a different program as a new process: a process splits, and the child immediately replaces itself with the program to run. This split-then-replace pair is how most programs are started — when you run a command, something forks and the child execs your command. There is also a single combined step, spawn, that does both at once for the common case.
Underneath, splitting is one setting of a more general operation, clone, that lets the creator choose how much the new line of execution shares with it. Share everything, and the result is another thread in the same process; share almost nothing, and the result is a new process — the two ends of the range from the process and thread model. Everyday code rarely uses the general form directly; it makes threads or spawns programs, which are particular settings of it.
The exact contract — every clone sharing flag, precisely what fork passes to a
child, and the variants of exec — is collected in the
process creation reference.
Getting hold of the new process #
When a process creates a child, it usually needs to keep track of it — to wait for it to finish, or to send it a signal later. For this it can hold a handle to the child: a reference to that one specific process, called a pidfd. It is the same kind of handle the system hands out for an open file.
A process can be given a pidfd for its child at the moment it creates it, or ask for one for a process that already exists. Either way the pidfd is tied to that one specific process and can never be mistaken for another, so waiting on it or signalling through it always reaches the intended target.
This is what makes a pidfd safer than a bare PID. A PID can be reused once the process it named has ended; code that holds a bare PID risks later acting on a completely different process that inherited the number. A pidfd never has that problem — it refers to the one process it was created for and nothing else, for as long as the program holds it.
Where to go next #
A process that has been created eventually ends. How it finishes, and how the system cleans up after it, is covered in Process lifecycle.
Process lifecycle
Peios / Using Peios / Threads and processes
Every process eventually ends. A process's life has three parts — it is created, it runs, and it finishes — and the system ensures that when it finishes, its result is collected and its resources are reclaimed.
The states it passes through #
While it exists, a process is in one of a handful of states, and the system moves it between them. Most of the time it is either running — executing, or ready to run the moment a core is free — or sleeping, paused while it waits for something to happen, such as data to arrive or a timer to fire. A sleeping process uses no processor time until what it is waiting for is ready; most processes spend most of their lives asleep.
A few states are worth knowing by name:
- Stopped — suspended (for example by job control when a job is paused at the terminal); it does nothing until told to resume.
- Traced — stopped under the control of a debugger that has attached to it, so the debugger can inspect and step it.
- Uninterruptible sleep — waiting on something the system will not interrupt, almost always a brief piece of disk or device I/O. A process here cannot be woken or even killed until the operation finishes; it normally lasts an instant, and a process stuck in it is a sign that hardware or a filesystem is stuck too.
- Zombie — finished, but still held as a result waiting to be collected (see below).
Once its result has been collected the process is gone, removed from the system entirely.
Two ways a process ends #
A process ends in one of two ways.
Most often it ends on its own: it finishes its work and stops. A process that ends this way leaves behind an exit status — a small record of how it went, usually just "succeeded" or "failed" (and, on failure, a number giving a rough reason). The act of a process ending itself is called exit.
The other way is that a process is stopped from outside — something tells it to stop, and it ends whether or not its work was done. (The messages that do this are signals, a subject of their own.) A process ended this way also leaves an exit status, marked to show it was stopped rather than finishing on its own.
Either way, a record of the process remains until its result is collected.
Collecting the result #
Whenever a child changes state — ends, is stopped, or resumes — the system
sends its parent a signal, SIGCHLD, prompting it to check. The parent
then reads the child's exit status. This is called waiting for the
process.
Between the moment a process ends and the moment its parent collects the result, the finished process is a zombie: no longer running, but with the system still holding its exit status so the parent can read it. Despite the name, a zombie is harmless — it does no work and uses almost nothing; it is a result waiting to be collected. The instant the parent collects it, the zombie is gone for good.
If a parent never collects, zombies accumulate — finished processes whose results no one read. That is a fault in the parent, not normal behaviour.
A parent holding a pidfd for its child can wait on it directly — the dependable way to be told the exact moment the child ends.
The exact calls — every wait variant, and how an exit status is read — are in the
process lifecycle reference.
When the parent ends first #
A parent does not always outlive its children. If a parent ends while one of its children is still running, that child becomes an orphan.
Orphans are not lost. The system's first process — PID 1, which on Peios is peinit — adopts every orphan and stands in as its parent from then on: when the orphan eventually ends, peinit collects its result, so no finished process is ever left uncollected. Adoption is automatic; the orphan keeps running unaffected, only with a new parent.
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.
What the system cleans up #
When a process ends, the system reclaims what it was using. Its private memory is freed and its open files and connections are closed.
It also releases its identity. A process acts as a principal, carried on its token; when the process ends, its hold on that token is released. If other processes were sharing the same identity — for example, siblings from the same login — the identity lives on for them, and the system clears it away only once nothing is using it any more. The details are in Token lifecycle.
The process's PID becomes available for the system to assign to a future process. Its Process GUID is not reused — it remains a permanent marker of that one process, so records of what it did still point unambiguously back to it long after it is gone.
Where to go next #
Processes are arranged into a family tree and grouped together in ways that matter for running them. That is Process relationships and job control.
Process relationships and job control
Peios / Using Peios / Threads and processes
Every process is related to others in two different ways: by descent — who started whom — and by grouping — which processes are handled together when they are controlled at a terminal.
The family tree #
Every process was started by another, so every process has a parent: the process that created it. A process can refer to its parent by the parent's PID, sometimes called the PPID (parent process ID).
Follow the parents upward and they all lead back to the same place — PID 1, the first process, which the system starts at boot. Every other process descends from it, directly or through a chain of parents, so the processes on a running system form a single family tree rooted at PID 1. This is the same tree that makes orphan adoption work: when a process loses its parent, PID 1 takes over the role, as covered in the lifecycle.
A few processes at the very root of the tree are special. PID 1 is the first
process started at boot — on Peios, peinit — and the ancestor of all the rest; the
system protects it, refusing to kill it with any signal it has not chosen to handle,
because losing it would bring down the whole tree. Beneath the userspace processes
the kernel runs threads of its own: PID 0 is the idle process (what a core runs
when it has nothing else to do), and PID 2, kthreadd, is the parent of every
kernel thread. The kernel's threads — among them the worker threads (kworker)
that run deferred work and the threads that service hardware interrupts — appear in
a process listing as the system's own work rather than as programs loaded from disk.
Process groups #
Several processes often do one job together. In a pipeline — the output of one command feeding into the next — each command is its own process, but they form one job: you stop, pause, or resume the whole job at once, not a piece at a time.
To make that possible, related processes are collected into a process group. A process group is a set of processes treated as a unit, so that an action — most often a signal, like the one that stops a job — can be delivered to all of them together instead of one by one.
Sessions and the terminal #
Process groups are themselves collected into a larger unit: a session, tied to the terminal the work is running at.
A terminal can only take input for one job at a time, so within the session one process group is the foreground group: the one connected to the keyboard, the one typing reaches, the one a stop-or-interrupt keystroke acts on. Any other groups are in the background, running without holding the keyboard. Moving a job between foreground and background — and starting, stopping, and resuming jobs — is job control.
A different kind of "session" #
The word "session" has two meanings here, and they are unrelated:
- A job-control session, described above, organises processes at a terminal — which groups share a terminal, which one is in the foreground. It says nothing about who anyone is.
- A logon session records one sign-in — a single authentication event — and ties together every identity token that came from it. It is part of the identity model, covered in Logon sessions.
They often line up in practice — signing in at a terminal creates a logon session, and the processes run there share a job-control session — but they are separate things answering separate questions: "which processes share this terminal?" versus "where did this identity come from?"
Where to go next #
Beyond its place in the tree and its groups, every process carries a bundle of its security-related settings — its Process Security Block. That is The Process Security Block.
The Process Security Block
Peios / Using Peios / Threads and processes
A process's identity — who it is acting as — is carried on its token, and can change moment to moment, since a thread can impersonate another principal. A process has a second aspect that is independent of identity: what it is. What program is it running? How trusted is that program? How hardened is it against attack? Who is allowed to operate on it?
Those facts are gathered in one place — the Process Security Block, or PSB. Every process has one. Where the token answers who this process is acting as, the PSB answers what this process is. Unlike the token, the PSB never changes when a thread impersonates: impersonation changes who, never what.
What the PSB holds #
- Its permanent name. The process's Process GUID — the never-reused identifier — lives on the PSB.
- How trusted its program is. When a process starts running its program, the system checks the program's cryptographic signature and records from it how trusted the program is. This is the process's PIP (Process Integrity Protection) label, and it decides which other processes are allowed to inspect, signal, or interfere with this one. The barrier is based on what program is running, not on who is running it: even a fully privileged process cannot disturb a more-trusted one. The full treatment is Process integrity protection.
- How it is hardened. A set of mitigations — restrictions the process carries on what it may do with its own memory and code, so that a bug or injected code has far less room to do harm. They can only ever be tightened, never loosened. The catalog and rules are Process mitigations.
- Who may operate on it. Every process has its own security descriptor — the rules for who is allowed to act on the process itself: inspect it, signal it, and so on. It lives on the PSB alongside the rest.
A few more specialised settings live here too — a process can be marked so that it may no longer create children, for instance — but those four are the core.
Inspecting and managing the PSB #
Because the PSB is where a process's trust level and hardening live, there is a
dedicated tool for working with it: the psb command, which inspects a
process's PSB and manages its mitigations and PIP mode.
Where to go next #
The two largest parts of the PSB each have a topic of their own: Process integrity protection, for the trust label that governs which processes may interfere with which, and Process mitigations, for the self-hardening flags and how they are applied.
Process creation reference
Peios / Using Peios / Threads and processes
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) orMADV_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
fcntllocks — distinct from the OFD andflocklocks above, which are shared through the inherited descriptors) - timers (
setitimer,alarm,timer_create) - outstanding asynchronous I/O
- directory-change notifications (
dnotify) - the
PR_SET_PDEATHSIGparent-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:
| Field | Purpose |
|---|---|
flags | The sharing flags (below). |
pidfd | Where to store a pidfd for the new child. |
child_tid / parent_tid | Where to record the new thread's ID, in the child's and the parent's memory. |
exit_signal | The signal delivered to the parent when the child ends. |
stack / stack_size | The new task's stack and its size. |
tls | The new task's thread-local-storage area. |
set_tid / set_tid_size | Request specific thread IDs for the new task — a privileged operation (see below). |
cgroup | Place 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:
| Flag | Effect |
|---|---|
CLONE_VM | Share memory rather than taking a private copy. |
CLONE_FS | Share filesystem context — current directory, root directory, file-creation mask. |
CLONE_FILES | Share the open-descriptor table, so opening or closing a descriptor in one is seen by the other. |
CLONE_SIGHAND | Share the table of signal handlers. Requires CLONE_VM. |
CLONE_THREAD | Put the new task in the same process (thread group) as the creator. Requires CLONE_SIGHAND. |
CLONE_SETTLS | Set the new task's thread-local-storage area. |
CLONE_SYSVSEM | Share the System V semaphore-adjustment list. |
CLONE_IO | Share the I/O context, so the two are accounted as one for disk I/O. |
CLONE_PIDFD | Return a pidfd for the new child. |
CLONE_PARENT | Make 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_SETTID | Record the new task's ID in the parent's / child's memory. |
CLONE_CHILD_CLEARTID | Clear the recorded thread ID and wake a waiter when the task exits — the mechanism behind joining a thread. |
CLONE_UNTRACED | Prevent a tracer from forcing tracing onto the new child. |
CLONE_VFORK | Suspend 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), covered under namespaces. CLONE_INTO_CGROUPplaces 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).
| Call | What it does |
|---|---|
execve | Replace the current program with the one at a given path. |
execveat | Replace 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. |
fexecve | Replace 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:
| Call | What it does |
|---|---|
pidfd_open | Obtain a pidfd for a process that already exists, given its PID. |
pidfd_send_signal | Send a signal to the process through its pidfd — with no risk of the PID having been recycled for a different process in between. |
pidfd_getfd | Duplicate 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 / Using Peios / Threads and processes
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:
| Call | What 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:
| Macro | Tells you |
|---|---|
WIFEXITED | the process ended on its own — if so, WEXITSTATUS gives its exit code |
WIFSIGNALED | the process was ended by a signal — if so, WTERMSIG gives the signal number and WCOREDUMP whether it dumped core |
WIFSTOPPED | the process was stopped (not ended) by a signal — WSTOPSIG gives which |
WIFCONTINUED | the 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:
| Call | What it offers |
|---|---|
wait | Wait for any child to end; return its PID and status. The simplest form. |
waitpid | Wait 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. |
waitid | The 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. |
wait4 | Like 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.
See also #
- Process lifecycle — the conceptual counterpart to this page.
- Process creation reference — fork, clone, exec, and the pidfd calls.
Thread operations reference
Peios / Using Peios / Threads and processes
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.
peinit
Peios / Using Peios / Services & jobs
peinit is the init system of Peios — the first userspace process, running at PID 1 for the lifetime of the machine. It is a single-threaded program with one job: manage services, from the first one started at boot to the last one stopped at shutdown. Every supervised process on a Peios system — a long-running daemon, a one-shot setup task, a health check, a scheduled job — is started, watched, and reaped by peinit.
What makes peinit its own thing is that identity is built in. peinit does not just launch processes; it launches them as someone. Each service runs under a KACS token that decides what it can reach, and each service is itself a securable object with a security descriptor that decides who may start, stop, or query it. Service management and access control are the same system, not two systems bolted together.
This page sketches what peinit is responsible for, names the handful of ideas that make it unlike other init systems, and points at the pages that cover each one.
peinit in one sentence #
peinit is the sole service manager and PID 1 — it reads service definitions from the registry, starts them in dependency order under per-service identities, supervises them through a defined state machine, and tears them down in reverse at shutdown.
Everything in this topic is an elaboration of that sentence: what a service definition is, what dependency order means, what the state machine looks like, how identity is materialised, and how you drive the whole thing from a shell.
Three kinds of object #
peinit is easiest to understand through the three first-class objects it works with. Keeping them straight is most of the battle when you read a status output or an event log.
| Object | What it is | Lifetime |
|---|---|---|
| Service | A definition — a named unit of execution with identity, policy, and configuration, stored in the registry. It has a runtime state (the state machine) and, when running, a process. | Long-lived. Persists across restarts and reboots. |
| Job | A single process execution. Every time peinit forks — a service's main process, a hook, a health check, an ad-hoc run — that is one job, with its own GUID and exit result. | Short-lived. A restart creates a new job. |
| Operation | A requested action on a service — start, stop, restart, reload, reset. Control commands create operations that are validated, queued, and executed. | Short-lived. Resolves to a terminal state, then is dropped. |
A service is the what; a job is what actually ran; an operation is what someone asked for. A single restart command, for example, is one operation, which stops the current job and starts a new one, all against one service. Jobs and operations both carry GUIDs and are emitted to eventd as they complete, which is how the history of a service is reconstructed after the fact. They get their own page: Jobs and operations.
Underneath all three sits the fourth thing peinit is always handling — the token, the identity a service runs under. Tokens are not peinit's invention; they are the system-wide identity object. peinit's role is to obtain the right token for each service and install it before the process runs. That is the subject of Service identity and privileges.
What peinit is not #
peinit borrows vocabulary from init systems you may already know, and the familiarity is a trap. Clearing three wrong mental models is most of understanding what peinit actually is.
It is not systemd. There are no unit files. peinit does not read, parse, or translate .service files, and there is no migration shim. A service definition is a key in the registry under Machine\System\Services\, made of typed registry values — not a text file under /etc. Some interfaces are deliberately compatible where that helps: peinit speaks the sd_notify readiness protocol, and timer schedules use systemd's OnCalendar calendar-expression format. The compatibility stops at those wire formats; the model underneath is different.
It is not Windows SCM. The influence is real — services as securable objects with per-service descriptors, token-based service identity, an access-controlled control interface, a structured state machine — but it is architectural, not interface-level. peinit does not implement the SCM RPC protocol, Windows service types, or Windows control codes.
It is not a logging system. peinit holds a service's stdout/stderr pipes at birth, so it controls where output goes — but storage, indexing, and queries are eventd's job. peinit forwards; eventd is the historian. The same split applies to job and operation history: peinit emits structured events and forgets them; eventd keeps them. See Service output and logging.
A fourth, smaller correction: peinit does not supervise forking daemons. A service that double-forks to "daemonise" is solving a problem that does not exist when the manager tracks the process it spawned. peinit tracks every process by pidfd; a legacy binary that insists on double-forking must be wrapped at the package layer.
The one operational invariant #
peinit is single-threaded PID 1, and that one fact explains a surprising amount of its design. In a single-threaded PID 1, a blocking system call blocks everything — child reaping, watchdog expiry, shutdown signals, every other event stops while the call is stuck. So peinit's hard rule is that its event loop never blocks on a userspace service.
Two consequences you will meet repeatedly:
- peinit operates on an in-memory snapshot of the registry, not a live view. It reads all service definitions synchronously once, during boot, and thereafter works from an in-memory model that it updates from change notifications. Supervision never waits on the registry being available. This is why a configuration change does not always take effect immediately — see Defining a service.
- Anything that could block is pushed off the main loop. Filesystem condition checks run in a short-lived forked helper rather than a
stat()on the event loop; token requests to authd are driven by a timed state machine, not a blocking call; log delivery is best-effort and never exerts back-pressure on peinit.
You do not need to think about the event loop to operate peinit, but it is the reason behind several behaviours that would otherwise look arbitrary.
Where peinit sits in the system #
peinit is part of the Trusted Computing Base: a compromise of peinit is a compromise of the whole machine. It runs as SYSTEM with all privileges, and it never drops that identity. It depends on, and is depended on by, the other TCB components:
| peinit relies on | For |
|---|---|
| KACS | Service identity (installing tokens on children), control-interface authentication (peer token + AccessCheck), and per-service access control. See Access decisions. |
| LCS / the registry | Service definitions, boot configuration, and its own parameters. See The registry. |
| registryd | The registry source daemon — the first service peinit starts. peinit treats it as an opaque dependency. (registryd is an interface; the default implementation is loregd — see Boot and boot modes.) |
| authd | Minting tokens for non-platform services. peinit requests; authd mints. |
| eventd | Service logs (forwarded over a socket) and job/operation/audit events (emitted through KMES). |
| JFS | Delivering ad-hoc job submissions — a service asking peinit to run a process on a client's behalf. |
The bootstrapping puzzle — authd needs the registry, the registry needs to be started by something, and that something is peinit running before any of them exist — is resolved by the boot model in Boot and boot modes.
Where to start #
If you are configuring a system, start with Defining a service — what a service definition is, where it lives, and the rule that explains why some changes take effect immediately and others wait for a restart.
If you want to operate a running system — start, stop, query, and troubleshoot services — go to Controlling services for the command set and The service lifecycle to read what a status actually tells you.
If you care about what runs as whom, read Service identity and privileges and Who can manage a service — the two independent halves of the security model.
If you are investigating boot behaviour, Boot and boot modes covers Full, Safe, and Recovery modes and the escalation between them.
And when something is wrong, Troubleshooting peinit works backward from the symptom.
Defining a service
Peios / Using Peios / Services & jobs
A service is defined by a single key in the registry, under Machine\System\Services\<name>. The key's name is the service name, and the typed values inside it are the definition — the binary, who it runs as, what it depends on, how it is supervised. There is no file under /etc; there is a registry key.
peinit reads these definitions in two situations: once at boot, to build the service graph, and on demand, when an administrator starts a service or runs reload-config. Between those reads it works from an in-memory copy. That last fact is the source of the most common "why didn't my change take effect?" question, and the second half of this page is devoted to it.
Service names #
A service name must be made of characters from [A-Za-z0-9._-] and be 1–128 bytes long. Anything else is a validation error. Two characters are pointedly excluded:
/— names map directly onto cgroup ids and registry key names, and a slash would be ambiguous in both.:— reserved for peinit-internal synthetic names (for example, the way a hook job is labelled).
The name is how you refer to the service everywhere: in peiosctl commands, in another service's dependency list, in a ServiceSecurity descriptor, and in the per-service SID derived from it.
The definition schema #
A definition is a set of typed registry values. Rather than list all of them in one wall of rows, the tables below group the fields by what they are for, with a pointer to the page that explains each group in depth. Every field is optional unless noted; the only hard requirement is ImagePath. The full type-and-default catalog lives in the Registry key reference.
What to run — the binary and its execution context.
| Field | Default | Purpose |
|---|---|---|
ImagePath (required) | — | Absolute path to the service binary. |
Arguments | — | Argument list passed to the binary. |
WorkingDirectory | / | Working directory for the process. |
Environment | — | KEY=VALUE pairs added to the environment. |
RuntimeDirectories | — | Private directories created under /run just before the process starts. See The execution environment. |
LimitNOFILE, LimitCORE | — | RLIMIT_NOFILE / RLIMIT_CORE. |
What kind of service — type and readiness. See Simple and Oneshot services.
| Field | Default | Purpose |
|---|---|---|
Type | Simple | Simple (long-running) or Oneshot (run-to-completion). |
Readiness | Notify | Notify (READY=1 via sd_notify) or Alive (ready when the process exists). Ignored for Oneshot. |
RemainAfterExit | 0 | Oneshot only — stay in Completed after a successful exit. |
SuccessExitCodes | — | Non-zero exit codes to treat as success. |
When to start — triggers and conditions. See Triggers and timers.
| Field | Default | Purpose |
|---|---|---|
Triggers | — | boot and/or timer:<schedule>. Absent = demand-only. |
Disabled | 0 | If 1, triggers must not activate the service (manual start still allowed). |
SafeMode | 0 | If 1, attempt to start in Safe mode. |
Conditions, Asserts | — | Start-time checks. A failed condition skips; a failed assert fails. |
Who it runs as — identity and privileges. See Service identity and privileges.
| Field | Default | Purpose |
|---|---|---|
Identity | LocalService | Principal name or SID for the service token. |
RequiredPrivileges | — | Privilege allow-list; everything else is stripped from the token. |
HookIdentity | service's Identity | Identity for ExecStartPre/ExecStartPost hooks. |
How it relates to other services — dependencies. See Dependencies and ordering.
| Field | Purpose |
|---|---|
Requires | Hard dependencies — must be satisfied first; their failure fails this service. |
Wants | Soft dependencies — started first if present, but optional. |
BindsTo | Runtime coupling — if the target stops, this stops too. |
Conflicts | Mutual exclusion — starting this stops the named services. |
OnFailure | Service to start when this one enters Failed. |
How it is kept alive — supervision and health. See Keeping services running.
| Field | Default | Purpose |
|---|---|---|
ErrorControl | Normal | Normal (stay Failed) or Critical (sync + reboot on irrecoverable failure). |
RestartPolicy | OnFailure | Never / OnFailure / Always. |
RestartMaxRetries, RestartWindow, RestartDelay | 5 / 120 / 1 | Restart budget, the window of health that resets it, and the backoff base. |
HealthCheck, HealthCheckInterval, HealthCheckTimeout, HealthCheckRetries | — / 30 / 5 / 3 | Active health-check command and its timing. |
WatchdogTimeout | 0 | Expected interval between WATCHDOG=1 pings; 0 disables. |
The transition phases — hooks and timeouts. See The execution environment and The service lifecycle.
| Field | Default | Purpose |
|---|---|---|
ExecStartPre, ExecStartPost | — | Commands run before the binary / after readiness. |
ExecReload | (SIGHUP) | Reload command or signal:<NAME>. |
StartTimeout, StopTimeout | 30 / 10 | Seconds for the whole start sequence / between SIGTERM and SIGKILL. |
The remaining knobs — scheduling, notify, fds, metadata.
| Field | Default | Purpose |
|---|---|---|
TimerPersistent, TimerJitter | 1 / 0 | Catch up missed timer runs after reboot / random delay per firing. |
NotifyAccess | Main | Who may send sd_notify messages (only Main is supported). |
FdStoreMax | 0 | Size of the per-service fd store; 0 disables it. |
ServiceSecurity | inherit | Security descriptor controlling who may manage the service. |
DisplayName, Description | — | Human-readable labels for status output. |
Forward compatibility #
The schema version lives at Machine\System\Services\SchemaVersion (currently 1). peinit is deliberately forward-compatible:
- Unknown values are ignored. A definition written for a newer peinit does not break an older one.
- A newer schema version does not block boot. peinit logs a warning and continues.
- The schema only grows. New capability arrives as new optional fields, never as a breaking change to an existing one.
One defensive rule cuts the other way: a known field must not appear more than once in a collected definition. A duplicated known field is a validation error, even though the registry would ordinarily give you at most one value per name.
peinit works from a snapshot, not the live registry #
Here is the idea that explains most surprises. peinit does not re-read the registry every time it touches a service. It reads definitions at well-defined moments and operates on an in-memory model in between.
Two layers of snapshotting stack on top of each other:
- Boot generation. At the start of boot, peinit reads all definitions, builds the dependency graph, and validates it. The whole boot runs against that one snapshot. Changes made during boot — by an install script, a post-hook — do not perturb the boot in progress.
- Activation generation. When peinit starts a specific service, it snapshots that service's definition for the entire start. Pre-exec hooks, the token request, the readiness timeout, the first health checks — all use the values captured at activation. Edit a field while the service is
Starting, and the edit waits for the next start.
peinit learns about registry edits through change notifications: it subscribes to Machine\System\Services\ and Machine\System\Init\ at boot, and processes events in its event loop at a time of its choosing. If the notification queue overflows — a bulk admin operation, a flurry of scripted writes — peinit detects the overflow marker and does a full reload-config to resynchronise. You never have to think about the queue; you do have to know that a change is picked up, not pushed, and that when it takes effect depends on the field.
Field mutability: when a change takes effect #
Every field falls into one of four mutability classes. This table is the one to keep handy.
| Class | When a change takes effect | Fields |
|---|---|---|
| Immutable at runtime | Next restart only. | ImagePath, Type, Identity, RequiredPrivileges, ErrorControl |
| Apply on next start | Next start or explicit graph reload — not while running. | Requires, Wants, BindsTo, Conflicts, OnFailure, Conditions, Asserts |
| Hot-reloaded | Next relevant event, no restart. | ServiceSecurity (next control request) |
| Reloadable at runtime | Next relevant operation (restart, health-check cycle, …), no restart. | Arguments, SuccessExitCodes, all timeout/retry values, the health-check fields, RestartPolicy, Environment, WorkingDirectory, the hooks, Readiness, NotifyAccess, LimitNOFILE/LimitCORE, FdStoreMax, SafeMode, DisplayName, Description |
The practical reading: changing what a process is or runs as (ImagePath, Identity, Type, privileges, ErrorControl) is fundamental enough that it only applies when a fresh process starts — you must restart. Changing policy that peinit consults each time it acts (timeouts, restart behaviour, health checks) is picked up the next time peinit acts. And ServiceSecurity is special: it is re-read on every control request, so an access-control change takes effect on the very next command without touching the running service.
For inactive services the rules are simpler, because there is no running process to protect: a new service entry becomes available once the change notification is processed; a changed timer arms on the next evaluation; a changed dependency takes effect on the next start.
Removing a service definition #
Deleting a definition from the registry does not kill a running instance. peinit learns of the removal through the same notification path and behaves according to whether the service is running:
- Not running (Inactive, Failed, Completed, Skipped, Abandoned): peinit discards the in-memory entry immediately. There is nothing to supervise.
- Running (Active, Starting, Reloading, Backoff, Stopping): the running process is a job, and a job outlives its definition. peinit marks the entry definition-removed, keeps the cached definition only to finish supervising the existing instance, and does not restart it when it exits. Once it exits, the entry — and any stored fds — are discarded.
While an entry is definition-removed it keeps satisfying its dependents (it is still running), stop still works so you can drain it cleanly, but start, restart, and reload are rejected with UNKNOWN_SERVICE — there is no definition to start from. A status query reports it with a definition_removed: true flag so the draining instance is never invisible.
Where to start #
To understand the Type and Readiness fields and the Simple/Oneshot split, read Simple and Oneshot services.
To understand how a definition becomes a running, supervised process — and what each state in a status output means — read The service lifecycle.
For the complete type-and-default catalog of every registry key peinit reads, see the Registry key reference.
Simple and Oneshot services
Peios / Using Peios / Services & jobs
A service's type answers one question: does the process keep running, or does it run once and exit? peinit supports exactly two answers — Simple and Oneshot — set by the Type field (default Simple). The type is independent of when the service starts; a Oneshot can be boot-triggered, timer-triggered, or demand-only, exactly like a Simple service.
Simple services #
A Simple service is a long-running daemon. peinit forks, installs the token, and execs the binary. The process is the service: while it runs, the service is running; when it exits, the service has stopped. This is the default and covers the large majority of services — registryd, authd, sshd, application daemons.
The interesting question for a Simple service is when does it count as started — the moment its dependents are allowed to begin. That is the readiness model, set by the Readiness field.
Readiness: Notify vs Alive #
| Readiness | Ready when… | Use for |
|---|---|---|
| Notify (default) | The service sends READY=1 via sd_notify. | Any service that has meaningful startup work — opening a socket, loading state, connecting to a backend. The dependent genuinely should wait. |
| Alive | The process exists — readiness is immediate. | Services with no startup handshake, where "the process is up" is as good a signal as you will get. |
The distinction matters because it is a promise to dependents. With Notify, a service that declares itself a Requires target is telling peinit "do not start anything that needs me until I say READY=1." With Alive, peinit can only promise that the process was forked — not that it is functional.
Once ready, a Simple service transitions to Active and stays there until its process exits or it is stopped. What happens on exit — clean exit, crash, restart — is the subject of The service lifecycle and Keeping services running.
Oneshot services #
A Oneshot service is a run-to-completion task. peinit forks, installs the token, execs the binary, and waits for it to exit. It is the right type for setup work: creating directories, initialising a database, running a schema migration, applying a one-time fixup at boot.
For a Oneshot, readiness is exit, not a signal. The Readiness field is ignored entirely — READY=1 is meaningless for a process whose whole job is to finish. Success and failure are decided by the exit code:
- Exit 0 (or any code listed in
SuccessExitCodes) → the service succeeded. - Any other exit, or death by signal → the service failed, and the failure policy applies.
SuccessExitCodes is for binaries that signal a meaningful outcome with a non-zero code — for example, a tool that exits 2 to mean "nothing to do." Each entry is a decimal 0–255; code 0 is always success and need not be listed.
Completed, and RemainAfterExit #
A successful Oneshot transitions to Completed, and what happens next depends on RemainAfterExit:
RemainAfterExit | After a successful exit |
|---|---|
| 0 (default) | The service passes through Completed — releasing its dependents — then transitions to Inactive. |
| 1 | The service stays in Completed. |
Both forms satisfy dependents; the only difference is what a later status query shows. Use RemainAfterExit=1 when "this task is done" is a state worth seeing — a boot-time migration you want to confirm ran, say — rather than a service that quietly returns to Inactive.
Oneshot timing and hooks #
Two details distinguish a Oneshot's start sequence from a Simple one:
StartTimeoutcovers the entire run. For a Simple service the timeout covers startup until readiness; for a Oneshot it covers everything from the first pre-hook to process exit. A long-running Oneshot must raiseStartTimeoutaccordingly (or sendEXTEND_TIMEOUT_USECas it works — see Keeping services running).ExecStartPostruns only on success. Post-hooks fire after the successful exit. If the Oneshot fails,ExecStartPostdoes not run.
Restarting a Oneshot #
A Oneshot is not restarted on success, regardless of RestartPolicy — even Always. A successful exit is the goal, not a failure to retry. RestartPolicy governs only the response to failure (a non-zero exit). To re-run a Oneshot on a schedule, give it a timer trigger; that is the intended mechanism.
Simple vs Oneshot at a glance #
| Simple | Oneshot | |
|---|---|---|
| Process lifetime | Long-running; is the service | Runs once, exits |
| Readiness | Notify (READY=1) or Alive | Successful exit — Readiness ignored |
| Satisfies dependents when | Active | Completed (with or without RemainAfterExit) |
| Successful end state | Active (until stopped) | Completed, then Inactive (unless RemainAfterExit=1) |
ExecStartPost fires | After readiness | After successful exit only |
StartTimeout covers | Startup until readiness | The whole execution |
| Watchdog & health checks | Supervised while Active | None — WatchdogTimeout/HealthCheck are inert |
| Restart on success | n/a | Never (use a timer to re-run) |
The sd_notify contract #
A Notify-readiness service signals readiness by sending READY=1 over the sd_notify protocol — a datagram to the socket named in the NOTIFY_SOCKET environment variable, which peinit sets for every service. READY=1 is only the most visible message; the same channel carries watchdog keepalives (WATCHDOG=1), graceful-stop notice (STOPPING=1), timeout extensions (EXTEND_TIMEOUT_USEC), status strings (STATUS=), and the fd store. These are covered where they belong — readiness here, the rest in Keeping services running and Controlling services.
Readiness is always per start generation. A READY=1 left over from a previous incarnation of the process is never valid for the current start — peinit ties every notify message to the specific process it is currently supervising, verified by pidfd.
Who is allowed to send these messages is governed by NotifyAccess, which currently has a single value: Main (0, the default). Under Main, only the tracked main PID — the process peinit forked and supervises — is authorised to send sd_notify readiness and status messages; a message arriving from any other PID (a child, a helper, an unrelated process) is ignored. So if a worker your service spawns sends READY=1, peinit will not act on it — the readiness signal must come from the main process itself.
No forking daemons #
peinit does not support services that double-fork to background themselves. The whole reason a traditional daemon double-forks — to detach from the launcher — is moot when the launcher tracks the process it spawned. peinit obtains a pidfd for every process at fork time, which is a kernel handle to that exact process, immune to PID-reuse races. There is no MAINPID= mechanism for a service to redirect supervision onto a different process; peinit supervises what it forked.
If a legacy binary insists on daemonising, the fix lives at the packaging layer — wrap it so it stays in the foreground (commonly a --no-daemon/--foreground flag) — not at the init layer.
Where to start #
To control when either type of service starts, read Triggers and timers.
To follow a service from definition to running process and read its states, read The service lifecycle.
To configure restart, health checks, and watchdogs for a Simple service, read Keeping services running.
Triggers and timers
Peios / Using Peios / Services & jobs
A trigger decides when a service starts on its own. A service's triggers are independent of its type: a boot-triggered Oneshot and a timer-triggered Simple service are both perfectly ordinary. Triggers live in the Triggers field as a list, each entry written type or type:argument.
| Trigger | Form | Meaning |
|---|---|---|
| Boot | boot | Start during the Phase 2 boot sequence, in dependency order. |
| Timer | timer:<schedule> | Start on a schedule. The schedule is a calendar expression. |
A service with no triggers is demand-only: it never starts itself, and is brought up only by an explicit control command or because another service depends on it. Many services are demand-only by design.
Waiting for the boot to settle #
boot:settled is boot with one difference: the service starts once the Phase 2 boot set has stopped moving — every service in it reached a state it will not leave on its own — rather than during it.
It exists for services that write to a terminal. peinit reports its progress to /dev/console, and a service holding that same terminal writes there too, so a prompt started mid-boot gets written over: login emits Username: with no newline and waits, peinit appends a service line, and the result is unreadable until you press Enter and login redraws.
The wait is capped (Machine\System\Boot\SettleTimeout, 5 seconds by default). A service stuck in Starting therefore costs a deferred service a short delay rather than preventing it — which matters most exactly when something is broken and a console prompt is what you need.
A boot:settled service is deliberately not part of the boot plan. It does not consume the parallel-start budget, is not counted towards boot success, and cannot block another service. Waiting for quiet is a preference; it must not change what the boot means.
You can list more than one trigger, including more than one of the same type. ["timer:*-*-* 02:00:00", "timer:*-*-* 14:00:00"] runs at 2 am and 2 pm. The model is built to grow — future trigger types (path, device, event) will slot into the same list without a schema change.
The Disabled flag #
Disabled=1 suppresses automatic activation. A disabled service will not be started by any trigger — boot, timer, or anything added later — but it can still be started by hand through the control interface. It is the switch for "configured, but not running on its own right now."
Two related controls are easy to confuse with it:
| To… | Use |
|---|---|
| Stop a service from auto-starting, but keep manual start | Disabled=1 |
| Stop a service from being started at all, even manually | Deny SERVICE_START in its ServiceSecurity descriptor |
| Stop a running service | The stop command |
Enabling and disabling are registry writes performed by admin tooling, not peinit commands. peinit picks up a Disabled change through a registry notification or on reload-config. A disabled service's definition is still loaded into peinit's model, so it is ready for an on-demand start the instant you ask.
Calendar expressions #
Timer schedules are written in systemd's OnCalendar format. peinit's grammar is normative and self-contained — what follows is the whole language. The general shape is:
DayOfWeek Year-Month-Day Hour:Minute:Second Timezone
Every field is optional and has a default, so most real schedules are short.
| Field | Form | If omitted |
|---|---|---|
| DayOfWeek | weekday name(s) | any day (*) |
| Date | Year-Month-Day | *-*-* (any date) |
| Time | Hour:Minute:Second | 00:00:00 |
| Second | the :Second of Time | :00 |
| Timezone | IANA zone name | system-local time |
So 02:00:00 is a complete expression — date defaults to every day, and it means "every day at 02:00." Hour:Minute with no seconds defaults the seconds to 00.
Component syntax #
Each numeric component — year, month, day, hour, minute, second — and the weekday accept the same operators:
| Operator | Example | Matches |
|---|---|---|
| Wildcard | * | any value |
| List | 1,15 | any listed value |
| Range | Mon..Fri, 8..17 | the inclusive range |
| Repetition | 0/15 | 0, then every multiple of the step — 0, 15, 30, 45 |
| Range + step | 8..17/2 | 8, 10, 12, … up to and including 17 |
Weekdays are English names, case-insensitive, abbreviated (Mon) or full (Monday), and take lists and ranges.
Last day of the month #
A ~ in place of the - between Month and Day counts the day from the end of the month: ~01 is the last day, ~02 the second-to-last, and so on. So *-*~01 is the last day of every month. Repetition combines with it — Mon *-05~07/1 is "the last Monday in May."
Named shortcuts #
These stand in for the expression shown:
| Shortcut | Equivalent |
|---|---|
minutely | *-*-* *:*:00 |
hourly | *-*-* *:00:00 |
daily | *-*-* 00:00:00 |
weekly | Mon *-*-* 00:00:00 |
monthly | *-*-01 00:00:00 |
quarterly | *-01,04,07,10-01 00:00:00 |
semiannually | *-01,07-01 00:00:00 |
yearly / annually | *-01-01 00:00:00 |
Timezones and precision #
A timezone specifier follows the IANA database — Europe/London, US/Eastern, UTC. With none, the expression is interpreted in system-local time.
Precision is second-level. Unlike systemd, peinit does not accept sub-second fractions; a fractional second is a parse error. Service scheduling has no use for finer granularity.
Examples #
| Expression | Meaning |
|---|---|
*-*-* 02:00:00 | Every day at 2 am (system-local) |
Mon *-*-* 00:00:00 | Every Monday at midnight |
*-*-1,15 12:00:00 | 1st and 15th of each month at noon |
*-*~01 00:00:00 | Last day of each month at midnight |
Mon..Fri *-*-* 09:00:00 | Weekdays at 9 am |
*-*-* *:00/15:00 | Every 15 minutes |
*-*-* 02:00:00 Europe/London | Every day at 2 am London time |
What happens when a timer fires #
A timer firing does not blindly start the service — peinit considers the service's current state first, so a firing never collides with a run already in progress.
| Type | Current state | Action |
|---|---|---|
| Oneshot | Inactive / Completed / Failed | Start it (operation source Timer). |
| Oneshot | Active / Starting | Set a single pending flag — one catch-up run when the current one finishes. |
| Simple | Inactive / Failed | Start it. |
| Simple | Active / Starting | No-op; the firing is logged. |
The Oneshot "pending" flag is deliberately not a queue: many firings that land while a Oneshot is busy collapse into one catch-up run, taken when the current run ends. A service cannot stampede itself.
That single flag is shared across all of a Oneshot's timers, not one per timer. If a Oneshot has several timer triggers and two of them fire while it is busy, at most one run is still pending when the current one finishes. The timers are otherwise fully independent — each keeps its own next-firing time and its own last-run timestamp — but they can only ever queue up a single shared catch-up between them.
Each timer firing creates a normal start operation, so a timer-started service is observable exactly like an admin-started one — same GUIDs, same events.
Persistent timers and catch-up #
TimerPersistent (default 1) controls whether runs missed while the machine was off are caught up after a reboot.
peinit records the last-run timestamp of each timer in the registry. On boot, for each persistent timer it reads that timestamp, computes the next firing after it, and — if that time is already in the past — fires once, immediately, then resumes the normal schedule.
The catch-up is always a single run, never one-per-missed-occurrence. A daily timer that was off for five days fires once on the next boot, not five times. A non-persistent timer (TimerPersistent=0) ignores history entirely and simply computes its next future firing from now.
One subtlety worth knowing: the last-run timestamp is keyed by the schedule string. If you change a timer's schedule, the old timestamp is orphaned and the new schedule has no history, which causes exactly one spurious catch-up run. Schedule changes are rare and the cost is one extra run, so peinit accepts it rather than tracking trigger identity.
Jitter #
TimerJitter (default 0) adds a random delay of 0 to TimerJitter seconds to each firing, recomputed every time. A daily timer with TimerJitter=900 fires at a slightly different moment between 00:00 and 00:15 each day. Jitter only ever delays — a timer never fires before its scheduled time. It is the standard remedy for a fleet of machines all hammering the same backend at exactly midnight.
When the clock jumps #
Calendar timers are wall-clock schedules: *-*-* 02:00:00 means "02:00 on the wall," not "every 86,400 seconds." peinit arms them as absolute real-time timers that are cancelled and recomputed whenever the system clock is stepped, so the schedule stays anchored to the wall clock across corrections.
| Event | Behaviour |
|---|---|
| NTP step or manual clock set | Armed calendar timers are cancelled and recomputed against the new wall clock. A backward step pushes the next firing later; a forward step that crosses a missed time fires it once. |
| Suspend / resume | A scheduled time that elapsed while suspended fires once on resume — once, not once per missed occurrence. |
| A missed occurrence within one uptime | Fire once, then resume the normal schedule. peinit never replays every occurrence in the gap. |
| Daylight-saving transition (timezone-aware timer only) | A scheduled time in the skipped hour never fires; a scheduled time in the repeated hour fires once. See below. |
Daylight-saving is the case most likely to surprise you, and it only affects timers that name an IANA zone that observes DST (a bare 02:30:00 in system-local time follows whatever the system does). Say you schedule *-*-* 02:30:00 Europe/London:
- Spring forward — the clock jumps from 01:59 straight to 03:00, so 02:30 does not exist on that day. The timer does not fire; it simply resumes at the next valid
02:30. - Fall back — the clock reaches 02:30, then rewinds and reaches 02:30 a second time. The timer fires exactly once, on the first occurrence, not twice.
This is distinct from peinit's interval timers — the watchdog, health-check intervals, restart backoff, and the start/stop/reload phase timeouts. Those are genuine durations measured on the monotonic clock, so "wait 30 seconds" means 30 elapsed seconds and never lurches when the wall clock is set.
There is one boot-time case worth calling out on its own. When peinit reads a timer's stored last-run timestamp at boot and finds it is in the future relative to the current wall clock — meaning the clock has gone backwards since that run — it can't trust the timestamp, so it treats the timer as having an unknown last run and fires the persistent catch-up immediately. This only applies to the boot-time check; the runtime handling above is unaffected.
Where to start #
To see how a timer-started run appears as a job and an operation, read Jobs and operations.
To understand the states a timer-started service moves through, read The service lifecycle.
For the exact registry keys that store last-run timestamps, see the Registry key reference.
The service lifecycle
Peios / Using Peios / Services & jobs
A service managed by peinit is, at every instant, in exactly one state. The state is what a status query reports, what gates dependents, and what decides which commands are valid. Alongside the state, peinit records the cause of the most recent transition — the why behind the where. Reading a status is reading these two things together: "Failed, because RestartBudgetExhausted" tells a very different story from "Failed, because ValidationError."
This page is the reference for both. It is worth internalising before Controlling services and Troubleshooting, because both lean on it.
The states #
| State | Process? | Satisfies dependents? | What it means when you see it |
|---|---|---|---|
| Inactive | No | No | Not started, or stopped cleanly and not set to restart. The neutral resting state. |
| Starting | Maybe | No | Activation in progress — conditions, hooks, fork, or readiness wait. Dependents are blocked. |
| Active | Yes | Yes | Running and ready. The normal state of a healthy Simple service. |
| Reloading | Yes | Yes | Re-reading configuration. Still counts as running. |
| Stopping | Yes (briefly) | No | SIGTERM sent; waiting for exit or SIGKILL escalation. |
| Completed | No | Yes | Oneshot finished successfully. Stays here only with RemainAfterExit=1. |
| Backoff | No | No | A restart is pending; the service is waiting out its backoff delay before the next attempt. |
| Failed | No | No | Exited abnormally and restart policy is exhausted or not configured. |
| Abandoned | Yes (unkillable) | No | SIGKILL was sent but the process survived in uninterruptible sleep (D-state). peinit has given up supervising it; its cgroup is leaked. |
| Skipped | No | Yes | A start-time condition was not met. The service does not apply here. |
Three of these — Active, Completed, Skipped — satisfy dependents. Everything else blocks them. That single column is the rule the whole dependency system turns on: a service waiting on a Requires target does not move until that target reaches one of those three.
The common path #
Most of a service's life is a small loop. The diagram below shows the states a healthy Simple service moves through, plus the two ways it leaves Active.
stateDiagram-v2
[*] --> Inactive
Inactive --> Starting: start / trigger / dependency
Starting --> Active: ready (READY=1 or alive)
Active --> Stopping: stop / shutdown / conflict
Stopping --> Inactive: exited (clean stop / shutdown)
Stopping --> Failed: exited (conflict / BindsTo eviction)
Active --> Backoff: crash + restart allowed
Backoff --> Starting: backoff delay elapsed
Active --> Failed: crash + no restart
Starting --> Failed: timeout / hook / setup failure
Failed --> Starting: explicit start
A few things this picture makes concrete:
- An automatic restart always routes through Backoff, never through Failed.
Backoff → Startingis the retry;Failedis reached only when restarts are exhausted or disabled. This is whyOnFailurefires once at the end, not on every retry. Startingcan fail before any process exists — a parent-side setup error, a failed pre-hook, a condition or assert. The cause records which.Failed → Startingis how a manualstart(or a recovery path) revives a dead service; automatic restarts never originate fromFailed.Stoppinghas two exits. A clean stop or a shutdown lands inInactive. But a service stopped because it lost aConflictsrace (ConflictEviction) or because itsBindsTotarget went away (BindsToPropagation) comes to rest inFailed, carrying that cause — so an evicted or bound-out service shows as Failed instatusand needs areset(or, for a bound service, its target returning) before it starts again. It was not shut down on purpose, so peinit does not treat it as cleanly Inactive.
Oneshot services follow a parallel path through Completed instead of Active: Starting → Completed, and then either staying there (RemainAfterExit=1) or passing through to Inactive. See Simple and Oneshot services.
Transition causes #
Every transition carries a cause. peinit keeps the cause of the most recent transition on the service, and emits all of them to eventd. The full taxonomy, grouped by what kind of thing happened:
Why a service started
| Cause | Meaning |
|---|---|
ExplicitStart | An administrator or a trigger started it. |
DependencyStart | Started to satisfy another service's dependency. |
RestartPolicy | An automatic restart, after a backoff delay. |
BindsToRecovery | A bound target returned to Active; the dependent is auto-restarted. Does not consume the restart budget. |
Why a service stopped
| Cause | Meaning |
|---|---|
ExplicitStop | An administrator requested stop. |
ConflictEviction | A conflicting service started and won. |
BindsToPropagation | A bound target stopped, so this one stops too. |
ShutdownWave | The system is shutting down. |
Why a service failed or restarted — the diagnostic causes
| Cause | Meaning |
|---|---|
ProcessCrash | The main process exited non-zero or died on a signal. |
CleanExit | A Simple process exited successfully, and policy is not Always — not a crash; goes straight to Inactive. |
CleanExitRestart | A Simple process exited successfully but RestartPolicy=Always, so it is restarted. Logged clearly as not a crash. |
ReadinessTimeout | StartTimeout expired before the service became ready. |
WatchdogTimeout | A WATCHDOG=1 keepalive did not arrive in time. |
HealthCheckFailure | HealthCheckRetries consecutive health checks failed. |
PreHookFailure | An ExecStartPre hook exited non-zero. |
ParentSetupFailure | peinit could not even fork — resource exhaustion, cgroup error. No child was created. |
PreExecFailure | Setup after fork but before exec failed (token install, rlimits, environment). |
RestartBudgetExhausted | RestartMaxRetries reached within the window; no more retries. |
Why a service was rejected — definition and graph problems
| Cause | Meaning |
|---|---|
ValidationError | The definition failed validation (bad field, unresolvable conflict, illegal health-check timing…). |
CycleDetected | The service is part of a dependency cycle. |
DependencyFailure | A Requires dependency entered Failed or does not exist. |
AssertionError | A start-time Assert failed. |
ConditionSkipped | A start-time Condition was not met → Skipped (this is not a failure). |
ProcessUnkillable | The process survived SIGKILL (D-state) → Abandoned. |
ExplicitReset | An administrator cleared Failed/Abandoned/Skipped without starting. |
The grouping matters for what peinit does next: the diagnostic causes are mostly restart-eligible (peinit consults restart policy), whereas the definition/graph causes are never restarted — retrying a broken definition cannot help. The distinction is spelled out in Keeping services running.
Readiness gating during boot #
The dependent-satisfaction rule is what makes boot orderly. A service's dependents do not start until it satisfies them:
- A Simple service satisfies dependents when it signals
READY=1(Notify) or when its process exists (Alive). - A Oneshot service satisfies dependents when it exits successfully and reaches Completed.
- A Skipped service satisfies dependents immediately — it succeeded by not needing to run.
If a service does not reach readiness within its StartTimeout, its dependents diverge by relationship: Requires dependents transition to Failed (DependencyFailure); Wants dependents start anyway. That difference is the whole point of the two relationship types — see Dependencies and ordering.
The Abandoned state #
Abandoned is the one state that reflects a kernel-level problem rather than a service-level one. peinit reaches it when it has sent SIGKILL to a service's process group but the processes are still there after a grace period — the post-kill timeout, 5 seconds by default. If the service's cgroup has not emptied within it, the processes are wedged in uninterruptible kernel sleep (D-state), typically behind a hung mount or a broken storage controller, and the service is marked Abandoned (its cgroup leaked).
peinit cannot kill a D-state process; nothing in userspace can. So it stops trying: it marks the service Abandoned, leaks the cgroup (it cannot be removed while populated), and moves on rather than hanging. The leak is never silent — it shows up in the service's warnings and, on a later start, peinit creates a fresh "generational" cgroup so the new instance is unaffected by the stuck old one.
An Abandoned service is cleared with reset, which re-checks the cgroup: if it finally emptied, peinit cleans up; if it is still populated, peinit leaves it leaked and warns you. An Abandoned service is a sign of an underlying I/O fault that needs investigating, not something to paper over with a restart.
Invariants #
A handful of rules hold without exception:
- A service is in exactly one state at any moment.
- Only the transitions peinit defines are valid — there are no others.
- Only peinit changes a service's runtime state. No external process can reach in and set it.
- A service's securable identity (its ServiceSecurity descriptor — who can manage it) is independent of its process identity (its token — what it can access).
- Readiness is per start generation — a
READY=1from a previous incarnation is never honoured for the current start.
Where to start #
To understand what happens after a service crashes — restart policy, backoff, health checks, watchdogs — read Keeping services running.
To understand how dependents block on and are released by these states, read Dependencies and ordering.
To see which commands are valid in which state, read the command × state matrix in Controlling services.
Keeping services running
Peios / Using Peios / Services & jobs
Once a service is Active, peinit's job becomes keeping it that way — or deciding, deliberately, when to stop trying. That decision is governed by a small set of policies: a restart policy with a throttling budget, optional active health checks, an optional watchdog, and an error-control level that decides how serious a final failure is. This page covers all of them.
Restart policy #
When a service fails in a way that might be transient, peinit consults RestartPolicy:
| Policy | Value | Behaviour |
|---|---|---|
| Never | 0 | Never restart. The service goes straight to Failed. |
| OnFailure (default) | 1 | Restart on a crash or runtime failure. An exit code in SuccessExitCodes is not a failure and is not restarted. |
| Always | 2 | Restart on any failure, and — for a Simple service — restart even a successful clean exit (see clean exits). |
Not every failure is restart-eligible. peinit splits causes into three groups:
- Restart-eligible — peinit consults the policy and budget:
ProcessCrash,WatchdogTimeout,HealthCheckFailure,ReadinessTimeout,PreHookFailure,PreExecFailure,ParentSetupFailure. (The startup failures are eligible on purpose — a pre-hook that failed on a momentarily-missing mount deserves another try.) - Never restarted — retrying cannot help: an explicit stop or reset, shutdown, conflict eviction, a broken definition (
ValidationError,CycleDetected,DependencyFailure,AssertionError), an already-exhausted budget, or an unkillable process. - Budget-exempt —
BindsToRecovery, where a bound service came back and its dependents are revived. The dependent did not fail on its own, so this restart does not count against its budget.
The budget and backoff #
Restarting an instantly-crashing service in a tight loop is worse than useless, so every restart is throttled by two mechanisms working together.
Exponential backoff. Before each restart the service sits in the Backoff state for a delay that doubles on each consecutive failure, starting from RestartDelay (default 1 s) and capped at 60 s. So a crash-looping service backs off 1 s, 2 s, 4 s, 8 s, … up to a minute between attempts.
The budget. A consecutive-failure counter tracks how many times the service has failed in a row. Once it reaches RestartMaxRetries (default 5), the next failure is not restarted: the service transitions to Failed with cause RestartBudgetExhausted.
The reset. The counter resets to zero once the service stays Active for RestartWindow seconds (default 120) without failing. This is the crucial detail: the budget is not "N restarts ever" — it is "N restarts faster than the service can sustain RestartWindow of health." A service that crashes once a day and recovers cleanly each time never exhausts its budget, because every crash starts from a counter of zero. Only failures recurring faster than the service can stay healthy accumulate.
flowchart LR
A["Active"] -->|crash| B{"budget left?"}
B -->|yes| C["Backoff<br/>delay doubles"]
C -->|delay elapsed| D["Starting"]
D -->|ready, stays Active<br/>RestartWindow| A
B -->|no| E["Failed<br/>RestartBudgetExhausted"]
Clean exits #
For a Simple service, exiting with code 0 (or a SuccessExitCodes match) is success, not a crash — a daemon chose to quit. What peinit does next depends on the policy:
- Under
NeverorOnFailure, the cause isCleanExitand the service goes straight to Inactive, with no restart-policy consultation at all. - Under
Always, the cause isCleanExitRestartand the service is restarted — but through the same backoff and budget as a failure, so a daemon that exits cleanly in a tight loop cannot bypass throttling. Logs and status make clear it exited successfully and was restarted only because the policy is Always.
(A Oneshot clean exit is never restart-eligible regardless of policy — see service types.)
Health checks #
A live process is not always a working one — it can lose its database connection, wedge in a bad state, or return errors while still "up." An active health check closes that gap by running a command periodically and treating its exit code as a verdict: 0 is healthy, non-zero is a failure.
Health checks apply to Simple services only. peinit starts a service's health-check timer once the service becomes Active; a Oneshot has no long-running process to probe, so it never has one. HealthCheck and its companion fields are inert on a Oneshot — no timer is started — so don't set them there expecting supervision.
| Field | Default | Controls |
|---|---|---|
HealthCheck | — | The command to run (runs under the service's own token). |
HealthCheckInterval | 30 | Seconds between runs. |
HealthCheckTimeout | 5 | Seconds before a check is killed and counted as failed. |
HealthCheckRetries | 3 | Consecutive failures before the service is declared unhealthy. |
HealthCheckRetries consecutive failures mark the service unhealthy and restart it through the normal restart policy — backoff and budget included. A single success resets the failure count. If a check is still running when the next interval fires, the new one is skipped rather than stacked.
The health field in a status response reflects this: healthy, unhealthy (failing but not yet failed out), unknown (configured, no result yet), or null (no health check).
The flap guardrail. Health-check throttling only works if a failure cycle is shorter than the restart window — otherwise the counter resets between failures and the service restarts forever. peinit enforces this at validation time as a hard rule:
HealthCheckRetries × HealthCheckInterval < RestartWindow
A definition that violates it is a validation error, not a warning — the service is marked Failed with ValidationError rather than allowed to restart indefinitely.
A health check stuck in D-state is handled like any other stuck helper: its sub-cgroup is leaked and surfaced as a warning, but — unlike a stuck main process — it does not push the service to Abandoned. A health check holds no service resources; it is only a probe.
The watchdog #
The watchdog inverts the health check: instead of peinit asking the service if it is alive, the service must periodically tell peinit. Set WatchdogTimeout (seconds; 0 disables) and the service must send WATCHDOG=1 via sd_notify at least that often. Miss the deadline and peinit treats it as a failure (cause WatchdogTimeout) and applies the restart policy.
Like health checks, the watchdog is a Simple-only supervision mechanism. peinit arms the watchdog timer only once a Simple service becomes Active; on a Oneshot WatchdogTimeout is inert — no timer is started, and setting it changes nothing.
A service can adjust its own watchdog at runtime by sending WATCHDOG_USEC=<microseconds>: a positive value re-arms the timer with the new interval immediately; 0 disables the watchdog. This is for services whose phases have different latencies — a database might run a tight 5 s watchdog normally but widen it to 60 s during a compaction pass, because it knows its own phases better than the schema author did. The runtime value does not persist: a restart reverts to the schema's WatchdogTimeout.
Timeout extension #
A service that does genuinely slow work during a transition can ask for more time by sending EXTEND_TIMEOUT_USEC=<microseconds> while it is Starting, Stopping, or Reloading. Each message replaces the current phase deadline (it is not additive); a service that keeps sending them keeps proving it is making progress, and if it stops, the last deadline fires and peinit escalates normally.
The extension is capped to prevent a buggy service from extending forever:
| Phase | Base timeout | Maximum deadline |
|---|---|---|
| Starting | StartTimeout | StartTimeout × 4 |
| Stopping | StopTimeout | StopTimeout × 4 |
| Reloading | StartTimeout | StartTimeout × 4 |
During shutdown a second, global cap also applies — the deadline can never exceed the remaining time in the global shutdown timeout, and the stricter of the two caps wins. A message received outside a transition (the service is Active, Failed, …) is ignored — there is no timeout to extend.
OnFailure #
When a service enters Failed, an OnFailure field names another service to start in response — a fallback for graceful degradation (the main web UI failed; bring up a minimal emergency endpoint). It is not a monitoring or alerting hook; that is eventd's job.
OnFailure fires for runtime failures — ProcessCrash, WatchdogTimeout, HealthCheckFailure, the startup-failure causes, and non-Critical budget exhaustion. It deliberately does not fire for:
ShutdownWave— nothing new starts during shutdown.ValidationError,CycleDetected,DependencyFailure,AssertionError— these are definition or graph breakage, not a running service degrading. The fallback would likely sit in the same broken graph anyway.
Because a fallback can itself fail and carry its own OnFailure, peinit bounds the chain: it tracks which services it has already started for one originating failure, refuses to start one twice, and never follows the chain beyond a depth of 16. When the guard trips, it logs the loop and stops.
ErrorControl: when failure is not an option #
ErrorControl decides how serious an irrecoverable failure is:
| Level | Value | On irrecoverable failure |
|---|---|---|
| Normal (default) | 0 | The service stays in Failed. The rest of the system carries on. |
| Critical | 1 | peinit syncs filesystems and reboots immediately. |
A Critical service is one the system genuinely cannot run without — the platform daemons registryd, authd, lpsd, eventd are all Critical. "Irrecoverable" means the restart budget was exhausted: peinit tried, backed off, retried up to RestartMaxRetries, and the service still would not stay healthy. At that point the fastest path to a known-good system is a reboot.
A few consequences worth holding onto:
- Critical failure outranks
OnFailure. When a Critical service exhausts its budget, peinit reboots; it does not start theOnFailurehandler. ErrorControl=CriticalimpliesSafeMode. A Critical service is always eligible to start in Safe mode.- Critical services are OOM-protected. peinit sets their
oom_score_adjto-1000, so the kernel will not pick them as out-of-memory victims. - A runtime Critical crash does not enter Safe mode — it follows the reboot path. Safe mode is only for boot-time configuration errors (a cycle or conflict involving a Critical service). The escalation between reboots, the boot-attempt counter, and Recovery mode are covered in Boot and boot modes.
Where to start #
To see exactly which states these policies move a service through, read The service lifecycle.
To understand how a failed service propagates to the ones that depend on it, read Dependencies and ordering.
When a service will not stay up, work backward from the symptom in Troubleshooting peinit.
Dependencies and ordering
Peios / Using Peios / Services & jobs
Services rarely stand alone. A web app needs its database; a network daemon needs the loopback interface up; two implementations of the same role must never run at once. peinit expresses all of this with four relationship fields — Requires, Wants, BindsTo, and Conflicts — that together decide the order services start in, what happens when one stops, and how a failure spreads to its neighbours.
The relationships are declared on the dependent (the service that needs something), naming the target (the service it needs) by name. Conflicts is the exception — it is symmetric, and one side declaring it is enough.
The four relationships #
Requires — a hard dependency #
If A Requires B:
- Start. B must reach a satisfying state before A starts. If B is not running, peinit starts it first (cause
DependencyStart). If B fails, A is marked Failed withDependencyFailureand never even attempts to start. - Stop. Stopping B does not stop A. Requires is a start-ordering constraint, not a runtime leash — A keeps running.
- Runtime failure. If B crashes while A is already Active, A is unaffected. B's own restart policy handles B's recovery.
Requires is the workhorse: "I need this to have started, and if it can't, neither can I."
Wants — a soft dependency #
If A Wants B:
- Start. peinit starts B before A if B exists and is not Disabled — but if B fails to start, or does not exist, A starts anyway.
- Stop / failure. No effect either way. A and B are runtime-independent.
Wants is best-effort ordering: "start this first if you can, but I work without it."
BindsTo — a runtime coupling #
If A BindsTo B:
- Start. Identical to Requires — B must satisfy before A starts.
- Stop. If B stops for any reason — explicit stop, conflict, crash, shutdown — A is stopped too, transitioning to Stopping with cause
BindsToPropagation. When A finishes stopping it comes to rest in Failed (carryingBindsToPropagation), not Inactive — so astatusquery shows the bound service as Failed, making it clear it was taken down by its target rather than shut down cleanly. - Recovery. When B returns to Active, peinit automatically restarts A from that Failed state (cause
BindsToRecovery). This is reactive — peinit watches B's transitions — and it is budget-exempt: A did not fail on its own, so the restart does not count against its budget. If B never comes back, A stays Failed until youreset(or start) it.
BindsTo is Requires plus a runtime leash: "I need this to start, and I should not outlive it." It is the right choice for a sidecar that is meaningless without its principal. BindsTo implies Requires; listing both for the same target is harmless, and BindsTo semantics win.
Conflicts — mutual exclusion #
If A Conflicts with B:
- Start. Starting A while B is Active creates a stop operation for B (source
ConflictResolution), evicting it (causeConflictEviction) before A starts — and vice versa. If the loser will not stop within itsStopTimeout, SIGKILL escalation applies. The evicted loser does not land in Inactive: it comes to rest in Failed (carryingConflictEviction), so it shows as Failed instatusand needs aresetbefore it will start again. That is deliberate — an evicted service is not a clean stop, and leaving it Failed stops it from quietly restarting straight back into the conflict. - Symmetry. Conflicts is two-way. If A declares
Conflicts=["B"], starting either stops the other; B need not declare it back.
Conflicts is for true mutual exclusion — two services binding the same port, or two implementations of one role where exactly one must run — not for ordinary resource contention.
At a glance #
| Start order | Target failure stops dependent? | Target stop stops dependent? | |
|---|---|---|---|
| Requires | target first; dependent fails if target fails | At start time only | No |
| Wants | target first if present; dependent starts regardless | No | No |
| BindsTo | target first; dependent fails if target fails | Yes (and auto-recovers) | Yes (and auto-recovers) |
| Conflicts | starting one evicts the other | n/a | n/a |
Graph validation #
Before peinit starts anything, it builds the dependency graph and validates it. Validation runs once per graph build — at boot for the whole boot graph, and per request for an on-demand start's transitive closure. It is not incremental.
Cycles. peinit topologically sorts the graph; if the sort fails, there is a cycle. Every service in the cycle is marked Failed with CycleDetected, and peinit logs the full path (dependency cycle: A → B → C → A) so you can find and break it. A service may not depend on itself — a self-reference is rejected as a cycle. If any service in the cycle is ErrorControl=Critical, peinit downgrades to Safe mode rather than rebooting, because a reboot would just hit the same cycle.
Missing targets. What a missing target means depends on the relationship:
| Relationship | Missing or disabled target |
|---|---|
Requires | Dependent marked Failed (DependencyFailure). |
BindsTo | Treated as a missing Requires — Failed (DependencyFailure). |
Wants | Silently ignored — the entry is dropped. |
Conflicts | Silently ignored — nothing to conflict with. |
Unresolvable conflicts. If two boot-triggered services conflict with each other, there is no way to honour both, so both are marked Failed with ValidationError. As with cycles, if either is Critical, Safe mode applies.
Warnings. Some conditions are logged but do not block boot — most notably a Readiness=Alive service that others Requires (see service types). Warnings appear in the logs and do not change state.
Validation errors. Some conditions do fail the service — for example a health-check configuration that violates the flap constraint. These mark the service Failed with ValidationError.
When more than one finding applies to the same service, peinit records a single primary cause by precedence — CycleDetected > ValidationError > DependencyFailure — but it logs all findings, so the primary cause never hides the others.
Parallel start #
After validation, peinit starts services whose dependencies are all satisfied, and it does so in parallel up to a configurable limit:
| Registry key | Default | Meaning |
|---|---|---|
Machine\System\Boot\MaxParallelStarts | 10 | Maximum services starting concurrently. |
The scheduler is simple: every service with no unsatisfied dependencies is eligible; peinit starts up to MaxParallelStarts of them; as each one reaches a satisfying state its dependents become eligible and join the queue. The result is that independent subtrees of the graph come up at the same time, while ordering constraints are still honoured exactly.
Failure propagation #
When a service enters Failed during graph execution, the failure spreads along Requires and BindsTo edges — and only those:
- Every service that
Requiresthe failed one transitions to Failed withDependencyFailure. - Every service that merely
Wantsit is unaffected and starts normally. - Propagation is transitive: if A requires B and B requires C, and C fails, then B fails, then A fails — each with
DependencyFailure.
This is the payoff of the Requires/Wants distinction. A hard dependency failing takes its dependents down with it; a soft one failing is shrugged off. Choosing the right relationship is choosing how far a failure is allowed to travel.
On-demand starts #
When you start a service explicitly rather than at boot, peinit does the same graph work on a smaller scope — the requested service's transitive closure:
- Collect all transitive
RequiresandBindsTodependencies (and best-effortWants). - Validate that sub-graph (cycles, missing targets).
- Resolve
Conflicts— stop anything that conflicts. - Start the sub-graph with the same parallel scheduler.
Anything already in a satisfying state (Active, Completed, Skipped) is left alone — its dependency is already met, so there is no needless restart. Dependencies pulled in this way start with cause DependencyStart. If two on-demand starts need the same dependency at once, their start operations merge rather than racing.
Shutdown reverses the graph #
peinit does not need a separate stop-ordering configuration. Shutdown simply reverses the dependency graph: services with no dependents stop first, and services that others depend on stop last. A service is never stopped until everything that Requires or BindsTo it has already stopped.
The very last services to stop are the TCB daemons everything rests on — eventd, then authd, then lpsd, then registryd — with registryd stopped dead last, mirroring its position as the first service ever started. The full shutdown sequence is in Shutdown.
Where to start #
To see the states services move through as they start and stop in order, read The service lifecycle.
To understand how a target's failure interacts with the dependent's own restart policy, read Keeping services running.
To follow how dependency-driven starts appear as operations, read Jobs and operations.
Service identity and privileges
Peios / Using Peios / Services & jobs
Every service process runs under a KACS token — the kernel object that carries identity into every system call and is the input to every access decision. peinit's responsibility is to get the right token for a service and install it on the child process before the binary execs, so the service runs as the intended principal from its very first instruction.
One rule frames everything else: peinit never shares its own SYSTEM token with a service. Even a service configured Identity=SYSTEM gets a separately materialised token of its own — never peinit's. This keeps every service's identity independent and auditable, and it is one of peinit's security invariants.
How a token is materialised #
The Identity field decides where the token comes from:
Identity value | Token source |
|---|---|
SYSTEM | Minted by peinit from its own SYSTEM identity. |
| Any other principal name or SID | Requested from authd. |
| Absent or empty | authd, defaulting to LocalService. |
The SYSTEM path #
For a SYSTEM service, peinit mints an independent SYSTEM token using its own token as the template: it reads its own identity (user SID S-1-5-18, the group list, the privilege set) and mints a fresh primary token with the same identity, adding the service's per-service SID. The minted token is fully independent — the privilege trimming that follows affects only this token, never peinit's.
This path exists because of a bootstrapping problem: the platform services that make the normal token flow possible cannot use it, because they have to start before it exists. registryd, authd, lpsd, and eventd all run as SYSTEM and are all minted this way during boot, before authd is available to mint anything.
The authd path #
For any other identity, peinit asks authd for a token: it sends the Identity value verbatim, and authd routes it to the right source —
- Well-known principals (
LocalService,NetworkService, …) → a built-in identity with a predefined minimal privilege set; - Local service accounts → lpsd, the local principal store;
- Domain accounts → the directory connector;
— resolves the principal's SIDs, mints a token, creates a logon session, and hands the token back to peinit to install. peinit neither knows nor cares whether the identity is local or domain; routing is authd's job.
The per-service SID #
Every service token — minted or authd-issued — carries a per-service SID in its group list. It uses authority S-1-5-80 and is derived deterministically from the service name: the SHA-1 of the uppercased name (as UTF-16LE), with the digest split into five sub-authorities. authd adds it automatically for the tokens it mints; peinit computes and adds it itself for SYSTEM tokens, no authd involvement needed.
The point of the per-service SID is fine-grained access control even when services share an identity. A dozen services might all run as LocalService, but each has a unique service SID, so an ACL can grant access to exactly one of them without inventing a dedicated account. It is also what keeps the platform services — all running as SYSTEM — distinguishable to AccessCheck. See Well-known principals for the SID landscape this fits into.
Privilege restriction #
A token comes from its source (authd or the SYSTEM mint) with a default set of privileges. RequiredPrivileges lets a definition trim that set down to only what the service needs:
- peinit reads the
RequiredPrivilegesallow-list and removes every privilege not on it from the token before exec. - Restriction is purely subtractive. peinit removes privileges; it never adds them. A service cannot acquire a privilege its token's source did not grant.
- If
RequiredPrivilegesis absent, the token's default privilege set is used unchanged.
This is least-privilege made concrete: eudev, for instance, runs as SYSTEM (it needs device access during early boot) but with its privilege set stripped to the minimum it actually uses, so a compromise of eudev does not hand an attacker the full SYSTEM privilege set. Removal is permanent for the life of that token — it is not a disable that the service can re-enable. See Privileges for what the individual privileges grant.
Which identity runs what #
A service is not just its main process — it has hooks, health checks, and a reload command, and each runs under a defined identity:
| Context | Runs as |
|---|---|
| Main process | The service's Identity. |
ExecStartPre / ExecStartPost | HookIdentity if set, otherwise the service's Identity. |
| Health checks | The service's Identity, always. |
ExecReload (command form) | The service's Identity, always — HookIdentity does not apply. |
| Ad-hoc jobs | The token captured by JFS — the submitter's (possibly impersonated) identity. |
Token materialisation for hooks follows the same rules: a HookIdentity of SYSTEM is minted; any other principal is requested from authd, at the point the hook runs.
HookIdentity exists so hooks can run with different — usually higher — privileges than the service itself: creating directories in privileged locations, or running a database migration as an admin identity, before the long-running daemon drops to a lesser identity.
Security invariants #
The identity model rests on a few rules peinit never breaks:
- peinit never shares its SYSTEM token. Even
Identity=SYSTEMservices get a separately minted token. - peinit never drops its own SYSTEM identity. PID 1 runs as SYSTEM for the life of the system — its identity is axiomatic, granted by the kernel at boot.
- Privileges are subtractive only.
RequiredPrivilegesremoves; it can never add. - Identity is deterministic. Every service runs as a known principal.
SYSTEMmust be declared explicitly; an emptyIdentityis the minimalLocalService, never SYSTEM.
Where to start #
Identity decides what a service can do; the ServiceSecurity descriptor decides who can manage it — two independent concerns. Read Who can manage a service for the other half.
To see how the token is installed alongside the rest of the process setup, read The execution environment.
For the identity primitives themselves — tokens, SIDs, privileges — start at Tokens.
The execution environment
Peios / Using Peios / Services & jobs
When peinit starts a service, the process it hands you is built deliberately — a clean context with a known identity, a known environment, and only the resources peinit chose to give it. This page covers everything about that context except the token (its own page): the cgroup the process lives in, its standard streams, the environment it sees, the limits on it, and the hooks and checks that run around the start.
A clean context #
A service inherits only what peinit explicitly hands it — its standard streams and any stored file descriptors. Every other descriptor peinit holds — the control socket, the notify socket, the event-loop fd, the registry and authd and eventd connections — is opened close-on-exec, so it closes automatically at exec and can never leak into a service. peinit also resets the child's signal state to defaults (it runs with all signals blocked for its own signalfd; a service must not inherit that). The guarantee is that a service starts from a clean slate, not from peinit's privileged one.
The cgroup tree #
Every service runs in its own cgroup tree under /sys/fs/cgroup/peinit/, with separate sub-cgroups for the main process, hooks, and health checks:
/sys/fs/cgroup/peinit/<service>/
├── main/ the main process
├── hooks/ pre/post hooks
└── health/ health checks
peinit uses cgroups for two things only: tracking every process a service spawns (so a child that forks its own children is still accounted to the service), and clean kill (terminating the entire tree at once, so nothing is orphaned). It does not use cgroups for resource accounting or limits.
The split into sub-cgroups serves a real purpose: it satisfies cgroup v2's "no internal processes" rule, and it lets peinit kill a service's hooks or health checks without touching the main process.
When an old tree cannot be removed — because a process survived SIGKILL in D-state and leaked it — peinit creates a fresh generational tree (<service>.gen<N>) for the next start, so a stuck old instance never blocks a new one. Leaks are surfaced in the service's warnings; they are a sign of an underlying I/O fault, covered in Keeping services running.
Standard streams #
peinit wires a service's standard streams before exec:
- stdin is redirected to
/dev/null. peinit never gives a service an interactive input channel; a service that needs input must obtain it explicitly (a socket, a stored fd). - stdout and stderr are redirected to pipes peinit holds, so it can capture and forward every line. This is the subject of Service output and logging.
Attaching a terminal #
A service that sets TTYPath to an absolute terminal path — /dev/console, /dev/tty1, a serial line — gets that terminal on all three standard streams instead of the wiring above, and becomes a session leader owning it as its controlling terminal. That last part is what makes job control work: Ctrl-C interrupts, fg/bg behave, and a hangup reaches the process group. It is what a boot shell or a login prompt needs.
It carries a path rather than a flag because which terminal is a per-service question: a maintenance shell on tty1 while the kernel console is a serial line is an ordinary thing to want.
The environment #
peinit constructs each service's environment in layers, lowest precedence first — it does not pass through its own near-empty startup environment.
- Compiled-in base. A fixed floor peinit always provides — currently just
PATH:/bin. - Global
EnvVars. Each value underMachine\System\Init\EnvVars\becomes a variable (value name → variable name).EnvVars\PATHoverrides the basePATH; other names add. Every service gets this layer except registryd, which serves the key itself — see the warning below for why that exemption exists. - Per-service
Environment. The definition's ownKEY=VALUEentries, which override layers 1–2. - Protocol variables.
NOTIFY_SOCKET(always) andLISTEN_FDS/LISTEN_FDNAMES(only when stored fds are injected). These have the highest precedence and cannot be overridden — a service that clobberedNOTIFY_SOCKETwould break sd_notify.
A change to EnvVars\ takes effect on a service's next start, like the per-service Environment field — it is not pushed into running services.
peinit deliberately does not set HOME, USER, LOGNAME, SHELL, or TERM. Peios identity is a token (a SID), not a passwd entry, so there is no canonical home directory or login shell to fill in. A service that needs any of these supplies it through EnvVars\ or its own Environment.
Working directory and limits #
| Field | Effect |
|---|---|
WorkingDirectory | The process's working directory. Must be a non-empty absolute path; defaults to /. |
LimitNOFILE | RLIMIT_NOFILE — the maximum number of open file descriptors. |
LimitCORE | RLIMIT_CORE — the maximum core-dump size, in bytes. |
peinit also sets oom_score_adj: -1000 (OOM-immune) for ErrorControl=Critical services, and the default 0 for everyone else — so the kernel never picks a Critical platform daemon as an out-of-memory victim.
Runtime directories #
RuntimeDirectories gives a service private scratch space under /run without an ExecStartPre that has to mkdir it by hand. Each name you list becomes a directory created directly under /run — RuntimeDirectories=myapp yields /run/myapp — set up immediately before the main process starts. Each one gets a security descriptor granting full access to SYSTEM, Administrators, and the service's own SID, so the service can write to its directory while other principals cannot. If a directory can't be created, or its descriptor can't be applied, the start fails with ParentSetupFailure.
peinit does not remove these directories when the service stops. /run is a boot-scoped tmpfs, so they simply disappear at the next boot rather than being torn down on each stop — a restarting service finds its runtime directory (though not necessarily its contents) already in place.
For the exact naming rules on each entry and the field's type and default, see the Registry key reference.
Pre-exec and post-exec hooks #
Hooks let a service do work around its main process without baking it into the binary.
ExecStartPreruns before the main binary. Each command runs in sequence, in thehooks/sub-cgroup; any hook exiting non-zero aborts the start (the whole cgroup is killed and the service Fails withPreHookFailure). Use it for prerequisites that must hold before the service starts — creating a runtime directory, waiting on a precondition.ExecStartPostruns after readiness (Simple) or after a successful exit (Oneshot). A post-hook failure is logged but does not fail the service — by the time it runs, the service is already up. (A Oneshot that fails never runs its post-hooks.)
Hooks run under HookIdentity if set, otherwise the service's own identity. Their output is captured and tagged like the main process's, labelled with the hook (e.g. jellyfin/ExecStartPre[0]).
Command string parsing #
The command fields — ExecStartPre, ExecStartPost, ExecReload, HealthCheck — are parsed into an argument list with simple, predictable rules. peinit never invokes a shell.
- Whitespace splits the command into arguments; double quotes group text containing spaces into one argument and are not retained (
--name="hello world"→ one argument--name=hello world). - There is no shell expansion, variable substitution, or globbing. Backslash is literal; a single quote is an ordinary character.
- The first argument is the executable and must be an absolute path beginning with
/. Relative names and PATH search are validation errors. - An empty or whitespace-only command, or an unclosed quote, is a validation error.
If a command genuinely needs shell features, wrap it in a script and point the field at the script.
ExecReload can also be a signal, written signal:<NAME> — for example signal:SIGHUP. The name must be an exact, canonical Linux signal name (uppercase, no aliases, no numbers); SIGKILL and SIGSTOP are rejected because they cannot be handled as a reload. With no ExecReload at all, reload sends SIGHUP. The reload lifecycle is covered in Controlling services.
Conditions and asserts #
Before peinit runs any hook or the main process, it evaluates conditions and asserts — start-time checks in the same type:argument form. The difference is what failure means:
- A failed Condition skips the service — it transitions to Skipped, which satisfies its dependents (it succeeded by not needing to run). Conditions express "only run here if it makes sense."
- A failed Assert fails the service — it transitions to Failed with
AssertionError. Asserts express "this was expected to run, and a precondition is missing."
Conditions are checked first; only if all pass are asserts checked. All entries are AND-ed. The check types:
| Type | Form | Passes when |
|---|---|---|
path | path:<path> | the path exists (any type) |
file | file:<path> | a regular file exists |
directory | directory:<path> | a directory exists |
registry | registry:<key> | the registry key exists |
Two constraints follow from peinit being single-threaded PID 1, which must never block:
registry:checks are cache-only. Aregistry:check may only name a key peinit already caches (underMachine\System\Services\orMachine\System\Init\); it is evaluated against the in-memory model, never a live read. Naming any other key is a validation error.- Filesystem checks run off the main loop.
path:/file:/directory:checks are performed by a short-lived forked helper, not astat()on the event loop, becausestat()can wedge on a hung mount.
That timeout is the PreStartCheckTimeout field, which defaults to 5 seconds. A filesystem check that does not finish within it — say a stat() wedged on a hung mount — is treated as not satisfied, fail-safe: peinit kills the helper and treats the check as unmet, so a Condition skips the service and an Assert fails it, rather than letting the event loop hang.
The fd store #
The fd store lets a service preserve file descriptors across restarts — most usefully a listening socket, so a stateful daemon can restart without dropping connections. It is opt-in: FdStoreMax (default 0) sets the maximum number of fds peinit will hold; 0 disables it.
The mechanics ride on sd_notify:
- A service stores an fd by sending
FDSTORE=1with the fd attached (and optionallyFDNAME=<name>; the default name isstored). peinit holds it. - It removes one with
FDSTOREREMOVE=1andFDNAME=<name>. - On the next start, peinit injects the stored fds starting at fd 3, sets
LISTEN_FDSto the count andLISTEN_FDNAMESto the colon-separated names, then clears the store. (This is the sameLISTEN_FDSconvention systemd uses, so software with existing fd-passing support works unmodified.)
The store survives an automatic restart (crash → restart policy → new start) — that is the whole point. It is cleared on an explicit stop or shutdown (the service is not coming back), and when a removed definition is finally discarded.
Where to start #
For the identity half of the launch — how the token is chosen and installed — read Service identity and privileges.
For what happens to the stdout/stderr pipes peinit holds, read Service output and logging.
For how conditions, asserts, and hooks fit into the start sequence and its states, read The service lifecycle.
Controlling services
Peios / Using Peios / Services & jobs
peiosctl is the command-line tool for driving peinit at runtime — starting and stopping services, querying their state, reloading configuration, and shutting the system down.
peiosctl <command> [service] [flags]
$ peiosctl status jellyfin # current state of one service
$ peiosctl start jellyfin # start it, wait until Active or Failed
$ peiosctl list # every service you can query
$ peiosctl shutdown reboot # graceful reboot
Underneath, peiosctl is a thin client over peinit's control socket at /run/services/peinit/control.sock. The wire protocol — not the CLI — is the normative interface, so everything here (commands, rights, semantics) holds regardless of which front-end you use.
How the control interface works #
The socket speaks newline-delimited JSON: one request object per line, one response object per line. A request and its success response look like this:
Two properties are worth knowing even if you only ever use peiosctl:
- Every command is access-controlled. When you connect, peinit captures your token from the kernel and runs AccessCheck against the target service's descriptor for every command. There is no "trust localhost," no override. Who may do what is the subject of Who can manage a service.
- Lifecycle commands create operations. A
start/stop/restart/reload/resetreturns anoperation_id— a GUID you can poll. Conflict resolution between concurrent commands happens at the operation layer, which is why two simultaneousstarts merge instead of colliding.
Service commands #
| Command | Does | Required right |
|---|---|---|
start | Run the service through its full start sequence. | SERVICE_START |
stop | SIGTERM, then SIGKILL after StopTimeout. | SERVICE_STOP |
restart | Stop then start, as one operation. | SERVICE_STOP + SERVICE_START |
reload | Re-read configuration (ExecReload, or SIGHUP). | SERVICE_INTERROGATE |
reset | Clear Failed/Abandoned/Skipped → Inactive. | SERVICE_STOP |
status | Report state, cause, PID, uptime, health, current job and operation, warnings. | SERVICE_QUERY_STATUS |
list | List services and states (filtered to what you can query). | (per-service SERVICE_QUERY_STATUS) |
$ peiosctl restart jellyfin
$ peiosctl reload nginx
$ peiosctl reset failed-migration # clear a Failed state without starting
System commands #
| Command | Does | Required right |
|---|---|---|
shutdown <type> | Graceful shutdown. type is poweroff, reboot, or halt. | SYSTEM_SHUTDOWN |
reload-config | Re-read all definitions and rebuild the graph (atomic). | SYSTEM_RELOAD_CONFIG |
operation-status <id> | Report the state of an operation by GUID. | SERVICE_QUERY_STATUS on its target |
$ peiosctl shutdown poweroff
$ peiosctl reload-config
$ peiosctl operation-status a1b2c3d4-...
Wait semantics #
By default, a lifecycle command blocks until its operation reaches a terminal state — start waits for Active (or Failed), stop waits for Inactive, and so on. Pass --no-wait to get the operation_id back immediately and poll with operation-status instead.
| Command | Default | Waits for |
|---|---|---|
start | wait | Active (Simple) / Completed or Inactive (Oneshot), or Failed |
stop | wait | Inactive |
restart | wait | the successful start target, or Failed |
reload | no-wait | (with --wait) the Reloading state to resolve |
reset | immediate | — |
reload is the exception — it returns immediately by default, because a reload may have no observable completion. With --wait, the response carries a mode:
mode | Meaning |
|---|---|
confirmed | The service signalled READY=1 (and, for a command reload, the command exited 0). |
advisory | The reload was issued and the detection window elapsed without explicit confirmation. |
failed | The ExecReload command exited non-zero or timed out. The service stays Active — a failed reload never takes down a running service. |
The detection window is a fixed 2 seconds — it is built in and is not configurable via the registry. If the service says nothing within that window, the reload resolves as advisory and the service stays Active.
The command × state matrix #
A command sent to a service in an unexpected state returns an error, not a silent no-op. This matrix is the authority on what each command does in each state:
| Command | Inactive | Starting | Active | Reloading | Stopping | Completed | Backoff | Failed | Abandoned | Skipped |
|---|---|---|---|---|---|---|---|---|---|---|
| start | Start | MERGE | ALREADY | ALREADY | QUEUE | Start | DEFER | Start | ERROR | Start |
| stop | NOOP | Cancel+Stop | Stop | Stop | MERGE | Clear | Cancel | NOOP | ERROR | NOOP |
| restart | Start | QUEUE | Restart | Restart | QUEUE | Start | Restart | Start | ERROR | Start |
| reload | ERROR | ERROR | Reload | MERGE | ERROR | ERROR | ERROR | ERROR | ERROR | ERROR |
| reset | NOOP | ERROR | ERROR | ERROR | ERROR | ERROR | ERROR | Clear | Clear | Clear |
| status | OK | OK | OK | OK | OK | OK | OK | OK | OK | OK |
Legend:
- MERGE — an operation of this type is already running; your command merges into it and you get its GUID.
- DEFER — your
startis accepted and creates a Pending start operation, but it does not run yet: it waits out the service's existing backoff deadline and then starts. If a deferred start is already waiting, you merge into it and get its GUID. - ALREADY — already in the target state; returns the current status, not an error.
- QUEUE — queued as Pending; runs after the current operation finishes.
- NOOP — no effect; returns the current status.
- Clear / Cancel — clear the state to Inactive / abort the current operation, then proceed.
- ERROR — invalid for this state; returns an error with an explanation.
The Backoff column is the subtle one: the service is down with an automatic restart pending, so start is deferred — it creates a Pending start that honours the remaining backoff delay and only runs once that deadline expires (a second start merges into the one already waiting), stop cancels the pending restart and any deferred start (the service goes Inactive), restart cancels the automatic one and does an admin restart, and reload/reset are invalid because no process exists.
Reading status #
status returns the full picture for one service:
stateandcauseare the lifecycle pair — read them together.status_textis the latestSTATUS=string the service sent via sd_notify (nullif never sent; cleared on each restart).current_jobandcurrent_operationare the job and operation GUIDs, ornull.healthishealthy,unhealthy,unknown, ornull(no health check).definition_removedistruewhen the definition was deleted but an instance is still draining.warningslists leaked sub-cgroups and other operator-relevant notices.
list returns a compact summary of every service you can query — services you lack SERVICE_QUERY_STATUS on are simply omitted, not denied:
operation-status returns one operation by GUID; an unknown or expired GUID is the UNKNOWN_OPERATION error. (Operations are dropped after a short retention grace once terminal — long enough for a polling client to read the result, not forever.)
Error codes #
An error response is {"status": "error", "code": "...", "message": "..."}. The code is one of:
| Code | Meaning |
|---|---|
ACCESS_DENIED | AccessCheck denied the command against the target descriptor. |
UNKNOWN_SERVICE | No such service definition (also returned for start/restart/reload on a definition-removed service). |
UNKNOWN_OPERATION | No such operation GUID — never existed, or dropped after its retention grace. |
MALFORMED_REQUEST | The request line is not a single valid JSON object. |
REQUEST_TOO_LARGE | The request exceeds MaxRequestSize. |
INVALID_COMMAND | The command field is missing or unknown. |
INVALID_ARGUMENTS | A required field is missing or malformed (e.g. shutdown with no valid type). |
INVALID_STATE | Not valid for the service's current state (an ERROR cell above), or rejected because the system is already shutting down. |
OPERATION_TIMEOUT | A --wait operation did not reach a terminal state within its timeout. |
INTERNAL_ERROR | peinit hit an internal failure executing the command. |
The message is human-readable and non-normative — read it for context, key off the code.
Connection limits #
peinit enforces hard limits on the control socket, all tunable under Machine\System\Init\:
| Key | Default | Limits |
|---|---|---|
MaxControlConnections | 32 | Concurrent client connections (excess are refused at connect). |
MaxRequestSize | 65536 | Bytes per request. |
ConnectionTimeout | 30 | Seconds an idle connection may sit before it is closed. |
Exit status #
| Code | Meaning |
|---|---|
0 | The command succeeded. |
1 | A usage error. |
| non-zero | The command failed — an access denial, unknown service, invalid state, or timeout. |
Where to start #
To understand the states the matrix refers to, read The service lifecycle.
To understand the operations every lifecycle command creates and how to poll them, read Jobs and operations.
To configure who may run each command, read Who can manage a service.
Who can manage a service
Peios / Using Peios / Services & jobs
A service is a securable object. Just as a file or a registry key carries a security descriptor that decides who may touch it, a service carries one that decides who may start, stop, query, or reload it. peinit enforces it on every control command — there is no command that skips the check.
This is the second, independent half of the security model. The first half, Service identity and privileges, is about what a service can do (its token). This page is about who can manage the service — a completely separate question, answered by a completely separate descriptor.
Two descriptors, two questions #
A service is associated with two descriptors that are easy to conflate but answer different questions and are enforced by different components:
| Descriptor | Question | Stored | Enforced by |
|---|---|---|---|
| Registry key SD | Who can read or edit the definition? | On the Machine\System\Services\<name> key | LCS, at key-open time |
| ServiceSecurity SD | Who can manage the running service? | As the ServiceSecurity binary value on that key | peinit, on every control command |
The registry key SD is ordinary registry access control and not peinit's concern — peinit reads definitions as SYSTEM, which has full access. The ServiceSecurity SD is peinit's domain, and the rest of this page is about it.
They are genuinely independent. An administrator might be able to query a service's status (ServiceSecurity grants SERVICE_QUERY_STATUS) but not read its configuration (the registry key SD denies read) — or the reverse. Runtime control and configuration access are separate concerns, and both combinations are valid.
Service access rights #
The ServiceSecurity descriptor grants these rights:
| Right | Bit | Grants |
|---|---|---|
SERVICE_QUERY_STATUS | 0x0001 | Query state, PID, cause, health, warnings. |
SERVICE_START | 0x0002 | Start the service. |
SERVICE_STOP | 0x0004 | Stop the service. |
SERVICE_INTERROGATE | 0x0008 | Reload the service. |
SERVICE_ALL_ACCESS | 0x000F | All of the above — the "full access" granted to SYSTEM by default. |
restart requires both SERVICE_STOP and SERVICE_START, since it is a stop followed by a start. reset requires SERVICE_STOP.
When peinit evaluates the descriptor it maps the generic rights as follows, so a descriptor written with generic rights behaves sensibly:
| Generic | Maps to |
|---|---|
GENERIC_READ | SERVICE_QUERY_STATUS |
GENERIC_WRITE | SERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE |
GENERIC_EXECUTE | SERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE |
GENERIC_ALL | SERVICE_ALL_ACCESS |
How a command is authorised #
Every control command runs the same gate:
- peinit captures the caller's token from the kernel (
kacs_open_peer_token) — the caller's effective identity at connection time, so if the caller is impersonating, the impersonated identity is what is checked. - peinit resolves the target service and its ServiceSecurity descriptor.
- peinit runs AccessCheck: the caller's token against the descriptor, for the right the command needs.
- Denied → return
ACCESS_DENIEDand log the attempt (caller SID, target service, requested right). - Granted → execute the command.
The default descriptor #
If a service has no ServiceSecurity value, it inherits its parent key's. If no ancestor sets one either, peinit applies a built-in default:
- SYSTEM (
S-1-5-18) — full access. - Administrators (
S-1-5-32-544) — query and stop only.
So out of the box, administrators can see and stop a service but not start or reload it unless a descriptor grants more — a conservative default that you widen deliberately.
ServiceSecurity is hot-reloaded: a change to the value in the registry takes effect on the next control request, with no service restart. peinit picks the change up through a registry notification. This is why ServiceSecurity is in its own mutability class — access policy should be able to change without disturbing a running service.
The system control descriptor #
Some operations are not about any one service — shutdown and reload-config act on the whole system. These are checked against peinit's own descriptor, stored at Machine\System\Init\ControlSecurity:
| Right | Bit | Grants |
|---|---|---|
SYSTEM_SHUTDOWN | 0x0001 | Initiate poweroff, reboot, or halt. |
SYSTEM_RELOAD_CONFIG | 0x0002 | Re-read all definitions and rebuild the graph. |
Its generic mapping deliberately gives GENERIC_READ nothing — there is no "read" of the system control object, only the two actions:
| Generic | Maps to |
|---|---|
GENERIC_READ | (nothing) |
GENERIC_WRITE | SYSTEM_RELOAD_CONFIG |
GENERIC_EXECUTE | SYSTEM_SHUTDOWN |
GENERIC_ALL | SYSTEM_SHUTDOWN | SYSTEM_RELOAD_CONFIG |
The default grants SYSTEM full access and Administrators both rights. peinit loads this descriptor at boot and hot-reloads it on registry change, exactly like ServiceSecurity.
The list command filters, it does not deny #
list is access-control-aware in a quieter way: it returns only the services the caller has SERVICE_QUERY_STATUS on, and simply omits the rest. A caller with no query rights gets an empty list, not a denial. This means a low-privilege principal cannot even enumerate the services it cannot see — the existence of a service is itself information the descriptor controls.
The boundaries that hold #
A few invariants are worth stating outright, because they are what make this trustworthy:
- peinit never bypasses AccessCheck for a control operation. No backdoor, no override flag, no "trust localhost."
- The descriptors are the only policy inputs. peinit consults the ServiceSecurity and ControlSecurity descriptors and nothing else — not config files, not environment variables, not hardcoded lists.
- One service's state is never exposed to another without a check.
statusis per-service access-controlled;listfilters.
Where to start #
For the other half of the security model — what a service can reach once it is running — read Service identity and privileges.
For the commands these rights gate, read Controlling services.
For the descriptor and AccessCheck machinery itself, read Security descriptors and Access decisions.
Jobs and operations
Peios / Using Peios / Services & jobs
A service is a long-lived definition. But when you read a status output or query the event log, two shorter-lived objects show up alongside it: the job (one process execution) and the operation (one requested action). They are what make peinit observable — every run and every command carries a GUID you can follow. This page covers both, and the special case of ad-hoc jobs.
Jobs: what actually ran #
Every time peinit forks a process — a service's main binary, a pre- or post-hook, a health check, or an ad-hoc run — that single execution is a job, with its own GUID and exit result. If a service is the what, a job is what actually ran. A restart does not reuse a job; it creates a new one. The service always tracks its current job GUID, and a status query reports it.
Jobs come in five types:
| Type | Created for |
|---|---|
ServiceMain | A service's main process. |
PreExecHook | One ExecStartPre command. |
PostExecHook | One ExecStartPost command. |
HealthCheck | One health-check run. |
AdHoc | A process submitted via JFS. |
Their lifecycle is simpler than a service's, because a job only tracks a process — not policy:
| State | Meaning |
|---|---|
Created | The job object exists but the process is not forked yet (pre-hooks may be running). |
Running | The process is alive. |
Completed | The process exited successfully. |
Failed | The process failed — or peinit classified the job failed before fork (a parent-side setup error). |
Abandoned | The process survived SIGKILL (D-state). |
A job record carries the things you would want for forensics: the resolved identity and a token summary, the image path and arguments, created/started/ended timestamps, the exit code or signal, the cgroup, the failure cause, and the operation_id that created it (null for ad-hoc jobs). The clean division of ownership is: the service owns policy (restart, dependencies, health schedule, current state); the job owns the execution facts (PID, exit, timestamps, identity, cgroup, log correlation).
Retention and log correlation #
peinit keeps only active jobs in memory. When a job reaches a terminal state it emits a structured event (through KMES) carrying the full record, then drops the job. peinit keeps no job history — eventd is the historian, consuming those events from the kernel ring buffer. The lifecycle events are job.created, job.started, and job.ended.
Separately, all of a job's stdout/stderr is forwarded to eventd tagged with the job's GUID. That is what lets a query like "show me the logs for job X" return exactly that execution's output — not interleaved with the run before or after it.
Ad-hoc jobs #
An ad-hoc job is an arbitrary process a service asks peinit to run on behalf of one of its clients. It is not tied to a persistent definition — it runs once, reports its result, and is cleaned up. The motivating case: a service has impersonated a user and wants a process run as that user, but supervised centrally by peinit rather than by the service itself.
The obstacle is identity delegation: a service cannot simply forward an impersonation token over a socket. JFS (the Job Forwarding Subsystem) solves this in the kernel — it captures the caller's effective token and delivers it to peinit along with the job request. peinit forks, installs that captured token, and supervises the result like any other job.
An ad-hoc request carries a deliberately small subset of the service fields — ImagePath, Arguments, Environment, WorkingDirectory, Timeout, Description. A couple are worth pinning down: Timeout is in seconds, and 0 means no limit (the job runs for as long as it needs). WorkingDirectory defaults to / when you leave it out. The policy fields (restart, dependencies, health checks, ErrorControl, triggers) are not available — those belong to persistent definitions. An ad-hoc job runs once in its own cgroup, routes its output to eventd, and on exit emits job.ended and is dropped. If it overruns its Timeout, peinit escalates SIGTERM → SIGKILL exactly as a service stop does.
Ad-hoc jobs bypass the operation model entirely — there is no service to "start," so the request creates a job directly. The job is the whole lifecycle.
Operations: what was requested #
An operation is a first-class object representing a requested state-machine action on a service. Rather than letting control commands mutate state directly, peinit turns each one into an operation that is validated, queued, and executed by its event loop. This is what gives concurrent callers — admin tools, automated triggers, dependency propagation — explicit, observable conflict resolution instead of races.
There are five operation types — Start, Stop, Restart, Reload, Reset — and each carries a source recording why peinit created it:
| Source | Created by |
|---|---|
Admin | A control client. |
Boot | The Phase 2 boot lifecycle. |
Shutdown | The shutdown lifecycle. |
DependencyPropagation | A start pulling in a dependency. |
RestartPolicy | An automatic restart. |
Timer | A timer firing. |
BindsToRecovery | A bound target returning to Active. |
BindsToPropagation | A bound target stopping. |
ConflictResolution | A Conflicts eviction. |
OnFailure | A failed service's fallback handler. |
The source is why a status showing current_operation.source: "restart_policy" tells you the service is being auto-restarted, while "admin" tells you a person asked.
Operation lifecycle #
flowchart LR
P["Pending"] --> R["Running"]
R --> C["Completed"]
R --> F["Failed"]
R --> A["Aborted"]
P --> M["Merged"]
P --> X["Cancelled"]
P --> F
| State | Meaning |
|---|---|
Pending | Validated and queued, waiting on a precondition (e.g. a prior stop to finish). |
Running | Executing — the service is transitioning. |
Completed | Achieved its goal (start reached Active/Completed; stop reached Inactive/Failed). |
Failed | Did not achieve its goal — including timing out while still Pending. |
Merged | Folded into an identical operation already in flight (records the survivor's GUID). |
Cancelled | Terminated while still Pending — never ran. |
Aborted | Terminated while Running — interrupted in progress. |
Cancelled and Aborted are the same idea at two points: cancelled never ran, aborted was running. The reason (admin action, supersession) is a property of the event, not the state.
Conflict resolution and merging #
When a command arrives for a service that already has an operation in flight, peinit resolves the collision deterministically — the same logic the command × state matrix summarises:
- Same type (start over start, stop over stop, reload over reload) → merge. The new caller transparently receives the existing operation's GUID; from their side, their request is in progress.
- Stop wins over start. An explicit stop cancels a pending start or aborts a running one. The admin said stop.
- Later supersedes earlier, and a start while a stop is in flight is queued to run after the stop.
Timeout and retention #
An operation inherits its target's timeout as its maximum lifetime — StartTimeout for start/reload/reset, StopTimeout for stop, and the sum of both legs for restart. Crucially, the clock starts at operation creation, including queue time — from the caller's perspective they have been waiting since they sent the command, so a long queue can time an operation out before it even runs.
Terminal operations are emitted as events (operation.requested, .started, .completed, .failed, .cancelled, .merged, .aborted) and dropped from memory after a short grace (default 60 s — long enough for a polling client to read the result). As with jobs, peinit keeps no operation history; eventd does.
How they surface #
You meet jobs and operations in three places:
status—current_jobandcurrent_operationgive the GUIDs of the service's active execution and active action (ornull).operation-status <id>— polls one operation to a terminal state; a--waitlifecycle command does this for you under the hood.- eventd — the durable history. Every job and operation lifecycle transition lands there as a structured event, which is how you reconstruct what happened after the objects themselves are gone from peinit's memory.
Where to start #
To create and poll operations from the command line, read Controlling services.
To understand the service states an operation drives a service through, read The service lifecycle.
To query the durable job and operation history, read Auditing.
Service output and logging
Peios / Using Peios / Services & jobs
peinit is not a logging system — storage, indexing, and queries belong to eventd. But peinit holds the stdout/stderr pipes of every service at the moment it forks, so it is unavoidably in charge of where output goes, and it has to cope with the window early in boot before eventd even exists. This page is about that responsibility.
Capturing output #
When peinit forks a service it creates pipes for stdout and stderr, redirects the child's streams to the write ends, and keeps the read ends — monitoring them in its event loop. (The child's stdin is /dev/null; see The execution environment.) It reads output line by line and tags each line with:
- the service name,
- the stream (stdout or stderr),
- a timestamp,
- the job GUID.
The job GUID is the important one: it is what lets a later query return exactly one execution's output, never interleaved with the run before or after it. Hook output is captured too, labelled with the hook (e.g. jellyfin/ExecStartPre[0]), and so is health-check output — invaluable for diagnosing why a check failed.
Logs are best-effort; audit events are not #
This is the distinction to internalise, because it explains why some things survive a crash and others do not. peinit handles two different streams of information with two different guarantees:
| Service logs | Audit events | |
|---|---|---|
| What | stdout/stderr from services and hooks | Access denials, critical failures, recovery-mode entry, graph errors, job/operation lifecycle |
| Path | Forwarded to eventd's log socket | Emitted into the KMES kernel ring buffer |
| Guarantee | Best-effort — may be dropped under load | Durable — survive eventd restarts and reboots |
| Available from | Once eventd is up (buffered before) | The moment PKM loads — before eventd, before the registry |
Service logs are a convenience: useful, but lossy by design. Audit events are part of the security posture and go through KMES, which persists them in the kernel from the instant the module loads. So peinit keeps a pre-eventd buffer for logs but needs none for events — eventd picks events up from the ring buffer whenever it attaches, no matter how late.
The pre-eventd buffer #
Before eventd is running there is nowhere to send logs — its socket does not exist yet. peinit therefore buffers captured output in a fixed-size in-memory pre-eventd buffer (default 1 MB); when it fills, the oldest entries are dropped.
In practice the only services that run before eventd are registryd, lpsd, authd, and eudev. The first three are Peios-owned with controlled output; eudev is the real overrun risk (verbose device enumeration), and the restart budget naturally bounds output from a crash loop.
Flood protection #
A noisy service must never starve peinit's event loop. Several limits enforce that:
Key (Machine\System\Init\) | Default | Limits |
|---|---|---|
MaxLogLineLength | 8192 | Bytes per line; longer lines are truncated with a [truncated] marker. |
MaxLogBufferPerService | 65536 | Bytes buffered per service pipe before back-pressure. |
Two mechanisms back these up:
- A per-iteration read budget. Each pass of the event loop reads at most a bounded number of bytes from service pipes before moving on to other events — so no single chatty service can monopolise a loop iteration.
- Back-pressure, not dropping. If a service writes faster than peinit reads, the kernel pipe buffer fills and the service's own
write()calls block. This is deliberate: the service slows down. While reading the pipe, peinit does not drop output — the no-silent-drop guarantee covers reading the pipe. It is only downstream (the pre-eventd buffer, and eventd's datagram socket) that delivery becomes lossy.
The eventd handoff #
When eventd reaches Active, peinit switches from buffering to forwarding:
- It begins sending to eventd's log datagram socket (path from
Machine\System\eventd\LogSocketPath). - It replays the pre-eventd buffer, oldest first, preserving each line's timestamp and metadata. This replay is best-effort — the records are datagrams on a loss-tolerant socket and some may be dropped under load; peinit never blocks waiting to deliver them.
- It switches to real-time forwarding — new output goes out as it arrives.
- It clears the buffer.
From then on peinit is a pipe relay: read a line, tag it, forward it as a datagram. The log socket is non-blocking and loss-tolerant — if eventd cannot drain it fast enough the kernel drops further datagrams silently, because log ingestion must never exert back-pressure on the whole system through PID 1. peinit keeps no unbounded outbound buffer and never blocks on a send to eventd.
If eventd itself crashes after starting, peinit (which supervises it like any service) notices, re-enables the pre-eventd buffer, and repeats the handoff when eventd comes back. There is a log gap across the restart, bounded by the buffer size — but audit events are unaffected, because they were going to KMES the whole time and eventd resumes consuming them from the last persisted sequence.
Console output #
peinit writes its own operational messages to /dev/console:
- Phase 1 progress (mount results, registryd start),
- Phase 2 progress (service starts and failures, dependency errors),
- shutdown progress,
- recovery-mode entry,
- critical service failures.
Service stdout/stderr is not echoed to the console by default — the console is for peinit's status messages only. This keeps the console legible during boot and, crucially, usable in Recovery mode, where it may be the only interface an administrator has.
Where to start #
To see how the pipes are wired and what else the process inherits, read The execution environment.
To follow the job GUID that tags every log line, read Jobs and operations.
For where the logs and events actually go — storage, indexing, and queries — read Auditing.
Boot and boot modes
Peios / Using Peios / Services & jobs
peinit's boot has two phases and a chicken-and-egg problem to solve. The problem: peinit reads everything it does from the registry, but the registry is served by a daemon that something has to start first — and the identity authority that would mint that daemon's token does not exist yet either. The solution is to split boot into a hardcoded bootstrap that needs no registry, and a registry-driven phase that starts once the registry is up. The boundary between them is registryd.
Where peinit takes over #
peinit is PID 1, but it is not the first thing that runs. The initramfs assembles and mounts the real root — decryption, RAID/LVM, the root filesystem itself — and then hands control to peinit. The contract peinit relies on is narrow:
- The real root is already mounted read-write, and
/proc,/sys,/devare mounted and moved into it. - peinit is exec'd as PID 1 from a fixed path on the real root.
peinit does not assemble, decrypt, repair, or even re-mount the root — those need tools and configuration that belong to the initramfs. It also does not fsck the root or mount non-root storage (a data partition is mounted by an ordinary Oneshot service, not by peinit). For the trust and identity side of this handoff — signatures, the SYSTEM token peinit inherits — see peinit at PID 1.
Phase 1: the hardcoded bootstrap #
Phase 1 is compiled into peinit. It cannot change at runtime and touches no registry. It does the minimum to make Phase 2 possible:
- Confirm the root is writable with a single probe write. registryd's storage needs a writable root even for reads, so a read-only root cannot support Phase 2 → Recovery.
- Mount the remaining virtual filesystems —
/dev/pts,/dev/shm,/run,/sys/fs/cgroup— mounting each only if absent. A failure here → Recovery. - Restore the persisted random seed from
/var/state/peinit/random-seed, mixing it into the kernel's entropy pool early so anything that needs randomness during boot gets it. A missing seed is normal — first boots and stateless live boots have none — so peinit just carries on; a seed problem is never fatal and never sends boot to Recovery. - Establish the machine-id from
/lcl/etc/machine-id— a stable, opaque identifier for this install (used for log correlation, instance identity, and software compatibility). It is not a security principal: it is not a SID, an account, or a credential, and no authorisation decision depends on it. If the file is missing, empty, or malformed, peinit generates a fresh 128-bit ID and writes it before continuing. - Set the clock from the hardware RTC, so early timestamps and the boot counter are meaningful. A failure here → Recovery.
- Start registryd and wait for it to signal readiness, then probe-read the schema-version key to confirm it is actually serving reads. Any failure → Recovery — there is no Phase 2 without a registry.
- Provision boot-time paths. With registryd up and before Phase 2 starts, peinit applies the entries under
Machine\System\Init\ProvisionedPaths\— the registry-driven equivalent of tmpfiles.d, creating directories and files (with Peios security descriptors) that no single service owns. Best-effort entries that fail are logged and skipped, but an entry markedRequired=1that cannot be provisioned sends boot → Recovery. The individual keys are cataloged in the registry key reference. - Infrastructure setup — create the control socket, open the JFS device for ad-hoc jobs, bring up the loopback interface. Control-socket failure → Recovery; the other two are logged as warnings and boot continues.
Most Phase 1 failures are fatal to a normal boot, because none of the later machinery can run without this foundation — the only outcome is Recovery mode. The exceptions are the fail-soft steps called out above: a missing or unusable random seed, a regenerated machine-id, and best-effort provisioned paths all let boot continue.
registryd and loregd #
registryd is an interface, not a specific program. It is the path peinit execs to get a registry source daemon — the component that implements the registry's persistent storage and answers peinit's reads. The implementation behind that interface can vary; by default it is loregd.
This split matters in exactly one place: Recovery mode. In normal operation you only ever deal with the registryd abstraction — peinit starts it, treats it as opaque, and reads the registry through it. But when the registry itself is what broke, you need tools that work without a running registry, and those tools talk to the implementation directly. That is why the recovery tooling is named loregd (loregd --inspector, --recover-from-backup, …): in recovery you are working with the storage implementation, not the registry abstraction. It is the one context where the distinction is visible to an administrator.
Phase 2: the registry-driven boot #
With registryd serving reads, peinit boots the rest of the system from the registry:
- Read all definitions under
Machine\System\Services\. (A registry read timing out here → Recovery.) - Build and validate the dependency graph from the boot-triggered services and their transitive dependency closure. Validation runs before anything starts.
- Start services in dependency order, in parallel up to
MaxParallelStarts, with readiness gating releasing each service's dependents as it becomes satisfied.
Only services with a boot trigger are start candidates; demand-only services are pulled in only if something boot-triggered depends on them, and Disabled services are excluded from the graph (but kept in the model for on-demand start). The whole boot runs against one snapshot — mid-boot registry edits do not perturb it.
The platform daemons come up first because everything rests on them. They are all SYSTEM, all minted by peinit (no authd yet), and all ErrorControl=Critical:
| Service | Phase | Identity source | Readiness | ErrorControl |
|---|---|---|---|---|
| registryd | 1 | minted by peinit | sd_notify | Critical |
| eudev | 2 | minted by peinit (privileges stripped) | process alive | Normal |
| lpsd | 2 | minted by peinit | sd_notify | Critical |
| authd | 2 | minted by peinit | sd_notify | Critical |
| eventd | 2 | minted by peinit | sd_notify | Critical |
| networking | 2 | authd | sd_notify | Normal |
| sshd | 2 | authd | process alive | Normal |
| application services | 2 | authd | per-service | Normal |
Once authd is up, every subsequent service gets its token through the normal authd flow. The order above is emergent from the standard role definitions' dependencies, not hardcoded — change the dependencies and the order changes. In practice login services such as sshd come up last, so the system is fully operational before it starts accepting user sessions.
The three boot modes #
The three modes form an escalation path from normal operation to last-resort maintenance.
flowchart TD
F["Full boot"] -->|success| OK["operational<br/>counter reset"]
F -->|cycle/conflict w/ Critical| S["Safe mode<br/>(no reboot)"]
F -->|Critical failure| RB["sync + reboot"]
S -->|success| OKS["operational (reduced)<br/>counter reset"]
S -->|Critical failure| RB
RB --> CNT["counter increments"]
CNT -->|counter ≥ N| REC["Recovery mode"]
F -.->|Phase 1 failure| REC
Full mode #
The default. All boot-triggered services start in dependency order. A boot is successful — and the boot-attempt counter resets to 0 — when every Critical service has reached and held a dependent-satisfying state (Active, Completed, or Skipped) for a grace period (BootSuccessGrace, default 30 s). The criterion is "satisfying," not "Active," so that a Critical Oneshot that reaches Completed can still mark the boot good.
Safe mode #
Safe mode starts a reduced set of boot-triggered services and rebuilds the graph from scratch using only those, dropping dependencies on excluded services. Two categories are eligible:
- Critical services (
ErrorControl=Critical) — must start; a Critical failure here still follows the normal reboot path. - SafeMode services (
SafeMode=1) — best-effort; if they fail, Safe mode carries on without them.
Everything else is skipped. Safe mode is entered when:
- graph validation finds a cycle involving a Critical service, or
- an unresolvable conflict involving a Critical service, or
- the kernel command line says
peios.safemode=1.
The first two are configuration errors — rebooting would just hit them again — so peinit downgrades without rebooting. Safe mode is not entered by a Critical service crashing at runtime; that follows the reboot path. And it is purely a boot-sequencing concern: once booted, you can start any service by hand exactly as in Full mode. It is the mode for "the configuration is broken but the TCB is healthy."
Recovery mode #
Recovery mode is the last resort, and it offers no TCB guarantee — the administrator gets an unrestricted SYSTEM shell on the console and must treat it with corresponding care. It is a maintenance environment, not a degraded boot.
In Recovery, peinit completes the Phase 1 basics, tries to start registryd (ignoring failure — the shell must appear regardless), skips all Phase 2 services, and execs a SYSTEM shell on /dev/console (/bin/recsh if present, else /bin/sh), respawning it if it exits. It is entered when:
- the boot-attempt counter reaches N (default 3),
- the kernel command line says
peios.recovery=1, or - registryd fails during Phase 1 — entered immediately, with no reboot and no counter increment, because there is no Phase 2 to attempt.
Because the registry itself may be what broke, recovery provides tools that work without it — talking to the loregd implementation directly:
| Tool | Does |
|---|---|
loregd --inspector | Read the storage database directly for diagnosis. |
loregd --recover-from-backup | Restore from an automatic backup taken on every registryd startup. |
loregd --dangerously-clear-database | Wipe the registry entirely. Recoverable, because role definitions are the source of truth for service config. |
The boot-attempt counter #
The counter is what turns a crash-looping Critical service into an eventual Recovery shell instead of an infinite reboot loop. It is a plain integer in a file at /.peinit/boot-attempts — not in the registry, because the registry may be the very thing that is broken.
- peinit reads it at startup, before choosing a mode. The Recovery threshold (
counter ≥ N) is checked against this pre-increment value, so the default N of 3 admits exactly three attempts before Recovery. A missing file counts as 0; a corrupt or unreadable one → Recovery. Override N withpeios.bootattempts=Non the kernel command line, or set it to0to disable the check when the counter is itself the fault. - peinit increments it once per boot, right after confirming the root is writable and before Phase 2 — never before the root is known writable, or a read-only root would silently lose the increment and defeat escalation.
- The counter is reset to 0 on a successful Full or Safe boot (after the grace period).
- If the counter file cannot be written (disk full), peinit treats it as 0 and continues — a write failure must not by itself trigger Recovery.
A Critical service exhausting its restart budget — at boot or at runtime — triggers a sync and reboot, which increments the counter on the next boot. Repeat that enough and the counter crosses N, and peinit stops trying and hands you a Recovery shell. The counter deliberately does not try to catch a peinit too broken to reach its own increment; that is a binary-integrity problem, not a boot-loop problem.
Boot configuration #
| Key | Default | Controls |
|---|---|---|
Machine\System\Boot\MaxParallelStarts | 10 | Services starting concurrently (must be > 0; invalid → Recovery). |
Machine\System\Boot\BootSuccessGrace | 30 | Seconds a Critical service must hold a satisfying state before boot counts as successful. |
Machine\System\Boot\ShutdownTimeout | 90 | Maximum seconds for the whole shutdown sequence. |
Machine\System\Boot\PostKillTimeout | 5 | Seconds a service cgroup may take to drain after SIGKILL before it counts as stuck. |
The kernel command line #
peinit reads four peios.* tokens. They are deliberately few: everything peinit can read after registryd is serving belongs in the registry instead, where it can be inspected, secured and changed without editing a boot entry. What is left is either a per-boot mode decision or a Phase 1 value — one peinit needs before there is a registry to ask.
| Token | Effect |
|---|---|
peios.safemode=1 | Force Safe mode. |
peios.recovery=1 | Force Recovery mode, whatever the counter says. |
peios.bootattempts=N | Override the boot-attempt threshold; 0 disables the check. |
peios.notifysocket=PATH | Move the sd_notify socket. Phase 1, because registryd is the first service to use it and it must be bound before registryd starts. |
peios.quiet=N | How much peinit may write to the console: 0 write everything, 1 (default) stay out of a terminal a service owns, 2 also drop ordinary progress. See below. |
Console noise: peios.quiet #
peinit reports its progress to /dev/console, and so does any service that claims that terminal with TTYPath. Two writers, one device: a login prompt started while peinit is still narrating gets written over, and the reader cannot tell input from log. peios.quiet decides who yields.
It sets two independent rules, which is why it is a level and not a flag:
| Terminal a service owns | Everywhere else | |
|---|---|---|
peios.quiet=0 | peinit writes anyway | everything |
peios.quiet=1 (default) | only messages that mean the machine is about to be lost | everything |
peios.quiet=2 | only messages that mean the machine is about to be lost | errors only |
The rules stack rather than scale: errors are never less visible at 2 than at 1. An error overrides a requested blackout — silence was a preference, and an error is news — but it does not override terminal ownership, which is not peinit's to override. Only losing the machine (dropping to Recovery, halting with no shell, a Critical service about to force a reboot) is worth one corrupted line of somebody else's session.
prelude honours peios.quiet=2 as well, since it writes to the same console. The other two levels mean nothing there — prelude exits before the first service starts, so no terminal has an owner yet.
A few lines escape all of this: whatever peinit and prelude print before they have read the command line. Neither can honour a preference it has not seen yet, and those lines are also the only evidence either started at all.
Unknown peios.* tokens are ignored, as is a malformed value on either of the two valued tokens — this parser runs before anything exists to report a diagnostic to, and refusing to boot over a typo in a tuning knob is the worse outcome.
Where to start #
To understand the graph validation and parallel start that drive Phase 2, read Dependencies and ordering.
To understand the Critical-failure reboot path that feeds the boot counter, read Keeping services running.
For the trust and token side of early boot, read peinit at PID 1.
Shutdown
Peios / Using Peios / Services & jobs
Shutdown is boot run in reverse. peinit stops services in reverse dependency order — dependents before the things they depend on — escalating from a polite SIGTERM to a forced SIGKILL, all bounded by a global timeout, and then unmounts, syncs, and performs the final power action. This page covers how it is triggered and how it runs.
How shutdown is triggered #
There are four paths into shutdown, and they are not equal — three are graceful, one is not.
A control command. An administrator with SYSTEM_SHUTDOWN runs peiosctl shutdown <type>:
| Type | Result |
|---|---|
poweroff | Stop everything, unmount, power off. |
reboot | Stop everything, unmount, reboot. |
halt | Stop everything, unmount, halt (CPU stopped, power stays on). |
Signals. As PID 1, peinit treats a handful of signals as shutdown requests:
| Signal | Meaning |
|---|---|
| SIGINT | Reboot — the kernel sends this on Ctrl-Alt-Del. |
| SIGTERM | Poweroff. |
| SIGPWR | Poweroff — a compatibility path for environments that surface power failure or power-button policy as a signal. |
The power button. On Linux, a physical power-button press is a graceful poweroff — peinit watches the machine's input devices under /dev/input and treats a KEY_POWER press (the key going down, not its release or auto-repeat) as a shutdown request. This path is deliberately minimal and fail-soft: if /dev/input is missing, an input device cannot be opened, or a device stops responding, peinit just keeps running and drops that device — the button quietly stops working, but the machine stays up and you can still shut down over the control socket or with a signal. It is not a full power-management policy engine; a future acpid/logind-style daemon can layer richer policy on top by sending control-socket commands.
A Critical service failure — the ungraceful path. When an ErrorControl=Critical service exhausts its restart budget, peinit syncs and reboots immediately. There is no service-stop ordering: the system is in an undefined state and the fastest route to a known one is a reboot. This is what feeds the boot-attempt counter, and a Critical service that fails identically every boot becomes a reboot loop that the counter eventually breaks by escalating to Recovery.
The graceful sequence #
For a poweroff, reboot, or halt, peinit runs an ordered sequence:
- Enter the shutdown state. A flag is set, and while it holds: no new services may start, timer triggers are disarmed, and new control commands are rejected with
INVALID_STATE— exceptstatusqueries, which keep working so you can watch progress. - Suspend Critical-failure semantics. If a Critical service fails during shutdown, it is logged but does not trigger a reboot — the system is already going down, and rebooting would loop.
- Clear Completed services. Oneshot services sitting in Completed (with
RemainAfterExit) have no process; peinit moves them to Inactive so their dependents can be stopped cleanly. - Stop services in reverse dependency order, in waves. Each graceful-stop-eligible service (those in Active or Reloading) gets SIGTERM and
StopTimeoutseconds to exit; if it does not, peinit SIGKILLs its whole cgroup. A service is never stopped until everything thatRequiresorBindsToit has already stopped. Services that are Starting are not stopped gracefully — their startup is cancelled and the cgroup SIGKILLed. - Enforce the global timeout. The whole sequence is bounded by
ShutdownTimeout(default 90 s). When it expires, all remaining services are SIGKILLed; any whose cgroups do not empty within the post-kill timeout (default 5 s) become Abandoned (leaked), and shutdown continues regardless. - Save the random seed. With every service stopped and before any filesystem is touched, peinit writes a fresh seed drawn from the kernel's CSPRNG to
/var/state/peinit/random-seed— this is the entropy the next boot starts from. The write is crash-conscious (written to a temporary file, flushed, then atomically swapped in), and it is best-effort: if it fails, peinit logs it and carries on. Shutdown never stalls on it, and the forced and Critical-reboot paths skip it entirely in favour of getting the machine down fast. - Unmount filesystems. peinit snapshots the mount table and tries to unmount every remaining non-root mount in its namespace — not just the ones it mounted itself — working from the deepest mount points outward. That includes the Phase 1 set (
/proc,/sys,/dev,/dev/pts,/dev/shm,/run,/sys/fs/cgroup) wherever those are still mounted. A mount that is already gone counts as done. If one won't unmount because it is busy, peinit falls back to remounting it read-only; anything it still can't clean up is logged and left for the finalsync()and the kernel, never blocking shutdown. The root filesystem (/) is never unmounted — once the rest are handled, peinit remounts root read-only. - Sync and finish.
sync()to flush pending writes, then the final action — power off, reboot, or halt.
The reverse ordering falls straight out of the dependency graph; there is no separate stop-order configuration. The last services to stop are the TCB daemons everything rests on — eventd, then authd, then lpsd, then registryd dead last, mirroring its position as the first ever started.
STOPPING=1 and timeout extension #
A service that begins shutting down on its own — say, in response to an internal error — can tell peinit by sending STOPPING=1 via sd_notify. peinit acknowledges it and, importantly, stops sending SIGTERM to that service: it is already on its way down, and a redundant signal could interfere.
STOPPING=1 is a courtesy, not a timeout extension. The StopTimeout keeps running; if the process has not exited when it expires, peinit escalates to SIGKILL regardless. A service that genuinely needs longer must say so with EXTEND_TIMEOUT_USEC (see Keeping services running) — and during shutdown those extensions are capped not only by the per-service StopTimeout × 4 but also by the remaining global ShutdownTimeout, whichever is smaller.
Forced shutdown #
When something is wedged and you need the machine down now, repeated SIGINT forces it: three Ctrl-Alt-Del presses within five seconds skip the graceful sequence entirely — SIGKILL everything, sync, reboot. It is the escape hatch for a graceful shutdown that is itself stuck behind an unresponsive service.
Shutdown during boot #
A shutdown requested while peinit is still in Phase 2 boot takes effect immediately: services still Starting are SIGKILLed (they never reached Active, so there is nothing to stop gracefully), services that did reach Active are stopped per the normal sequence, and the boot is abandoned.
How peinit handles signals #
peinit handles all signals through a signalfd read from its event loop — every signal is blocked and read as data, so there are no async signal handlers and no async-safety hazards. PID 1 cannot be killed by any signal; the kernel protects it.
| Signal | Behaviour |
|---|---|
| SIGCHLD | Reap children; match exits to services and jobs. Also reaps orphaned processes the system reparented to PID 1. |
| SIGINT | Reboot request. Repeated within the window → forced reboot. |
| SIGTERM | Poweroff request. |
| SIGPWR | Poweroff request — compatibility path for power-button/power-failure policy. |
| SIGHUP | Ignored — PID 1 has no controlling terminal. |
| SIGPIPE | Ignored — a broken control-socket pipe must never crash PID 1. |
All other signals are ignored.
Where to start #
To understand the reverse ordering and which relationships gate it, read Dependencies and ordering.
To understand the Critical-failure path that bypasses graceful shutdown, read Keeping services running.
For the boot side of the lifecycle and the reboot/recovery escalation, read Boot and boot modes.
Troubleshooting peinit
Peios / Using Peios / Services & jobs
Almost every peinit problem is diagnosed the same way: read the state and the cause. A status query gives you both, and the cause taxonomy tells you what the cause means. This page works backward from common symptoms to the cause and the fix.
$ peiosctl status <service> # state + cause + health + warnings
$ peiosctl list # everything you can see, at a glance
For history beyond what peinit holds in memory — past jobs, prior failures, the exact log line a service died on — query eventd; peinit emits every job and operation transition there.
A service won't start #
status shows it Failed or Skipped. The cause says why:
| Cause | What happened | What to do |
|---|---|---|
ValidationError | The definition is malformed — a bad field, an unresolvable conflict, an illegal health-check timing. | Check the logs for the specific field. Fix the definition; the message names what is wrong. |
DependencyFailure | A Requires/BindsTo target failed or does not exist. | Fix the target first — status it. The dependent recovers once the target can start. |
ConditionSkipped (→ Skipped) | A start-time condition was not met. Not a failure — the service decided it does not apply here. | If it should run, the condition's premise is false (a missing path/file/key). This is often correct behaviour. |
AssertionError | A start-time assert failed — a required precondition is missing. | Create the missing precondition (path, file, directory, registry key), then start again. |
PreHookFailure | An ExecStartPre hook exited non-zero. | Run the hook command by hand as the hook identity to see why. |
ParentSetupFailure | peinit could not even fork — fd/PID exhaustion, a cgroup error. No process was created. | A system-resource problem, not the service's fault. Check for fd/PID limits and cgroup health. |
PreExecFailure | Setup after fork failed — token install, rlimits, environment. | Usually identity: check Identity, that authd is up, and that RequiredPrivileges names real privileges. |
ReadinessTimeout | The process started but never became ready within StartTimeout. | Either the service is genuinely slow (raise StartTimeout, or have it send EXTEND_TIMEOUT_USEC) or it never sends READY=1 (wrong Readiness, or a bug). |
A service keeps restarting #
The service flaps — up, down, up, down. This is restart policy doing its job, but it points at an unstable service.
- It eventually settles in
FailedwithRestartBudgetExhausted. It crashedRestartMaxRetriestimes faster than it could stay healthy forRestartWindow. The fix is the service, not the policy — read its logs for the crash. Raising the budget only delays the inevitable. - It flaps forever and never exhausts the budget. It is briefly reaching Active each cycle and resetting the counter. Either genuinely stabilise it, or — if a health check is failing it after it starts — check the health command; a flaky check restarts a perfectly good service.
- It restarts on a clean exit you did not expect.
RestartPolicy=Alwaysrestarts even successful exits (causeCleanExitRestart). If the service is meant to exit, useOnFailureinstead.
The machine keeps rebooting #
A reboot loop almost always means a Critical service is failing. A ErrorControl=Critical service that exhausts its budget makes peinit sync and reboot; if it fails the same way every boot, you loop.
The boot-attempt counter is designed to break this: after N attempts (default 3) peinit stops and drops you into Recovery mode. From the Recovery shell, find the failing Critical service (its logs are in eventd, which persists across reboots), fix or disable it, and reboot. If you need to intervene before the counter trips, boot with peios.recovery=1 on the kernel command line.
A service is Abandoned #
Abandoned means peinit SIGKILLed the process but it survived — stuck in uninterruptible kernel sleep (D-state), the signature of hung I/O (a dead NFS mount, a failing disk). Nothing in userspace can kill a D-state process.
- The underlying I/O fault is the real problem — investigate the storage or mount, not peinit.
- The service's cgroup is leaked and shows in
warnings. A later start uses a fresh generational cgroup, so the new instance is unaffected. peiosctl reset <service>clears the Abandoned state and re-checks the cgroup: if the stuck process finally died, peinit cleans up; if not, it stays leaked and warns you. Leaked cgroups clear fully only on reboot.
I changed the config and nothing happened #
peinit works from an in-memory snapshot, and each field has a mutability class that decides when a change applies:
- Immutable at runtime (
ImagePath,Type,Identity,RequiredPrivileges,ErrorControl) → takes effect only onrestart. - Apply on next start (dependencies, conditions,
OnFailure) → next start or graph reload. - Reloadable at runtime (timeouts, restart policy, health checks, environment, hooks…) → next time peinit acts on the service.
- Hot-reloaded (
ServiceSecurity) → next control request.
If a change is not landing, check its class first. For a wholesale re-read of every definition, run peiosctl reload-config — it rebuilds and validates the whole graph atomically and swaps it in only if validation passes (running services are untouched). And remember peinit pulls changes from registry notifications rather than having them pushed, so there can be a brief lag.
ACCESS_DENIED #
A control command returns ACCESS_DENIED. peinit ran AccessCheck on your token against the target's descriptor and the right was not granted:
- A service command needs the matching right in the service's ServiceSecurity descriptor (
startneedsSERVICE_START,restartneeds both stop and start, …). shutdownandreload-configneedSYSTEM_SHUTDOWN/SYSTEM_RELOAD_CONFIGin peinit's control descriptor.- The check uses your effective identity at connect time — if you are impersonating, that is what is checked.
Every denial is logged with the caller SID, target, and requested right. Walk it through Debugging a denial.
A dependent never started #
You started (or booted) a service, but something that depends on it never came up:
- A
Requires/BindsTodependent stays blocked until its target reaches a satisfying state (Active, Completed, or Skipped). If the target is stuck in Starting or Failed, so is the dependent — fix the target. - A dependent that connected to its target on boot and got connection refused usually means the target uses
Readiness=Alive— peinit only waited for the process to exist, not to be serving. Switch the target toReadiness=Notifyso it signalsREADY=1when actually ready. (peinit logs this as a validation warning at boot.) - A
Wantsdependent starts regardless of its target — if you needed it to wait,Wantswas the wrong relationship; useRequires.
Booted into Safe mode #
Safe mode starts only Critical and SafeMode=1 services. You land here when graph validation finds a cycle or unresolvable conflict involving a Critical service, or when peios.safemode=1 is on the kernel command line. The TCB is healthy; the configuration is broken.
Fix the graph: the logs name the cycle path (A → B → C → A) or the conflicting pair. Once the cycle is broken or the conflict resolved, a normal boot returns. You can start any service by hand from Safe mode in the meantime — it is only auto-start that is restricted.
Booted into Recovery mode #
Recovery mode gives you a SYSTEM shell on the console and starts nothing else. You reach it when the boot counter hits N, when peios.recovery=1 is set, or when registryd failed in Phase 1 (entered immediately, no counter).
If the registry itself is the problem, use the offline tools that bypass it — they talk to the loregd implementation directly:
$ loregd --inspector # read the storage directly to diagnose
$ loregd --recover-from-backup # restore the backup taken each registryd start
$ loregd --dangerously-clear-database # last resort — wipe; roles re-supply config
Recovery has no TCB protections — it is a SYSTEM shell, full stop. Treat it accordingly, and remember it needs console access (physical, IPMI, or serial); there is no remote recovery yet.
Where to start #
To read states and causes fluently, keep The service lifecycle handy.
For restart, backoff, and Critical-reboot behaviour, see Keeping services running.
For the commands and their outputs, see Controlling services.
Registry key reference
Peios / Using Peios / Services & jobs
This page is the reference catalog for everything peinit reads from or writes to the registry: the full service-definition schema with types and defaults, and every key peinit touches outside the service definitions. For the meaning of each item, follow the link in its row; this page is for looking up a type, a default, or a path.
Registry value types #
Service definition fields map onto registry value types as follows:
| Schema type | Registry type | Is |
|---|---|---|
| string | REG_SZ | A UTF-8 string. |
| multi_string | REG_MULTI_SZ | An ordered list of strings. |
| dword | REG_DWORD | A 32-bit unsigned integer. |
| binary | REG_BINARY | A raw byte sequence. |
| (timestamp) | REG_QWORD | A 64-bit value — used for timer last-run timestamps. |
The service definition schema #
Every service is a key under Machine\System\Services\<name>; these are the values inside it. Only ImagePath is required. Fields are grouped by purpose; for full semantics see Defining a service and the linked pages.
Execution — see The execution environment
| Field | Type | Default | Meaning |
|---|---|---|---|
ImagePath | string | (required) | Absolute path to the service binary. |
Arguments | multi_string | — | Command-line arguments. |
WorkingDirectory | string | / | Working directory (non-empty absolute path). |
TTYPath | string | — | Absolute path to a terminal to attach as the service's stdio and controlling terminal, e.g. /dev/console or /dev/tty1. Absent or empty means the daemon default (/dev/null stdin, captured output). Attaching a terminal suppresses log capture. |
Environment | multi_string | — | KEY=VALUE pairs added to the environment. |
RuntimeDirectories | multi_string | — | Directories created directly under /run just before the main process starts, each secured for SYSTEM, Administrators, and the service's own SID. |
LimitNOFILE | dword | — | RLIMIT_NOFILE. |
LimitCORE | dword | — | RLIMIT_CORE, in bytes. |
Type and readiness — see Simple and Oneshot services
| Field | Type | Default | Meaning |
|---|---|---|---|
Type | dword | 0 (Simple) | 0 = Simple, 1 = Oneshot. |
Readiness | dword | 0 (Notify) | 0 = Notify (READY=1), 1 = Alive. Ignored for Oneshot. |
RemainAfterExit | dword | 0 | Oneshot only — stay Completed after a successful exit. |
SuccessExitCodes | multi_string | — | Non-zero exit codes treated as success (each 0–255). |
Activation — see Triggers and timers
| Field | Type | Default | Meaning |
|---|---|---|---|
Triggers | multi_string | — | boot and/or timer:<schedule>. Absent = demand-only. |
Disabled | dword | 0 | If 1, triggers must not activate the service. |
SafeMode | dword | 0 | If 1, attempt to start in Safe mode. Critical implies SafeMode. |
Conditions | multi_string | — | Start-time checks; a failure skips the service. |
Asserts | multi_string | — | Start-time checks; a failure fails the service. |
PreStartCheckTimeout | dword | 5 | Seconds allowed for the helper that evaluates filesystem Conditions/Asserts; if it overruns, it is killed and the check counts as not satisfied (a Condition skips, an Assert fails). |
TimerPersistent | dword | 1 | Catch up a missed timer run after a reboot. |
TimerJitter | dword | 0 | Maximum random delay (seconds) added to each firing. |
Identity — see Service identity and privileges
| Field | Type | Default | Meaning |
|---|---|---|---|
Identity | string | LocalService | Principal name or SID for the service token. |
RequiredPrivileges | multi_string | — | Privilege allow-list; all others are removed from the token. |
HookIdentity | string | (service's Identity) | Identity for ExecStartPre/ExecStartPost. |
Dependencies — see Dependencies and ordering
| Field | Type | Default | Meaning |
|---|---|---|---|
Requires | multi_string | — | Hard dependencies. |
Wants | multi_string | — | Soft dependencies. |
BindsTo | multi_string | — | Runtime coupling. |
Conflicts | multi_string | — | Mutual exclusion. |
OnFailure | string | — | Service to start when this one enters Failed. |
Supervision and health — see Keeping services running
| Field | Type | Default | Meaning |
|---|---|---|---|
ErrorControl | dword | 0 (Normal) | 0 = Normal, 1 = Critical (sync + reboot on irrecoverable failure). |
RestartPolicy | dword | 1 (OnFailure) | 0 = Never, 1 = OnFailure, 2 = Always. |
RestartMaxRetries | dword | 5 | Consecutive restarts before Failed. |
RestartWindow | dword | 120 | Seconds of Active health that resets the restart counter. |
RestartDelay | dword | 1 | Backoff base (doubles per failure, capped at 60). |
HealthCheck | string | — | Periodic health-check command. |
HealthCheckInterval | dword | 30 | Seconds between health checks. |
HealthCheckTimeout | dword | 5 | Seconds before a check is killed and failed. |
HealthCheckRetries | dword | 3 | Consecutive failures before unhealthy. |
WatchdogTimeout | dword | 0 | Seconds between expected WATCHDOG=1 pings; 0 disables. |
Transition phases — see The service lifecycle and Controlling services
| Field | Type | Default | Meaning |
|---|---|---|---|
ExecStartPre | multi_string | — | Commands run before the binary (sequential; any failure aborts start). |
ExecStartPost | multi_string | — | Commands run after readiness / successful exit (failure logged, not fatal). |
ExecReload | string | — (SIGHUP) | Reload command, or signal:<NAME>. |
StartTimeout | dword | 30 | Seconds for the whole start sequence. |
StopTimeout | dword | 10 | Seconds between SIGTERM and SIGKILL. |
Notify, fds, security, metadata
| Field | Type | Default | Meaning |
|---|---|---|---|
NotifyAccess | dword | 0 (Main) | Who may send sd_notify (only Main is supported). |
FdStoreMax | dword | 0 | Max fds in the fd store; 0 disables it. |
ServiceSecurity | binary | inherit parent | Descriptor controlling who may manage the service. |
DisplayName | string | — | Human-readable name for status display. |
Description | string | — | Description of the service. |
Service definition keys #
| Key | Type | Purpose | See |
|---|---|---|---|
Machine\System\Services\ | (parent key) | Parent of all service definitions; each child key is a service. | Defining a service |
Machine\System\Services\SchemaVersion | dword | Schema version guard (currently 1). | Defining a service |
Machine\System\Services\<name>\LastTimerRun | REG_QWORD | Last-run timestamp for a single-trigger persistent timer. Written by peinit. | Triggers and timers |
Machine\System\Services\<name>\TimerState\ | (subkey) | Per-trigger last-run timestamps for multi-trigger services. Each value is named by the percent-encoded schedule string and holds a REG_QWORD. | Triggers and timers |
Boot configuration #
Under Machine\System\Boot\:
| Key | Type | Default | Purpose | See |
|---|---|---|---|---|
MaxParallelStarts | dword (> 0) | 10 | Maximum services starting concurrently. Missing → default; zero/invalid → Recovery. | Boot and boot modes |
BootSuccessGrace | dword | 30 | Seconds a Critical service must hold a satisfying state before boot is successful. | Boot and boot modes |
ShutdownTimeout | dword | 90 | Maximum seconds for the whole graceful shutdown. | Shutdown |
SettleTimeout | dword | 5 | Seconds to wait for the boot set to settle before starting boot:settled services anyway. | Triggers and timers |
PostKillTimeout | dword | 5 | Seconds peinit waits for a service cgroup to drain after SIGKILL before treating it as stuck. ShutdownTimeout bounds the shutdown as a whole; this bounds one service's last stage of it. | Shutdown |
Operational parameters #
Under Machine\System\Init\:
| Key | Type | Default | Purpose | See |
|---|---|---|---|---|
ControlSecurity | binary | SYSTEM full; Administrators shutdown + reload-config | Descriptor for system-level control operations. | Who can manage a service |
MaxControlConnections | dword | 32 | Maximum concurrent control-socket connections. | Controlling services |
MaxRequestSize | dword | 65536 | Maximum control-socket request size (bytes). | Controlling services |
ConnectionTimeout | dword | 30 | Seconds before an idle control connection is closed. | Controlling services |
MaxLogLineLength | dword | 8192 | Maximum bytes per service output line before truncation. | Service output and logging |
MaxLogBufferPerService | dword | 65536 | Maximum bytes buffered per service pipe before back-pressure. | Service output and logging |
LogReadBytesPerEvent | dword | 16384 | Bytes drained from a service's output pipe per readable event. Larger favours throughput on chatty services, smaller favours fairness between them. | Service output and logging |
PreEventdBuffer | dword | 1048576 | Total bytes of service output held in memory before eventd is up to receive it. Overflow drops the oldest output; it never blocks a service. | Service output and logging |
EnvVars\ | (parent key) | empty | Default environment variables injected into Phase 2 services (value name = variable, REG_SZ data = value). The descriptor here is security-critical. | The execution environment |
ProvisionedPaths\ | (parent key) | empty | Boot-time path provisioning: each child key names one directory or file that peinit creates and secures after registryd starts and before Phase 2 — the registry-backed equivalent of tmpfiles.d. | Boot and boot modes |
ProvisionedPaths\<name>\Kind | string | (required) | directory or file — what to create at Path. | Boot and boot modes |
ProvisionedPaths\<name>\Path | string | (required) | Absolute path to create or verify. | Boot and boot modes |
ProvisionedPaths\<name>\Security | binary | SYSTEM + Administrators full, users read/traverse | Peios file security descriptor to apply to the object. | Boot and boot modes |
ProvisionedPaths\<name>\Required | dword | 0 | If 1, a failure to provision this entry sends boot to Recovery mode before Phase 2 starts. | Boot and boot modes |
Keys peinit reads from other subsystems #
| Key | Type | Purpose | See |
|---|---|---|---|
Machine\System\eventd\LogSocketPath | string | Path of eventd's log datagram socket, where peinit forwards service output. | Service output and logging |
Where to start #
For how these fields combine into a working definition and when changes take effect, read Defining a service.
To look up the meaning, valid range, and effect-timing of any key on a live system, use regman.
Mount policies
Peios / Using Peios / Mount policies
A mount policy is the per-superblock setting that controls how FACS treats a filesystem. The policy tells the kernel: does FACS apply here at all? When a file has no SD, what should happen? When the kernel synthesises a default SD because the file doesn't have one, where does the template come from? These questions are uniform within a filesystem — every file on a single mount gets the same treatment — and the answer is the mount policy.
There are four policy classes in v0.20. Three are FACS-managed (the kernel applies KACS access control); one is unmanaged (FACS doesn't apply at all). The class is decided when the filesystem is mounted, and changes apply lazily — existing handles are unaffected, but the next access against any file on the mount sees the new policy.
This page introduces the four classes at a glance. Policy classes covers each in detail with the missing-SD and corrupt-SD rules; SD storage by filesystem covers how each filesystem type physically stores the SD; Managing mounts covers the syscalls and the generation counter for cache invalidation.
Why mount policies exist #
A FACS-managed file needs a security descriptor. The DACL on it is the access decision input. The kernel needs to be able to look at any FACS-managed file and find an SD.
But not every filesystem can store an SD on every file. ext4 stores SDs in xattrs, but only up to 4 KB without special features; XFS supports xattrs natively up to 64 KB; tmpfs has no persistent storage at all; FAT and exFAT have no xattr support; remote filesystems like NFS may not surface SDs to the client. The kernel needs different behaviours for these cases.
That's what mount policies provide. The policy is the kernel's per-filesystem answer to "what to do when a file does not have an SD I can read":
- For some filesystems, refuse access (the file should have an SD and doesn't, so something is wrong).
- For some, synthesise an SD on the fly in memory (and don't try to write it back).
- For some, synthesise an SD and try to write it back so future accesses don't need to re-synthesise.
- For some (the kernel's own pseudo-filesystems), don't apply FACS at all.
Each of these is one of the four classes. The class is a property of the mount, not the file.
The four classes at a glance #
| Class | What it does | Typical use |
|---|---|---|
facs_deny_missing | FACS-managed. A file with no SD is unreachable — every access fails. | System mounts (root, /home, /var) where every file should have an SD. |
facs_synthesize_ephemeral | FACS-managed. A missing SD is synthesised in memory but not written back. The next access re-synthesises. | Removable media, FAT/exFAT, NFS client mounts — filesystems that may not preserve xattrs cleanly or where modifying SDs is not appropriate. |
facs_synthesize_persistent | FACS-managed. A missing SD is synthesised and immediately written back. Subsequent accesses use the stored SD. | Filesystems being adopted into Peios — a previously-unmanaged filesystem getting an SD on first access. |
unmanaged | FACS does not apply. The kernel uses its own per-operation access rules for files under this mount. | /proc, /sys — kernel pseudo-filesystems with their own access semantics. |
unmanaged is not settable via the public ABI — only the kernel itself sets this class, at boot time, for the pseudo-filesystems it manages. The other three are administratively settable.
A mounted filesystem has exactly one of these classes at any time. Changing the class is a single operation that affects every file on the mount going forward (subject to the lazy-invalidation behaviour).
Per-superblock, not per-path #
A mount policy is per-superblock, not per-path or per-bind-mount. All paths visible through the same underlying filesystem instance share the same policy.
The implications:
- A bind mount of
/etc/peiosto/old/etc/peiosdoes not let you set different policies for the two paths. They are the same superblock; one policy. - A read-only bind mount and the underlying read-write mount of the same filesystem share the policy.
- Two separate mounts of the same filesystem (uncommon but possible in some namespacing setups) each have their own superblock instance and can have different policies.
The per-superblock rule keeps mount policy simple. The alternative — per-mount or per-path — would mean a single inode could be subject to different policies depending on how it was reached, which would be operationally confusing.
What the policy decides #
For a FACS-managed mount (any of the three managed classes), the policy decides three things:
- What happens when an inode has no SD attached. Different classes handle this differently — deny, synthesise-in-memory, or synthesise-and-persist.
- The mount-level SD template. Each FACS-managed mount can carry a default SD template, used when synthesising an SD for a file that has no parent-derivable SD (root of the filesystem, typically).
- Whether mutations to synthesised SDs are persisted. Ephemeral synthesises in memory only; persistent writes back to the filesystem.
For the unmanaged class, the policy decides one thing: FACS doesn't apply. Files under this mount are governed by whatever per-operation rules the kernel has for that pseudo-filesystem.
What the policy does not decide #
A few clarifications:
- The policy does not change what filesystem operations are available. Read, write, mmap, all the usual operations work normally on every class. The policy affects how the access check runs, not what operations exist.
- The policy does not control persistence of file data. A file's contents are persisted (or not) by the underlying filesystem; the policy is only about SD persistence.
- The policy does not affect already-open handles. The handle model caches access masks on fds; changing the policy does not retroactively change cached masks. Future opens see the new policy.
- The policy does not propagate inheritably. Changing a parent's mount policy doesn't affect mounted-elsewhere child directories — each mount carries its own policy on its own superblock.
Where to start #
If you want each class in detail — what facs_deny_missing actually does at runtime, how synthesize_ephemeral works for FAT, what happens when a synthesised SD turns out to be problematic — read Policy classes.
If you want to know how each filesystem stores SDs — ext4's ea_inode, XFS's native xattr support, NTFS via system.ntfs_security, the no-xattr-support filesystems — read SD storage by filesystem.
If you want the operational story — kacs_set_mount_policy, kacs_get_mount_policy, the generation counter that makes invalidation lazy, why mount policy changes don't walk the filesystem — read Managing mounts.
The commands #
The concept pages above describe the model; three command-line tools put it to work. mount attaches a filesystem to the mount tree and is where a KACS mount policy (and its optional SD template) is chosen at attach time — the one place the policy classes above meet a command line. umount detaches a filesystem again, by mount point or by source, with lazy, forced, recursive and all-targets variants. lsblk lists the block devices available to mount, with their filesystems and, like ls -l, their SD-derived owner and mode. Use these when you want the operational reference for a specific flag rather than the model behind it.
Policy classes
Peios / Using Peios / Mount policies
The four policy classes are the answer to one question: when FACS needs an SD for a file and the filesystem doesn't have one, what should happen?
The three managed classes — facs_deny_missing, facs_synthesize_ephemeral, facs_synthesize_persistent — answer the question in three different ways. The fourth, unmanaged, answers it by stepping out of the question entirely: FACS doesn't apply, so the question doesn't arise.
This page covers each class in detail, the synthesis chain that produces an SD when one is needed, and the universal corrupt-SD rule.
facs_deny_missing #
A FACS-managed mount where missing SDs are treated as an error. Every file on a facs_deny_missing mount must have a valid SD; one that does not is unreachable.
The runtime behaviour:
- The kernel reads the file's SD from the filesystem (typically an xattr).
- If no SD is present, the access fails. The kernel returns
-EACCESfor any operation that would require an SD-based access check. - The file is effectively unreadable, unwritable, undeletable, unmodifiable. Every access fails because there is no policy to evaluate against.
This is the strict mode. It is appropriate for filesystems where every file should have been provisioned with an SD — system mounts, application storage, anywhere the operator has set up the filesystem deliberately.
The defaults for Peios system mounts (the root filesystem, /home, /var) are facs_deny_missing. The image-build process ensures every file in the base image has an SD; subsequent file creation always inherits an SD from the parent. There is no path by which a file without an SD legitimately ends up on these filesystems.
If somehow a file does end up without an SD — a backup-restore tool that skipped xattrs, a misbehaving filesystem driver — that file is unreachable. The fix is to give it an SD (kacs_set_sd, requires WRITE_DAC, which the caller may not have if they cannot read the file).
Practical upshot: a facs_deny_missing mount is strict. Files without SDs stay unreachable until given one. This is the right setting for filesystems where you trust the provisioning.
facs_synthesize_ephemeral #
A FACS-managed mount where missing SDs are synthesised in memory, but not written back to the filesystem. The synthesised SD is used for the access check; it does not become part of the file's persistent state.
The runtime behaviour:
- The kernel reads the file's SD from the filesystem.
- If no SD is present, the kernel synthesises one using the synthesis chain (covered below).
- The synthesised SD is used for the access check.
- The synthesised SD is not written back to the filesystem. It exists only in the kernel's cache for the duration this inode is in memory.
- On a subsequent open of the same file (after the inode has been evicted from cache, say), the SD is synthesised again. The same inputs produce the same output, so the same SD comes out — but the synthesis is repeated.
This class is appropriate for filesystems where you can't or don't want to write SDs:
- Removable media (USB drives, optical media). You don't want to modify the media just because you read a file on it.
- FAT and exFAT. These filesystems have no xattr support; the SD has nowhere to go even if you wanted to write it.
- NFS client mounts. The actual file lives on a remote server; modifying its SD via xattr would have unpredictable effects.
- tmpfs and devtmpfs. Per-instance pseudo-filesystems with no real persistence.
The synthesis-only-in-memory pattern lets FACS apply access control to these filesystems without changing their on-disk content. The trade-off is that the cost of synthesising is paid every time the inode is cold.
facs_synthesize_persistent #
A FACS-managed mount where missing SDs are synthesised and written back. Each file is synthesised once; from then on it has a real SD.
The runtime behaviour:
- The kernel reads the file's SD from the filesystem.
- If no SD is present, the kernel synthesises one using the synthesis chain.
- The synthesised SD is used for the access check — immediately, from the kernel's cache.
- The synthesised SD is also written back to the filesystem (typically as the SD xattr). The file now has a persistent SD.
- On a subsequent open, the SD is read from storage — no re-synthesis needed.
The write-back in step 4 does not happen during the access — it is deferred until just after the triggering operation finishes (when the kernel can safely write without holding the locks the access is using). In practice the SD is on disk by the time the syscall that first touched the file returns to userspace, so for an observer it is effectively immediate.
Because the synthesised SD is a deterministic function of its inputs (the parent's SD, or the mount template), the on-disk copy is a cache of a value the kernel can always recompute. If the write-back is interrupted — the inode is evicted first, or the process exits in the gap — nothing is lost: the next access re-synthesises the identical SD and tries the write-back again. A file is never left with the wrong SD, only occasionally with the SD still in memory rather than on disk. (A mount-template change in that gap simply discards the pending SD and re-synthesises against the new template, so a superseded SD is never frozen onto the disk.)
This class is for filesystems being adopted into Peios. A previously-unmanaged filesystem (say, an ext4 volume from a Linux system without KACS) can be mounted with facs_synthesize_persistent; every file accessed gets an SD on first access; over time, every file ends up with an SD.
The use case: migrating an existing filesystem to KACS-managed without a single-pass conversion tool. The synthesis-on-access pattern lets the conversion happen incrementally as files are touched. After enough time, every regularly-accessed file has been adopted; rarely-touched files get adopted the next time they are read.
After all files have SDs, the mount can be switched to facs_deny_missing for strict mode. The transition is a single kacs_set_mount_policy call; existing SDs are unaffected, and any straggler files without SDs become unreachable (which is the desired effect of switching to strict).
unmanaged #
The unmanaged class is the special case for filesystems FACS shouldn't apply to. The kernel uses its own per-operation access rules for files under this mount; no SD-based access check runs at FACS's level.
Specifically:
/procisunmanaged. Access to/proc/<pid>/*files is governed by the process-level checks (process SD + PIP, per the two-check rule), not by FACS./sysisunmanaged. Writes are restricted toBUILTIN\AdministratorsandSYSTEMby hardcoded rule./sys/kernel/security/kacs/*has explicit SDs set by the kernel; reading these uses the kernel's own logic.
The unmanaged class cannot be set via the public ABI. The kacs_set_mount_policy syscall rejects attempts to set this class with -EINVAL. Only the kernel itself sets this class — at boot, for the pseudo-filesystems it manages.
The reason for restricting this: making a regular filesystem unmanaged would mean FACS has no say over it at all, which would be a meaningful operational decision but also a security-relevant one. The kernel reserves the class for its own use.
If you mount a regular filesystem and don't want FACS to apply, the closest you can get is facs_synthesize_ephemeral with a permissive mount template — files get a permissive synthesised SD that effectively grants access. This is not the same as no FACS, but it is the closest path available through the public ABI.
The synthesis chain #
For the two synthesising classes, when the kernel needs to produce an SD for a file with none, it uses a chain of sources. The first source that yields a usable SD wins.
flowchart LR
A["File has no SD"] --> B["Inherit from parent directory"]
B -->|parent has inheritable ACEs| F["Use computed SD"]
B -->|parent has none / no parent| C["Mount-level SD template"]
C -->|template is set| F
C -->|no template| D["Hardcoded fallback SD"]
D --> F
In order:
- Parent directory inheritance. The kernel reads the parent directory's SD and computes what a newly-created child would inherit (per the Inheritance rules). If this produces a usable SD (with inheritable ACEs from the parent), that SD is used.
- Mount-level SD template. Each FACS-managed mount can carry a default SD template — set via
kacs_set_mount_policyalong with the policy class. The template is a complete self-relative SD (max 64 KB). If the parent did not yield an SD, the template is used. - Hardcoded fallback. If neither the parent nor the template produces an SD, the kernel uses a hardcoded fallback:
GENERIC_ALLto SYSTEM andBUILTIN\Administrators;GENERIC_READ | GENERIC_EXECUTEto Everyone. The owner is set to SYSTEM, group to SYSTEM.
The fallback is conservative: administrators get full control, others get read-and-execute. It is what every filesystem mounted with facs_synthesize_* and no specific configuration falls back to, and what every root-of-mount file ends up with on a freshly-installed Peios system.
For most mounts, the fallback is the safety net — typical files have parents with inheritable ACEs, and synthesis lands on step 1. The template is used for files at the root of the mount (where there's no parent on this filesystem) or in unusual cases where parent-inheritance doesn't apply.
Corrupt SD handling #
A universal rule across all FACS-managed classes: a corrupt SD is treated as a denial. If the SD on a file exists but fails structural validation — bad header, malformed ACL, invalid SID, exceeds the size limit, anything that prevents the parser from making sense of it — the access check fails with -EACCES.
The kernel does not fall back to synthesis when an existing SD is corrupt. The synthesis path is for files with no SD; a file with a broken SD is different. The kernel:
- Detects the corruption when reading the SD.
- Returns
-EACCESfor the access. - Emits an audit event (one per inode per cache population — the same corrupt SD encountered repeatedly during one mount's lifetime produces one audit event for that inode, not one per access).
The corruption audit event is part of the audit stream and useful for diagnostics. A misbehaving backup-restore tool that produced corrupt SDs on a restored set of files generates a flurry of these events when the files are first accessed.
The corrupt-SD rule is the same across all three FACS-managed classes. facs_deny_missing denies missing; facs_synthesize_* synthesises missing; all three deny corrupt. The "missing" and "corrupt" cases are different — missing is "no SD was attached" and is recoverable through synthesis or operator intervention; corrupt is "the SD that was attached is broken" and requires either fixing the SD or accepting denial.
Class transitions #
Changing a mount's policy class is allowed (subject to access rules covered in Managing mounts). The interesting transitions:
facs_synthesize_persistent→facs_deny_missing. Common during migration. As files are adopted bysynthesize_persistent, they accumulate SDs. Once enough have been adopted, switch todeny_missingto enforce strictness. Any file still missing an SD becomes unreachable; an administrator can either accept that or set SDs on the holdouts.facs_synthesize_ephemeral→facs_synthesize_persistent. Less common. Would convert an ephemeral mount to a persistent one — the next access of any file without a stored SD would write the synthesised SD back. This effectively "snapshots" the synthesis on first access.facs_deny_missing→facs_synthesize_*. Unusual; would relax strictness. The kernel allows it but the operational reasoning is rarely good — synthesis is a recovery mechanism, not an everyday setting.
A class transition is just a policy update; no files are touched at the transition. The new policy applies to future accesses. Existing cached state (synthesised SDs in memory) may need to be re-evaluated — see the generation counter in Managing mounts.
Where to go next #
For how each filesystem physically stores the SD, read SD storage by filesystem.
For the syscalls that set and read a mount's policy, read Managing mounts.
For choosing a policy from the command line at attach time, read mount.
SD storage by filesystem
Peios / Using Peios / Mount policies
A security descriptor is structurally a single piece of metadata that has to live somewhere. Different filesystems have different ways to hold metadata; some have built-in security-attribute support (NTFS), some have generic extended attribute support that fits (ext4, XFS, Btrfs), some have neither (FAT, exFAT). The mount-policy class you choose for a filesystem depends partly on what the filesystem can store and partly on what behaviour you want.
This page covers the storage mechanism each filesystem type uses and the implications.
The canonical xattr #
For filesystems that support extended attributes, Peios stores the SD in the security.peios.sd xattr. The name is in the security.* namespace, which requires privileged access to read or write at the filesystem layer — which doesn't matter for FACS-managed access (FACS denies direct xattr operations on the SD xattr unconditionally; access goes through kacs_get_sd / kacs_set_sd), but does mean the xattr survives operations that respect security xattrs.
For NTFS, the xattr name is system.ntfs_security — the native NTFS security attribute, accessed through the same xattr interface. Using the NTFS-native name means SDs round-trip cleanly between Peios and other operating systems that read NTFS.
The choice of xattr name is filesystem-specific:
| Filesystem | xattr name |
|---|---|
| ext4, XFS, Btrfs, tmpfs, others with generic xattr support | security.peios.sd |
NTFS (via the ntfs3 driver) | system.ntfs_security |
For other filesystems (with no xattr support or no convention), the SD is held in memory and re-synthesised on each cold open. See the per-filesystem sections below.
ext4 #
ext4 supports xattrs natively. SDs up to about 4 KB fit in the inline xattr space; larger SDs require the ea_inode feature.
ea_inode is an ext4 feature that lets a single xattr value spill into a dedicated inode rather than being limited to the size of the inode's inline xattr area. With ea_inode enabled at filesystem creation time (or enabled later via tune2fs), an SD can be up to the standard 65,535-byte SD limit.
mke2fs on Peios enables ea_inode by default for ext4, and raises the inode size to 512 bytes so that a typical SD fits inline in the first place — see mke2fs. Real-world SDs rarely exceed the inline xattr space, but pathological cases (deeply-nested ACEs, lots of inherited ACEs from a complex parent) can push past 4 KB; ea_inode accommodates them.
If an ext4 filesystem without ea_inode is mounted on Peios and a file has an SD larger than its inline xattr space could hold, the filesystem's xattr write would fail. The kernel handles this by either:
- Refusing the
kacs_set_sdcall with an error indicating the filesystem cannot hold the SD. - Or, in the
facs_synthesize_persistentcase, refusing the write-back and treating the file as if it has no SD (re-synthesising next time).
The cleanest fix is to enable ea_inode. For everyday use, the inline xattr space is sufficient.
XFS #
XFS supports xattrs natively, up to 64 KB per attribute by default. This is comfortably more than the SD size limit (65,535 bytes), so SDs fit without any special configuration.
XFS is a fine choice for FACS-managed filesystems. No ea_inode-style consideration is needed; SDs just work.
Btrfs #
Btrfs supports xattrs natively. Small xattrs are stored inline; larger ones get their own extent. SDs of any practical size fit without special handling.
Btrfs's snapshot and clone behaviour is interesting for SDs: a Btrfs snapshot of a directory includes the SDs of every file within. A snapshot is a point-in-time view; the SDs in the snapshot reflect what they were when the snapshot was taken. If the original files' SDs are subsequently modified, the snapshot still has the old SDs. This is the expected behaviour but worth noting for backup/snapshot workflows.
tmpfs and devtmpfs #
tmpfs is an in-memory filesystem. It supports xattrs, but everything it stores lives in RAM and is lost on unmount or reboot. SDs on tmpfs are present while the filesystem is mounted; they vanish when it is unmounted.
tmpfs is typically mounted with facs_synthesize_ephemeral. The synthesis happens in memory anyway (the tmpfs storage is memory), so there's no operational difference between "store an SD in the tmpfs xattr" and "synthesise an SD into the kernel's per-inode cache".
devtmpfs is the kernel-managed filesystem for device nodes. SDs on devtmpfs are applied by udev rules, not by FACS-driven synthesis. The udev daemon (or its Peios equivalent) sets the SD on each device node as the node is created. This is a different model from the synthesis-based pattern other filesystems use; it works because the device-node population is controlled centrally and the SDs can be set deterministically.
devtmpfs uses the facs_synthesize_ephemeral class, but in practice the synthesis path is rarely hit because udev provides the SDs.
NTFS — round-trip via ntfs3 #
NTFS is the Windows-native filesystem. Peios mounts it through the ntfs3 kernel driver, which exposes the NTFS security attribute via the standard xattr interface under the name system.ntfs_security.
The implications:
- SDs written by Peios to an NTFS volume use the same on-disk format as Windows. A Windows system reading the same volume sees the SD natively.
- SDs written by Windows to an NTFS volume are readable by Peios. Round-tripping a volume between the two operating systems preserves SDs.
This is the "binary-compatible" property the SD format gives. The wire format is the same; the filesystem driver translates between the xattr interface and the on-disk security attribute.
NTFS volumes are typically mounted facs_synthesize_ephemeral rather than facs_deny_missing. The reasoning: an NTFS volume from Windows may have files without Peios-recognised SDs (the SDs are Windows-native and may use principals that don't exist on the Peios system). Ephemeral synthesis lets such files be accessible without modifying the volume's stored SDs.
FAT and exFAT #
FAT and exFAT have no xattr support at all. There is no place to store an SD; the on-disk format simply doesn't have the metadata channel.
FACS handles this by using facs_synthesize_ephemeral for FAT/exFAT mounts. Every file gets a synthesised SD in memory; no SD is ever written back. The synthesised SD applies for the file's time in the inode cache; when the file is evicted, the SD is gone and will be re-synthesised on next access.
This means FAT/exFAT files cannot be given persistent KACS-style permissions. The synthesised SD is the same every time (assuming the same inputs — parent SD, mount template), so the access decision is deterministic, but there is no way to customise per-file.
For most FAT use cases this is fine — FAT is typically used for removable media or boot partitions where uniform-permissions semantics is acceptable. The mount-level SD template can be configured to grant whatever access pattern is appropriate for the mount as a whole.
NFS — synthesise locally, enforce remotely #
NFS client mounts are a unique case. The actual files live on a remote server; the local filesystem driver is a network protocol implementation, not a real filesystem.
The mount class is facs_synthesize_ephemeral. FACS synthesises a local SD per the synthesis chain (typically yielding a sensible default from the mount template) and uses it for local access control. The local FACS check decides whether to forward the operation to the server.
If the local check passes, the operation goes to the server. The server has its own access control (potentially also Peios with FACS, potentially another OS with different rules); the server's access control decides whether the operation actually proceeds. If the server denies, the operation fails with whatever error the protocol returns (typically -EACCES or -EIO).
This is "dual authority" — both client and server have a say. The client's denial is local-final; the server's denial is remote-final. There is no single source of truth for the access decision.
The implications were covered in Special cases under "NFS — dual authority": don't trust local FACS results for security, expect I/O errors from server-side denial, the local synthesised SD is not the server's actual SD.
/proc and /sys #
/proc and /sys are kernel pseudo-filesystems. Their mount-policy class is unmanaged — set by the kernel at boot, not changeable via the public ABI.
/proc doesn't have an SD per-file in the FACS sense. Access to /proc/<pid>/* is gated by the per-process rules (process SD + PIP, from the two-check rule). The kernel implements these checks directly when serving /proc file operations.
/sys similarly has its own per-file rules. The /sys/kernel/security/kacs/* entries have explicit SDs the kernel maintains; other /sys files have hardcoded rules ("writes restricted to BUILTIN\Administrators and SYSTEM").
The unmanaged class is what tells FACS to not interfere with these. The kernel knows what it is doing with its own pseudo-filesystems; FACS stays out of the way.
Stacked filesystems — overlayfs and StrataFS #
A stacking filesystem presents files that physically live somewhere else. overlayfs merges a read-only lower layer with a writable upper one; StrataFS composes several directories into one view. Neither stores a security descriptor of its own, and neither needs to: the descriptor belongs to the file, and the file is on the layer underneath.
So a stack forwards. When the kernel needs the SD of a file in the merged view, it asks the layer that actually holds it, and what comes back is that layer's effective descriptor — which is to say, whatever an access check on the underlying file would have used. If the layer underneath stores an SD, the stack sees the stored one. If the layer underneath synthesises, the stack sees the synthesised one. Synthesis composes upward through the stack; it is not a private arrangement between the kernel and the bottom layer.
That is what makes the common live-boot arrangement work:
overlay <- the merged root, facs_deny_missing
upper: tmpfs <- writable scratch, stores real SDs
lower: squashfs <- read-only image with no SDs, facs_synthesize_ephemeral
The overlay stores nothing and can still be facs_deny_missing, because neither layer beneath it ever answers "missing": the tmpfs has stored descriptors, and the squashfs synthesises one for every inode.
Two consequences worth holding onto:
The stack's own policy class governs only what happens when every layer beneath it has nothing to offer. Pick it for that case. Over a synthesising lower, facs_deny_missing is the strict-and-correct choice; over an unmanaged lower it would lock the whole view.
A copy-up does not carry the lower's descriptor up with it. When overlayfs copies a file from the lower layer to the upper to make it writable, it creates a genuinely new inode in the upper, and that inode gets its descriptor by ordinary inheritance from its parent in the upper — not by duplicating the lower's. This is deliberate: security.peios.sd is not userspace-writable (SD mutation is kacs_set_sd's job), so a verbatim copy could not be performed even in principle, and the inherited answer is the right one anyway. Every other extended attribute is copied up normally.
Summary #
| Filesystem | SD storage | Typical mount class |
|---|---|---|
| ext4 | security.peios.sd xattr; ea_inode for SDs > 4 KB | facs_deny_missing (for system mounts) |
| XFS | security.peios.sd xattr, native large support | facs_deny_missing |
| Btrfs | security.peios.sd xattr, native | facs_deny_missing |
| tmpfs | security.peios.sd xattr in memory | facs_synthesize_ephemeral |
| devtmpfs | xattr in memory, populated by udev | facs_synthesize_ephemeral |
| NTFS (ntfs3) | system.ntfs_security xattr, NTFS-native | facs_synthesize_ephemeral (typical) |
| FAT / exFAT | No SD storage; in-memory only | facs_synthesize_ephemeral |
| NFS client | No client-side storage; synthesise locally | facs_synthesize_ephemeral |
| squashfs | No SD storage unless the image was built with one | facs_synthesize_ephemeral |
| overlayfs | None of its own — reads the real layer | facs_deny_missing |
| StrataFS | None of its own — reads the provider | facs_deny_missing, not settable |
| /proc | n/a — no FACS | unmanaged |
| /sys | n/a — kernel-managed SDs | unmanaged |
The pattern: real on-disk filesystems with xattr support get one of the facs_* classes depending on policy needs. Pseudo-filesystems and the kernel's own filesystems are unmanaged. Filesystems without xattr support default to ephemeral synthesis as the only viable mode. Stacking filesystems store nothing and defer to the layer beneath.
Where to go next #
For what each policy class does with a missing SD, read Policy classes.
For setting and reading a mount's policy at runtime, read Managing mounts.
For the structure of the SD being stored, read Security descriptors.
Managing mounts
Peios / Using Peios / Mount policies
A mount's policy is set via a syscall, read via a syscall, and otherwise sits as kernel-internal state on the filesystem's superblock. Mount-policy changes are administrative — they require SeTcbPrivilege — and they take effect lazily, without the kernel walking the filesystem to update anything.
This page covers the two syscalls (kacs_set_mount_policy, kacs_get_mount_policy), the generation counter that makes lazy invalidation work, and what does and does not happen at a policy change.
kacs_set_mount_policy #
The write syscall:
result = kacs_set_mount_policy(fd, args)
Where fd is a file descriptor referring to any object on the target superblock (the kernel uses the fd to identify which superblock — the file itself does not need to be the root of the mount). args is a kacs_mount_policy_args struct:
| Field | Meaning |
|---|---|
policy | The new policy class (one of KACS_MOUNT_POLICY_DENY_MISSING, KACS_MOUNT_POLICY_SYNTHESIZE_EPHEMERAL, KACS_MOUNT_POLICY_SYNTHESIZE_PERSISTENT). |
flags | Reserved; must be zero. |
generation | (Output) The new generation counter value after the change. |
template_sd_ptr, template_sd_len | Optional mount-level SD template. Max 64 KiB. |
The kernel:
- Validates the fd and identifies the superblock.
- Validates the policy value. Attempts to set
KACS_MOUNT_POLICY_UNMANAGED(which is not settable via this ABI) are rejected with-EINVAL. - Validates the template SD if provided — must parse, must be within size limits.
- Checks the caller's privileges.
SeTcbPrivilegeis required. - Atomically updates the superblock's policy class, the template SD, and increments the generation counter.
- Writes the new generation to
args.generation. - Returns 0.
The change is atomic at the superblock level — every future access against any inode on this superblock sees the new policy. There is no transitional state where some inodes are under the old policy and others under the new.
Privilege requirement #
The reasoning: changing a mount's policy is an administrative decision that affects the entire filesystem's access semantics. It belongs in the TCB tier, not in the regular-administrator tier. An ordinary administrator who wants to change a mount's policy goes through a tool that itself has the privilege.
The template SD #
The optional template SD is the default SD used during synthesis when the parent's inheritance does not yield one. The kernel stores the template on the superblock; subsequent synthesis-on-missing calls use it.
The template is a complete self-relative SD — owner, primary group, DACL, optional SACL. The kernel validates the template at policy-set time:
- Must be in self-relative format.
- Must include an owner (an SD without one is malformed and rejected).
- Must fit within 65,535 bytes total; the template specifically is capped at 64 KiB.
- ACLs and ACEs must parse.
A bad template at policy-set time is rejected with -EINVAL; the current policy and template are unchanged.
The template can be omitted entirely (zero pointers) for mounts that don't need one. The synthesis chain then falls through to the hardcoded fallback for any file whose parent doesn't yield an SD.
kacs_get_mount_policy #
The read syscall:
result = kacs_get_mount_policy(fd, args)
Same fd and args semantics, returning the current policy class, current template (if buffer provided), and current generation counter.
The kernel:
- Validates the fd, identifies the superblock.
- Checks the caller's privileges.
SeTcbPrivilegeis required to read. - Writes the policy class, generation, and template to
args(template only if a buffer was provided). - Returns 0.
Like the write syscall, this is SeTcbPrivilege-gated. The mount policy is system-level state, exposed only to TCB-tier callers.
This is the kernel's read-back-the-current-policy interface. A management tool reads the policy, perhaps applies a transformation, and writes the new value back. The read-modify-write pattern is what most policy management code uses.
The generation counter #
A subtle but important feature: the kernel maintains a monotonic generation counter per superblock. Each successful kacs_set_mount_policy (or template change) increments it.
The purpose is lazy invalidation.
Without the counter, a policy change would either need to immediately propagate to every cached SD on the filesystem (expensive — could be millions of inodes) or accept that cached SDs from before the change remain valid (incorrect — the new policy might say different things).
With the counter, the kernel can do something smarter:
- Each in-kernel cached SD or synthesised entry records the generation it was derived from.
- At access time, the kernel compares the cached entry's generation against the superblock's current generation.
- If they match, the cached entry is current; use it.
- If they differ, the cached entry is stale; discard it and re-derive.
The kernel does not walk the filesystem at the policy change. The walk happens implicitly, one inode at a time, as accesses arrive. An access that arrives 30 seconds after the policy change is the first to invalidate that specific inode's cached state; the access pays a small cost to re-derive, but every subsequent access uses the new cached state.
This is why mount policy changes are cheap to apply. The expensive work (walking inodes) never happens; what happens instead is per-inode re-derivation on first access after the change.
What the generation counter affects #
The counter affects in-kernel cached state derived from the mount policy:
- Missing-SD synthesised entries. If a file had a missing SD and was synthesised on-the-fly, the synthesised SD is cached. A policy change invalidates this cache; the next access re-synthesises.
- Ephemeral-synthesis caches for
facs_synthesize_ephemeralmounts. Same. - Template-derived information — the kernel's representation of the mount template is regenerated on next access.
The counter does not affect:
- On-disk SDs. A file that has a stored SD continues to have that SD. The policy change doesn't rewrite stored SDs.
- Already-open file descriptors. The cached granted mask on an fd is independent of the mount-policy generation; it's the result of the access check at open time. Policy changes after open don't affect open fds. (This is the standard check-at-open model.)
- Inodes not currently in cache. They have nothing to invalidate; the next access just goes through the normal path with the current policy.
Reading the counter #
The generation counter is exposed in the output of kacs_get_mount_policy. A management tool can read the counter to know when policy was last changed, or compare against a previously-read value to detect external changes.
The counter is per-superblock; different filesystems have unrelated counter values. The counter starts at some implementation-defined value at mount time and increases monotonically with each policy change.
What happens at a policy change #
To summarise, when kacs_set_mount_policy succeeds:
- The superblock's policy class field is updated.
- The superblock's template SD is updated.
- The superblock's generation counter is incremented.
- Open file descriptors against files on this mount are unaffected (cached granted masks intact).
- Cached in-memory state derived from the old generation will be invalidated on first access after the change.
- Files with stored SDs continue to have those SDs.
- The filesystem itself is not walked, not modified, not reorganised.
The change is essentially a small superblock update plus a lazy invalidation marker. The work happens at future accesses, distributed across actual usage.
What policy changes do not do #
A few clarifications:
- They don't rewrite SDs on disk. A change from
facs_synthesize_ephemeraltofacs_synthesize_persistentdoesn't go back and write synthesised SDs to disk. Files that were synthesised under the old policy continue to have no on-disk SD; the next access re-synthesises (now writes back, per the new persistent policy). - They don't close open files. Processes with handles to files on the mount continue to have those handles. The cached granted masks are unchanged.
- They don't propagate across mounts. A policy change on one mount doesn't affect any other mount, even if they reference the same underlying device (per the per-superblock rule).
- They don't change the FACS handle model. The new policy applies to future access checks; existing handles use their cached masks per usual.
Use patterns #
A handful of common patterns for managing mount policy:
Boot-time policy. peinit applies the policy class and template for each mounted filesystem at boot. The configuration source is typically registry-based; peinit reads the configuration and calls kacs_set_mount_policy for each mount.
Migration from a non-Peios filesystem. Mount with facs_synthesize_persistent initially. Let usage gradually adopt files into having stored SDs. Once enough have been adopted, switch to facs_deny_missing (or leave it as synthesize_persistent if you want the synthesis-on-missing safety net).
Removable media insertion. When a removable filesystem is mounted (a USB drive, an optical disc), the mount setup chooses facs_synthesize_ephemeral. The volume's contents become reachable without modifying the on-disk metadata; ejection cleanly leaves the media untouched.
Policy uplift on a hardened deployment. A deployment starting with facs_synthesize_ephemeral everywhere (permissive) can migrate to facs_synthesize_persistent (filling in stored SDs) and then to facs_deny_missing (strict mode). Each transition is one kacs_set_mount_policy call per mount.
Errors #
Possible failures from kacs_set_mount_policy:
| Error | Cause |
|---|---|
-EBADF | The fd is invalid. |
-EPERM | The caller does not hold SeTcbPrivilege. |
-EINVAL | Setting an unmanaged policy via the public ABI; setting an unknown policy value; non-zero reserved flags; malformed template SD; template size exceeded. |
Possible failures from kacs_get_mount_policy:
| Error | Cause |
|---|---|
-EBADF | The fd is invalid. |
-EPERM | The caller does not hold SeTcbPrivilege. |
-ERANGE | A template buffer was provided but is smaller than the template. The required size is written to the output. |
In normal operation both calls succeed. Failures are typically privilege issues or malformed inputs.
See also #
- Policy classes — what each settable class does.
- mount — setting the policy from the command line at attach time.
- Privileges — the privilege model behind the SeTcbPrivilege gate.
mount
Peios / Using Peios / Mount policies
mount attaches a filesystem to the Peios mount tree. With no operands it instead lists what is currently mounted. It is a faithful reworking of the util-linux mount(8) surface for Peios, with two structural differences: Peios has no /etc/fstab and no /etc/mtab, so every mount is described entirely on the command line and live mount state is read from the kernel; and a mount can apply a KACS mount policy to the new filesystem at attach time (see Mount policies).
mount [-t TYPE] [-o OPTIONS] SOURCE TARGET
mount [-o remount,OPTIONS] TARGET
mount --bind|--rbind|--move SOURCE TARGET
mount --make-shared|--make-private|... TARGET
mount [-l] [-t TYPE]
Internally mount uses the fd-based mount API (fsopen / fsconfig / fsmount / move_mount, plus open_tree and mount_setattr), never the classic single-shot mount(2). This matters in one place you can see: when a mount fails, the kernel's fs_context message log is drained and printed as the reason, even without -v.
Operands and argument shapes #
Because there is no fstab to consult, operand resolution is strict:
- No operands, no verb — list mode (see below).
- Two operands —
SOURCE TARGET. - One operand — an error. In util-linux a lone operand is resolved through fstab; Peios has none, so a single operand cannot be turned into a
SOURCE TARGETpair. Supply both, or use--source/--target. - A lone target is valid only for
-o remountand for a standalone propagation change (--make-*), which act on an existing mount point.
--source SRC and --target DIR name the operands explicitly and may be combined with a single positional. --target-prefix DIR prepends DIR/ to the target after it is chosen. Options may be interspersed with operands (mount SRC -o ro TGT), matching util-linux.
Paths, -o key=value values, labels and UUIDs are handled as opaque byte strings, never assumed to be UTF-8; an embedded NUL is a usage error.
Canonicalisation #
By default source and target paths are canonicalised (made absolute, symlinks resolved). -c / --no-canonicalize disables that; X-mount.nocanonicalize[=source|target] is the -o form and can disable it for just one side. The final path handed to the kernel is protected against a TOCTOU symlink swap on its last component.
Operation modes (verbs) #
mount performs one of several operations. The structural verbs — bind, rbind, move, beneath and remount — are mutually exclusive with one another. A propagation change is not a structural verb: it may stand alone on a target, or trail another verb or a new mount in the same command (applied afterwards as one or more mount_setattr steps), and several may be combined.
| Verb | How to request it | What it does |
|---|---|---|
| New mount | mount [-t T] SRC TGT | Attach a fresh instance of the filesystem at SRC onto TGT. |
| Bind | -B / --bind, or -o bind | Make an existing subtree visible at a second location. |
| Recursive bind | -R / --rbind, or -o rbind | Bind a subtree together with every mount underneath it. |
| Move | -M / --move, or -o move | Relocate an existing mount to a new mount point. |
| Move beneath | --beneath SRC TGT | Attach SRC beneath the mount currently at TGT (the kernel enforces several constraints; violations surface as exit 32). |
| Remount | -o remount[,...] TGT | Change the options of an existing mount (see Remounting). |
| Propagation | --make-* / --make-r*, or the -o tokens below | Change how mount/unmount events propagate across a subtree. |
| List | mount / mount -l | Print the current mounts. |
Propagation flags #
Each of these sets the propagation type of the mount at the target. The --make-r* (and r-prefixed -o) forms apply recursively to the whole subtree; the recursive form is all-or-nothing (it either applies to the entire subtree or to none of it).
| Flag | -o token | Recursive flag | Recursive -o token | Meaning |
|---|---|---|---|---|
--make-shared | shared | --make-rshared | rshared | Events propagate to and from peer mounts. |
--make-slave | slave | --make-rslave | rslave | Events propagate in from the master but not back out. |
--make-private | private | --make-rprivate | rprivate | No propagation either way. |
--make-unbindable | unbindable | --make-runbindable | runbindable | Private, and cannot be bind-mounted. |
A freshly created mount, bind or move is private by default unless its destination's parent is shared, in which case it joins that peer group — the standard kernel rule.
Source specification #
| Form | Resolution |
|---|---|
Device path (/dev/sda1) | Used directly. |
-L LABEL / LABEL=, -U UUID / UUID=, PARTLABEL=, PARTUUID= | Resolved to a device via libblkid. No match is a mount failure (exit 32). |
| A directory or regular file | Used directly (a bind source or target; a file may be a bind target). |
A pseudo source (tmpfs, proc, sysfs, none, …) | Passed through; -t is required because it cannot be probed. |
| An image file | Attached through a loop device (see Loop devices). |
Filesystem type #
-t TYPE names the type explicitly. -t auto, or omitting -t entirely, asks libblkid to safely probe the source; an ambiguous or multiply-signed device is refused (exit 32) rather than guessed at. X-mount.auto-fstypes=LIST constrains the probe candidates. Type lists and no<type> negation are not accepted in the mounting path (they remain meaningful only as a listing filter).
The -o option language #
-o takes a comma-separated list of key or key=value tokens and is repeatable (all occurrences are joined). A key="..." double-quoted value protects embedded commas; the key=value split is on the first =. Every token is sorted into one of the following categories.
Per-mount attributes #
Applied to the mount itself. Each accepts its negation; ro/rw additionally accept a =vfs / =fs / =recursive scope qualifier (bare is =vfs, non-recursive; =fs targets the superblock read-only flag; =recursive applies to the whole subtree).
| Option | Effect |
|---|---|
ro / rw | Read-only / read-write. |
suid / nosuid | Honour / ignore set-user-ID bits. |
dev / nodev | Allow / disallow device nodes. |
exec / noexec | Allow / disallow execution. |
atime / noatime | Update / never update access times. |
relatime / norelatime | Relative-atime updates on or off. |
strictatime / nostrictatime | Strict-atime updates on or off. |
diratime / nodiratime | Directory access-time updates on or off. |
nosymfollow | Do not follow symlinks on this mount. There is no positive symfollow token — the attribute is cleared only via a remount mask. |
The atime tokens share a single mode field; the last one specified wins.
Superblock flags #
| Option | Effect |
|---|---|
sync / async | Synchronous / asynchronous writes. |
dirsync | Synchronous directory updates. |
lazytime / nolazytime | Lazy on-disk timestamp updates on or off. |
iversion / noiversion | Inode version counting on or off. |
silent / loud | Suppress or emit certain kernel messages. |
mand / nomand | Obsolete (removed from the kernel). Accepted and ignored with a note under -v. |
Filesystem-specific parameters #
Any token not recognised above is forwarded verbatim to the filesystem: key=value as a string parameter, a bare key as a flag. There is no built-in allow-list and no length limit; a rejection by the filesystem is reported with the offending key and the kernel's message.
For example, StrataFS takes its precedence-ordered directory stack through
strata=:
See StrataFS for the stack flags, merge
and write-routing model, and the stratafs inspection command.
Userspace-only tokens #
These never reach the filesystem. They include the meta-verbs remount, bind, rbind, move; the loop controls loop / loop=/dev/loopN, offset=, sizelimit= (numeric values accept K/M/G/T and KiB/MiB/… suffixes); the propagation tokens listed above; and defaults, which expands to rw,suid,dev,exec,async (later tokens override it).
Functional X-mount.* options #
| Option | Effect |
|---|---|
X-mount.mkdir[=mode] | Create the target directory if missing (default mode 0755; the alias of -m). |
X-mount.subdir=DIR | Attach subdirectory DIR of a freshly mounted filesystem at the target. Effective only for a new-instance mount; silently ignored (noted under -v) for bind/move/remount/propagation. |
X-mount.noloop | Suppress the implicit loop device for a regular-file source. |
X-mount.auto-fstypes=LIST | Constrain the -t auto probe to these types. |
X-mount.nocanonicalize[=source|target] | The -o form of -c; with =source or =target it disables canonicalisation for just that path. |
X-mount.idmap and X-mount.owner / group / mode are not supported and are rejected with a usage error: Peios ownership is SID/security-descriptor based, so the fix is to add a SID/ACE to the descriptor rather than remap or chown.
KACS mount policy #
This is the genuinely Peios-specific part of mount. A mount policy is a per-superblock setting that governs how FACS treats the filesystem; the full model is described in Mount policies and its detail pages. mount can set that policy at attach time:
| Option | Policy class |
|---|---|
-o policy=deny-missing | facs_deny_missing — a file with no SD is unreachable. |
-o policy=synth-ephemeral | facs_synthesize_ephemeral — a missing SD is synthesised in memory only. |
-o policy=synth-persist | facs_synthesize_persistent — a missing SD is synthesised and written back. |
--synth-sddl SDDL | Provide the mount-level SD template used during synthesis (only valid with a synth-* policy). |
policy=unmanaged is not user-settable; only the kernel sets the unmanaged class, for its own pseudo-filesystems. policy= is valid only on a new mount of a real filesystem — combining it with bind/move/remount/propagation, or with list mode, is a usage error. See Policy classes for what each class does and SD storage by filesystem for how the SD is physically stored.
The policy is applied to the detached filesystem before it is attached: if setting it fails, nothing is ever published with an unintended policy (no rollback needed). Because setting a mount policy is a SeTcbPrivilege-gated operation (see Managing mounts), a caller without that privilege gets a clean EPERM (exit 1) and no mount. --synth-sddl is validated client-side first — it must be well-formed SDDL and must include an owner.
Peios applies no coarse uid==0 check anywhere: the mount applet is not installed set-user-ID, and every privileged action is authorised per-operation by KACS.
Remounting #
-o remount changes the options of an existing mount without detaching it. Peios does not perform the util-linux "read the old flags and re-supply them" dance — the fd-based API applies deltas rather than resetting, so each remount touches only what you name. Per-mount attributes (ro/rw, nosuid, atime, …) are changed via mount_setattr; superblock flags, filesystem parameters and ro=fs/rw=fs go through the superblock reconfigure path. =recursive remounts the whole subtree, all-or-nothing.
A bind mount shares its source's superblock, so any superblock-level option on a bind remount (a Category-B flag, a filesystem parameter, or ro=fs/rw=fs) is refused as a usage error rather than silently mutating the shared superblock for every mount of that filesystem. Only per-mount VFS attributes are valid on a bind remount.
Loop devices #
A regular-file source is backed by a loop device automatically when the type is unspecified or the filesystem is recognised by libblkid; X-mount.noloop suppresses this. -o loop forces auto-allocation of a free device, loop=/dev/loopN names one, and offset= / sizelimit= select a region of the file (they imply loop and are an error against a real block device). A backing file already attached at the same offset and size is reused rather than doubly attached, to avoid corruption. Loops created by mount are auto-cleared by the kernel on unmount, so they do not leak; umount -d force-clears the rest (see umount).
Other flags #
| Flag | Effect |
|---|---|
-t, --types TYPE | Filesystem type, or auto. |
-o, --options LIST | Mount options (above); repeatable. |
-r, --ro, --read-only | Mount read-only (-o ro). |
-w, --rw, --read-write | Mount read-write and forbid the automatic read-only fallback on a write-protected device (-o rw). |
--source SRC / --target DIR | Name the operands explicitly. |
--target-prefix DIR | Prepend DIR/ to the target. |
-B, --bind / -R, --rbind / -M, --move / --beneath | The structural verbs. |
--make-*, --make-r* | Propagation changes. |
--exclusive | Force a unique superblock instance (no reuse). Meaningful only for multi-instance filesystems (e.g. tmpfs) or read-only block mounts; on an already-mounted writable block device it fails with EBUSY (exit 32). |
-m, --mkdir[=MODE] | Create the target directory if missing (default mode 0755). |
-L, --label LABEL / -U, --uuid UUID | Select the source by filesystem label / UUID. |
-c, --no-canonicalize | Do not canonicalise paths. |
-f, --fake | Dry run: parse, resolve and plan everything but skip the mount syscalls and the policy step. |
-v, --verbose | Narrate resolved values and each syscall; drain the kernel fs_context log. Repeatable but -vv is the same as -v. |
-l, --show-labels | In list mode, append each filesystem's label. |
--onlyonce | Skip the mount if it is already present (a driver-aware check against live mount state, not a naive string match). |
-N, --namespace NS | Operate inside mount namespace NS (a PID, an ns file path, or a named namespace). Source resolution happens in the caller's namespace; the mount lands in the target namespace. |
-i, --internal-only | Do not invoke a mount.<type> helper. |
-n, --no-mtab | Accepted and ignored (Peios has no mtab). |
--synth-sddl SDDL | KACS synth-policy template SD (above). |
-h, --help / -V, --version | Standard. |
No external mount helpers ship in this version, so a network or FUSE type fails with "no helper for type" (exit 1) — a clean deferral, not a crash.
List mode #
With no operands, mount prints one line per mount, read from /proc/self/mountinfo:
SOURCE on TARGET type FSTYPE (OPTIONS)
mount -t TYPE with no operands filters the list by filesystem type instead of mounting; here type lists (-t ext4,xfs) and no<type> negation (-t nosysfs) are honoured. -l appends [LABEL] to each line when a label is known.
Details of the rendering: the option field is the positional VFS-then-superblock merge with a coalesced ro/rw, not sorted; mountinfo octal escapes are decoded; control characters in the mount point become ?; and the source is shown as the kernel recorded it, with /dev/loopN resolved to its backing file and /dev/dm-N to /dev/mapper/<name>. The listing shows only mounts the caller's token may observe; a partial or empty view is correct, not an error, and the paths of filtered-out parents are never reconstructed.
For a device-oriented view of what is available to mount, use lsblk.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Incorrect invocation or a permission denial — bad flags, mutually-exclusive verbs, a lone operand, an invalid policy= or --synth-sddl, an authorisation failure (EPERM/EACCES), an embedded NUL, or "no helper for type". |
2 | System error (out of memory, no free loop device, cannot fork). |
4 | Internal error (an invariant failure). |
8 | Interrupted (SIGINT), after signal-safe cleanup. |
32 | Mount failure — a syscall in the flow failed, a label/UUID had no match, a probe was undetermined, or a --beneath/move/--exclusive kernel refusal. |
126 | An external mount.<type> helper was found but failed to execute (moot until a helper ships). |
Code 16 (mtab) is never produced. Code 64 (some succeeded, some failed) only arises when several source arguments are given and at least one succeeds while another fails.
umount
Peios / Using Peios / Mount policies
umount detaches a filesystem from the Peios mount tree. It is the counterpart of mount and, like it, reads live mount state from the kernel (/proc/self/mountinfo) rather than any /etc/mtab — Peios has none. Each operand is resolved against that live state and unmounted with umount2(2).
umount [-lfRA] [-dr] TARGET|SOURCE...
Operands and how they are resolved #
Each operand is either a mount point or a source:
- If the (canonicalised) operand is itself a mount point in the current mount table, it is unmounted directly. This is always unambiguous.
- Otherwise the operand is treated as a source and matched against the sources in the mount table. If it is mounted in exactly one place, that place is unmounted. If it is mounted in several places,
umountrefuses with an error naming the candidates — resolve it by naming a specific mount point, or use-Ato unmount all of them.
If an operand matches nothing, it is "not mounted": normally an error (exit 32), but -g makes that a success and -q suppresses the message.
Several operands may be given in one invocation, and options may be interspersed with them (umount /a -f /b). Paths are handled as opaque bytes; an embedded NUL is a usage error. By default each operand is canonicalised; -c disables that and additionally selects UMOUNT_NOFOLLOW so the kernel does not follow a symlink in the final component.
Recursive and all-targets unmounts #
Two flags expand a single operand into multiple unmounts. Within one operand the expansion stops on the first failure.
-R/--recursiveunmounts the target and everything mounted underneath it, including any over-mount stack at a single point, ordered deepest-first.-A/--all-targetsunmounts every mount point of the given source in the current mount namespace. This is the live-mountinfo "unmount everywhere" complement to source resolution; it is not an fstab feature.-A -Rtogether compose: for each mount point of the source, recurse underneath it.
-R and -r are mutually exclusive (combining them is a usage error, exit 1).
Flags #
| Flag | Effect |
|---|---|
-l, --lazy | Detach the filesystem now and clean up references later (MNT_DETACH). |
-f, --force | Force the unmount (MNT_FORCE), e.g. for an unreachable server. |
-R, --recursive | Unmount the target and everything under it (see above). |
-A, --all-targets | Unmount every mount point of the given source (see above). |
-d, --detach-loop | After unmounting, free the backing loop device. Best-effort and verified: it clears the device only if it still backs the mount just removed, so a recycled loop number is never clobbered. Usually redundant, since mount-created loops auto-clear on unmount. |
-r, --read-only | If the unmount fails, remount the filesystem read-only instead (via mount_setattr). Mutually exclusive with -R. |
-g, --graceful | Exit 0 when the target is absent or not mounted (rather than failing). Applied unconditionally. |
-q, --quiet | Suppress "not mounted" messages. |
-c, --no-canonicalize | Do not canonicalise paths; selects UMOUNT_NOFOLLOW. Applied unconditionally. |
-v, --verbose | Say what is being unmounted. Repeatable. |
-N, --namespace NS | Enter mount namespace NS (a PID, an ns file path, or a named namespace) before reading its mount table and unmounting. All work happens inside that namespace. |
-n, --no-mtab | Accepted and ignored (no mtab on Peios). |
-i, --internal-only | Do not invoke a umount.<type> helper (none ship). |
--fake | Dry run: resolve operands and report, but skip the unmount syscalls. |
-h, --help / -V, --version | Standard. |
The umount2 flag mapping is direct: -l → MNT_DETACH, -f → MNT_FORCE, -c (or a symlinked final component) → UMOUNT_NOFOLLOW. MNT_EXPIRE is deliberately not exposed, matching util-linux.
On privilege #
As with mount, there is no coarse uid==0 check: the applet is not set-user-ID and every unmount is authorised per-operation by KACS. Where util-linux silently suppresses an option for non-root users (-c, and -g's effectiveness), Peios applies it unconditionally.
Exit status #
| Code | Meaning |
|---|---|
0 | Success (or a -g no-op on an absent target). |
1 | Incorrect invocation or a permission denial — including -R with -r, a missing operand, an ambiguous source, an embedded NUL, or an authorisation failure. |
2 | System error (e.g. cannot read the mount table). |
8 | Interrupted (SIGINT). |
32 | Unmount failed, or the target is not mounted (unless -g). |
64 | Several source/target arguments were given and at least one succeeded while another failed. |
126 | An external umount.<type> helper was found but failed to execute (moot until a helper ships). |
A single -R / -A / -A -R invocation stops on the first failure and yields 32; the aggregate 64 only appears across multiple top-level arguments. To see what is currently mounted before unmounting, use mount with no arguments or lsblk.
lsblk
Peios / Using Peios / Mount policies
lsblk lists the block devices on the system and their relationships — disks, their partitions, and the device-mapper and loop devices layered on top. It is the device-oriented companion to mount: where mount with no arguments shows what is mounted, lsblk shows what is available to mount and what filesystem sits on each device.
lsblk [options] [DEVICE...]
By default it prints a tree, one row per device with children indented beneath their parent. Given one or more DEVICE operands (a name like sda, a full path like /dev/sda, or anything resolving to a device node), it re-roots the output at those devices.
Where the data comes from #
Each column is sourced from one of four places, and any that cannot be read degrades to an empty cell rather than aborting the run:
- sysfs (
/sys/block) — the device tree, sizes, and the topology/hardware columns. Peios leaves/sys/blockunpatched, so this is the standard no-udev path. - libblkid — filesystem identity:
FSTYPE,FSVER,UUID,LABEL, and the partition-table columns. libblkid is opened at runtime. - The device node's security descriptor —
OWNERandMODE, read the same wayls -lreads them. - The
/dev/disk/by-*symlink farm —ID-LINK. Populated by the device manager once it has run; there is deliberately no/run/udev/dataparser, so the column reads the links themselves and is empty in the initramfs, where no device manager runs.
Output modes #
The mode selects how the rows are formatted; it does not change which columns are shown.
| Flag | Mode |
|---|---|
| (default) | Indented tree. |
-l, --list | Flat list — the same columns, no tree glyphs. |
-J, --json | JSON. Flag columns render as bare booleans and MOUNTPOINTS as an array; children nest under a children key. |
-P, --pairs | KEY="value" pairs, one device per line. |
-r, --raw | Raw, space-separated. Values that could contain a space or control character are hex-escaped (\xNN) so fields stay parseable. |
-T, --tree[=COLUMN] | Force tree output even alongside -l, optionally attaching the tree glyphs to COLUMN instead of NAME. |
Columns #
With no column flag, lsblk prints the default set: NAME, MAJ:MIN, RM, SIZE, RO, TYPE, MOUNTPOINTS. Three flags swap in a preset, and -o names an explicit list (which wins over all of them):
| Flag | Column set |
|---|---|
-o, --output LIST | Exactly the comma-separated columns named (case-insensitive; an unknown name is a usage error). |
-O, --output-all | Every available column. |
-f, --fs | Filesystem view: NAME, FSTYPE, FSVER, LABEL, UUID, MOUNTPOINTS. |
-m, --perms | Permissions view: NAME, SIZE, OWNER, MODE. |
The available columns are NAME, KNAME, PATH, MAJ:MIN, FSTYPE, FSVER, LABEL, UUID, PTUUID, PTTYPE, PARTTYPE, PARTLABEL, PARTUUID, MOUNTPOINT, MOUNTPOINTS, SIZE, RO, RM, HOTPLUG, TYPE, OWNER, MODE, MODEL, VENDOR, REV, SERIAL, TRAN, HCTL, ALIGNMENT, MIN-IO, OPT-IO, PHY-SEC, LOG-SEC, STATE, ROTA, SCHED, PKNAME, and ID-LINK.
The OWNER and MODE columns #
OWNER and MODE describe the device node, not the filesystem on it, and they mirror ls -l exactly — there is no GROUP column and no POSIX permission bits, because access to a device node is governed by its security descriptor, not by a mode. OWNER is the owner SID (S-1-…); MODE is a three-character [type][x][+]: the device-type character (b for a block device, c for a character device), an x slot (never set for a block device), and a + when the DACL is inheritance-protected. When the SD cannot be read — for instance on a non-Peios host with no KACS syscalls — OWNER shows ? and MODE degrades honestly.
Filtering, sorting and shaping #
| Flag | Effect |
|---|---|
-a, --all | Include empty (zero-size) devices, which are hidden by default. |
-d, --nodeps | Do not print a device's holders or slaves (drop the children). |
-I, --include LIST | Show only devices with these major numbers (comma-separated). |
-e, --exclude LIST | Exclude devices by major number. The default is 1 (RAM disks); an explicit -e replaces that default, and -a clears exclusions entirely. |
-s, --inverse | Print dependencies in inverse order (holders above the devices they depend on). |
-M, --merge | Collapse a subtree shared by several parents (a multipath device) to a single occurrence. |
-E, --dedup COLUMN | Drop rows whose COLUMN value duplicates an earlier one. |
-x, --sort COLUMN | Sort siblings by COLUMN (numeric columns sort by value, not text). |
Formatting #
| Flag | Effect |
|---|---|
-b, --bytes | Print SIZE as an exact byte count instead of a human-readable value. |
-p, --paths | Print full /dev paths in the NAME column. |
-n, --noheadings | Omit the header row. |
-i, --ascii | Draw the tree with ASCII characters instead of box-drawing glyphs. |
-y, --shell | Render column keys shell-safe (MAJ:MIN becomes MAJ_MIN). |
-w, --width NUM | Truncate each table row to NUM columns wide. |
--sysroot DIR | Read sysfs, the mount table and /dev from DIR instead of / (chiefly for testing against a fixture). |
-h, --help / -V, --version | Standard. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Incorrect invocation (an unknown column, a malformed major-number or width value) or a failed run (for example, /sys/block is unreadable). |
lsblk uses this single non-zero code, matching util-linux. A per-device read failure is not fatal: it degrades the affected columns to empty or ? and the run continues. A broken output pipe (piping into head, for instance) is treated as success.
Disks and filesystems
Peios / Using Peios / Disks and filesystems
Creating a filesystem is the one operation that happens before Peios has any say over it. A block device holds bytes; a filesystem is a structure imposed on those bytes; only when that structure is mounted does the kernel begin making access decisions about what it contains. Everything on this page happens on the early side of that line.
Peios ships two upstream families: e2fsprogs for ext2/3/4, and dosfstools for FAT. Both are the tools you know from any Linux system. e2fsprogs carries a small Peios patchset, and the one place it diverges is security descriptors, which Formatting with security descriptors covers. dosfstools carries no functional patch at all, for a reason worth stating up front: a FAT filesystem has no extended attributes, so it has nowhere to keep a security descriptor and there is nothing for that work to extend.
Why these tools are packaged, not rewritten #
Most user-facing commands on Peios come from peiosutils, which reworks each tool for the Peios security model — mount grew a policy= option, lsblk reports SD-derived owner and mode, ls reads SDs rather than POSIX modes. mkfs and fsck are deliberately not in that set.
The reason is where the security seam falls. mount is the seam: it is the operation that takes a filesystem and places it under KACS, choosing the mount policy that governs every access from that point on. mkfs and fsck sit below the seam. They write and repair a Linux-compatible on-disk format that Peios deliberately mirrors byte for byte — an ext4 filesystem made on Peios is an ext4 filesystem, readable anywhere. Rewriting them would mean reimplementing a format Peios does not want to change, and taking on the correctness burden of a filesystem checker for no security benefit.
So Peios packages e2fsprogs from upstream and carries a patch series for the one thing upstream has no concept of.
What ships #
| Package | Contents |
|---|---|
e2fsprogs | The tools below. |
e2fsprogs-devel | Headers, linker symlinks and pkg-config files for building against the libraries. |
e2fsprogs-static | Static archives. |
libext2fs, libe2p, libcom-err, libss, libuuid | The runtime shared libraries, packaged separately so a consumer can depend on one without pulling the tools. |
The tools themselves:
| Tool | Purpose |
|---|---|
mke2fs, mkfs.ext2, mkfs.ext3, mkfs.ext4 | Create a filesystem. This is where Peios security descriptors enter. |
e2fsck, fsck.ext2, fsck.ext3, fsck.ext4, fsck | Check and repair a filesystem. |
tune2fs | Change parameters on an existing filesystem, including enabling features after the fact. |
resize2fs | Grow or shrink a filesystem. |
dumpe2fs | Print superblock and block-group information. |
debugfs | Interactive low-level access to a filesystem image, including reading and writing extended attributes directly. |
blkid, findfs | Identify filesystems by label, UUID or type. |
e2label, e2image, e2undo, e2freefrag, filefrag, badblocks, logsave | Labelling, imaging, undo, fragmentation and block-scanning utilities. |
chattr, lsattr | Read and set ext2/3/4 inode attributes. |
uuidgen | Generate a UUID. |
libuuid comes from e2fsprogs; libblkid comes from the util-linux libraries. The split is arbitrary but fixed — each library has exactly one owning package, so the two sources never both ship the same file.
From dosfstools:
| Tool | Purpose |
|---|---|
mkfs.fat, mkfs.vfat, mkfs.msdos | Create a FAT12/16/32 filesystem. |
fsck.fat, fsck.vfat, fsck.msdos | Check and repair a FAT filesystem. |
fatlabel | Read or set a FAT volume label. |
The pre-4.0 aliases (mkdosfs, dosfsck, dosfslabel) are deliberately not shipped — they exist for compatibility with a command-line history Peios does not have.
Where these tools live #
Everything above is in /usr/bin, reached as /bin through the runtime view. None of it is in /sbin: that is for daemons, which appear in service definitions, and a filesystem tool is a privileged binary a person runs.
The fsck.<type> backends are the exception, and they sit in /usr/libexec/fsck/:
/libexec/fsck/fsck.ext2 fsck.ext3 fsck.ext4
fsck.fat fsck.vfat fsck.msdos
These are not commands you type. fsck picks a checker by exec'ing fsck.<type> for the filesystem type it detects or is given, so they are a private interface between the front-end and its backends — which is exactly what libexec distinguishes. fsck searches /libexec/fsck first, then the directories on your PATH, so a third-party checker installed elsewhere on PATH is still found.
The mkfs.<type> names stay in /usr/bin for the opposite reason: Peios ships no mkfs front-end, so nothing ever dispatches on them and they are only ever typed.
FAT and the EFI system partition #
The reason Peios ships FAT tooling at all is the EFI system partition. UEFI requires the ESP to be FAT, so a system that cannot format FAT cannot create its own boot partition.
An ESP is also the clearest case of a filesystem that holds no access control of its own. There is no extended-attribute channel, so no security descriptor is ever written to it, and none can be. Its entire access policy comes from the mount — necessarily one of the synthesising classes, since facs_deny_missing on a filesystem where every file is permanently missing an SD would make the whole partition unreachable. See SD storage by filesystem.
The practical consequence: the protection on your boot partition is the mount policy and the physical security of the disk, not a descriptor on the files. Treat the contents accordingly.
Where this sits in a running system #
debugfs is the tool to reach for when you want to inspect an image offline — without mounting it, and therefore without KACS being involved at all. It reads and writes extended attributes directly, which makes it the way to confirm that a security descriptor really landed on disk:
debugfs -R "ea_list <2>" /dev/vda2
Inode 2 is always the root directory of an ext filesystem, so this asks what extended attributes the filesystem's root carries.
e2fsck has a specific place in boot. Checking and repairing the root filesystem is the initramfs's job, not peinit's — by the time peinit runs, the root is already mounted, and a filesystem checker cannot repair a filesystem that is in use. See The initramfs stage.
What is not here yet #
Partition tables other than GPT. part writes GPT and only GPT. Peios boots through UEFI with no bootloader, so MBR has nothing to do on a Peios system — but a disk that already carries one is recognised and named rather than silently overwritten. Resizing, moving and MBR↔GPT conversion do not exist either. See Partitioning.
Filesystems other than ext2/3/4 and FAT. The mount side understands XFS, Btrfs and NTFS as well — see SD storage by filesystem — but Peios ships creation tools only for the ext and FAT families.
Where to start #
For the security-descriptor model — why a filesystem you intend to keep should carry a real SD from the moment it is created, and how the tree gets one — read Formatting with security descriptors.
For the exact command surface, including the extended options and the Peios defaults baked into the binary, read mke2fs.
For how these tools are put to work copying a live system onto a disk that then boots itself, read Installing to disk.
For the names a disk keeps across reboots — /dev/disk/by-uuid and its siblings, and the device manager that maintains them — read Stable device names.
For what happens to a filesystem once it is attached to the mount tree, read Mount policies.
Stable device names
Peios / Using Peios / Disks and filesystems
The kernel names a disk by the order it found it in: /dev/vda, /dev/sda, /dev/nvme0n1. That order is whatever the hardware produced this time — a second controller that probed faster, a USB stick that was plugged in, a disk that moved to another port — so the same disk is not guaranteed the same name on the next boot. A name of that kind is fine for a command you type now and wrong for anything written down: a boot cmdline, a mount reference, a script.
Peios keeps kernel names for the moment and gives every disk a set of stable names for everything else. They live under /dev/disk/, as symlinks to whatever kernel name the device happens to have:
| Directory | Keyed by | Example |
|---|---|---|
by-uuid | the filesystem's UUID, written when it was formatted | 4ED7-A6AC -> ../../vda2 |
by-label | the filesystem's label | PEIOS -> ../../vda |
by-partuuid | the GPT partition entry's UUID | 2036ae68-…-f118299d5446 -> ../../vda2 |
by-partlabel | the GPT partition entry's name | |
by-id | the hardware's own identity: model and serial, or WWN | nvme-QEMU_NVMe_Ctrl_peiosnvme1 -> ../../nvme0n1 |
by-path | the bus position the device sits at | |
by-diskseq | the kernel's monotonic disk sequence number, for this boot only |
Which one to use depends on what you mean. A filesystem is by-uuid — it follows the data if the disk is cloned, and it is what root=UUID= on the kernel command line names. A partition independent of what is on it is by-partuuid. A physical disk whatever is written to it is by-id. by-label is the human-friendly one and the only one that is not unique by construction.
Partitions appear under the same keys with the parent's identity plus -partN, so by-id/nvme-…-part2 is the second partition of that NVMe disk.
Who creates them #
The names are made by the device manager, eudev, which peinit starts first in phase 2 of boot. As each block device appears — at boot, when it replays the kernel's enumeration, and afterwards whenever a device is plugged in — the device manager probes it (its partition table, and the filesystem signature on each partition) and creates the matching links. Unplug the device and the links go away.
That is the same daemon that loads a driver for each device the kernel reports, so a disk behind a modular controller gets its driver, its kernel name and its stable names from one pass. See Boot and boot modes for where the service sits in the boot order; a service that opens a device by stable name should Requires it, because peinit does not release eudev's dependents until the boot-time replay has finished.
The same idea applies to network interfaces. The kernel names them eth0, eth1 in probe order; the device manager renames each one by where it sits — enp0s3 for the card in PCI slot 3 on bus 0, eno1 for an onboard port the firmware numbers — so a configuration written against the name survives a second card or a driver that probes faster next time. net.ifnames=0 on the kernel command line turns the renaming off.
The device manager does not decide who may open a device. That is the security descriptor on the node, seeded on /dev before any of this runs — see SD storage by filesystem.
Where they are not available #
The initramfs has none of them. No device manager runs there — only a single pass that loads drivers, described in The initramfs stage. The initramfs still honours root=UUID=, but it resolves the UUID by probing each block device directly rather than by looking in /dev/disk/by-uuid, and lsblk does the same. That is why the installer's cmdline works on a machine the initramfs has never seen: it never depended on the links.
by-diskseq does not survive a reboot. The sequence number is assigned in the order devices appear during this boot, which is precisely the property the other directories exist to avoid depending on. It is there for tools that need to tell a re-plugged device from the one it replaced.
Where to go next #
For writing a partition table that gives every partition a by-partuuid name, read Partitioning.
For how the installer records the root filesystem's UUID into the kernel command line, read Installing to disk.
Partitioning
Peios / Using Peios / Disks and filesystems
A disk arrives from the factory as one undifferentiated run of sectors. Before a filesystem can live on it, something has to write down where each one begins and ends. That is a partition table, and on Peios the tool that writes one is part.
part manages GPT and nothing else. That is not an omission waiting to be filled: Peios boots through UEFI with no bootloader and no boot manager, so the MBR layout it would otherwise support has nothing to do on a Peios system.
The shape of the tool #
If you have used Windows, part occupies the slot diskpart occupies — but it is not the same shape, and the difference is deliberate.
diskpart | part | |
|---|---|---|
| how you use it | an interactive shell: select disk 0, then act on it | one command, one device, one job |
| what it covers | partitions, formatting, drive letters, dynamic disks | the partition table |
Everything else diskpart bundles already has a home here: mke2fs and mkfs.vfat make filesystems, mount mounts them, and Peios has no drive letters. And a select-then-act model is a hazard in a script, which is what usually calls part — a command that acts on "whatever was selected earlier" is one stray line away from acting on the wrong disk.
Listing disks #
Run part list with no arguments to see every disk on the machine:
# part list
DEVICE SIZE CONTENTS
/dev/vda 8.0G gpt, 2 partitions
vda1 512M esp EFI system partition
vda2 7.5G linux Peios root
/dev/vdb 8.0G no partition table
This is the view to start from, because it answers the question that precedes every other one: which disk did you mean? The partition names are the ones the kernel actually created — vda1 on a virtio or SATA disk, nvme0n1p1 on an NVMe one — so they are what you can pass straight to mkfs or to the installer.
The structure here comes from the kernel, not from reading a partition table, so a disk carrying a format part cannot manage still shows its partitions. A disk it cannot read at all is still listed, with ? for its contents — the disk you cannot read is exactly the one worth knowing about.
Name a disk to see it in full:
# part list /dev/vda
/dev/vda: 16777216 sectors of 512 bytes (8.0G)
Label: gpt
Disk GUID: DE942295-427C-411D-8023-B689D8BEE907
Usable: 34 .. 16777182
# START END SIZE TYPE NAME
1 2048 1050623 512M esp EFI system partition
2 1050624 16777182 7.5G linux Peios root
Free (aligned): 0B in 0 extent(s), largest 0B
Free (aligned) counts space a new partition could actually occupy, which is not the same as unallocated sectors — the run below the first alignment boundary can never hold one. Reporting raw free space would promise room that add would then refuse to use.
part verify checks the same table's structure: both checksums, the two headers agreeing with each other, no overlaps, everything aligned and inside the usable range.
Creating a table #
# part create /dev/vda --yes
/dev/vda: wrote a new GPT (DE942295-427C-411D-8023-B689D8BEE907)
# part add /dev/vda --size 512M --type esp --name "EFI system partition" --yes
/dev/vda: partition 1 at 2048..1050623 (512M)
# part add /dev/vda --size max --type linux --name "Peios root" --yes
/dev/vda: partition 2 at 1050624..16777182 (7.5G)
add puts each partition in the first free run that fits, aligned to 1 MiB. --size max takes the largest free run rather than merely the last one, so it still does the obvious thing on a disk with a gap in the middle.
part del /dev/vda 2 --yes removes a partition by number. It frees the space and the slot; it does not touch the data that was in it.
Sizes are sectors unless you say otherwise #
--size takes K, M, G, T — powers of 1024 — or max. A bare number is a sector count, not bytes. --size 2048 is 1 MiB on a 512-byte-sector disk, and you can write 2048s to say so explicitly. Reading it as bytes would silently produce a partition a thousand times smaller than intended.
Types #
--type takes a short alias or a raw GUID:
| Alias | Meaning |
|---|---|
esp | EFI system partition |
linux | Linux filesystem data |
swap | Linux swap |
msdata | Microsoft basic data |
The list is short on purpose. Every type GUID in circulation would be a catalogue to keep current, and since a raw GUID is always accepted, nothing is unreachable for want of an alias.
Names #
Up to 36 UTF-16 code units — fewer if you use characters outside the Basic Multilingual Plane, which cost two units each. A longer name is refused rather than shortened. A partition name is how you identify the thing you are about to format, and a tool that quietly truncates it makes the label on your screen disagree with the label on the disk.
What it will not do #
part is the one tool on the system whose mistakes cannot be undone, so it is deliberately hard to point at the wrong thing.
It requires --yes. There is no interactive "are you sure": the usual caller is a script, and a prompt nobody can answer is worse than no prompt at all.
It refuses a partition. Naming /dev/vda1 where you meant /dev/vda would write a GPT inside a partition — a table that looks valid to anything reading that partition directly, and is invisible to everything else.
# part create /dev/vda1 --yes
part: /dev/vda1 is a partition, not a whole disk; did you mean /dev/vda?
It refuses a disk with anything mounted on it. Not just the disk itself — any partition of it.
It refuses a table it did not create. This is the important one:
# part create /dev/vdb --yes
part: this disk carries an MBR (dos) partition table, which part cannot manage;
pass --force to replace it — every partition on it will be lost
part list says what it found, not merely that it found no GPT — because "no GPT" is ambiguous between a blank disk and a disk holding somebody's data, and those deserve opposite treatment:
| What is there | What part says |
|---|---|
| an MBR | "an MBR (dos) partition table, which part cannot manage" |
| an Apple, BSD, Sun or SGI label | names it |
| a GPT whose header is corrupt | "the table may be damaged" |
| a filesystem written straight to the disk | "a <type> filesystem … with no partition table" |
| genuinely nothing | "no partition table" |
--force is the way through, and it is a second confirmation, separate from --yes. --yes means "I mean this destructive operation"; --force means "and I know it destroys a table that was already there". Requiring both is proportionate for an operation with no undo.
Alignment, and why 1 MiB #
Every partition starts on a 1 MiB boundary — 2048 sectors on a 512-byte-sector disk, 256 on a 4096-byte one. The number is not arbitrary: 1 MiB divides every erase block and RAID stripe width in practical use, so an aligned partition never straddles one. A misaligned filesystem pays a read-modify-write cycle on every boundary-crossing write, for the life of the filesystem.
part reads the logical sector size from the kernel rather than assuming it, so a 4Kn disk gets a correct table rather than one whose every structure is in the wrong place.
Exit status #
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | usage error, or the operation failed |
| 2 | could not read or write the device |
| 3 | refused by a safety check |
3 is separated from 1 on purpose. A refusal is part working correctly, not malfunctioning, and a script should treat "this disk is not what you said it was" differently from "partitioning broke". peios-install relies on exactly this distinction.
Where to go next #
To put a filesystem on what you just created, read Formatting with security descriptors and mke2fs.
To have the installer do all of this for you, read Installing to disk — peios-install --whole-disk runs exactly the three commands above before it formats anything.
Formatting with security descriptors
Peios / Using Peios / Disks and filesystems
A freshly created filesystem contains one directory — its root — and that directory has no security descriptor. Nothing in the ext4 on-disk format has any concept of one. This is a problem the moment the filesystem is mounted under a FACS-managed policy, because facs_deny_missing means exactly what it says: a file with no SD is unreachable, and that includes the root directory you are trying to enter.
There are two ways out. One is to let the kernel invent an SD at mount time. The other is to put a real one on the filesystem when it is created. Peios can do both, and which is appropriate depends on whether the filesystem is something you are passing through or something you intend to keep.
Synthesised versus stamped #
Mount-time synthesis is the right answer for media you do not own. A USB stick formatted on another system, a read-only image, an NTFS volume from Windows — these have no Peios SDs and should not acquire any. facs_synthesize_ephemeral gives every inode an SD in memory, derived from the mount's template, and never writes it back.
It is the wrong answer for a filesystem that is going to be a Peios system. A synthesised SD is a property of how the filesystem was mounted, not of the filesystem. Mount it with a different template and the whole tree's access policy changes; mount it somewhere that does not set a template and you get whatever the default is. The access policy of a system you own should live on that system, not in the command that attached it.
Stamping puts it there. mke2fs accepts a security descriptor at format time and writes it to the filesystem's root directory, so the filesystem carries its own policy from the moment it exists and mounts cleanly under facs_deny_missing with no template required.
The root SD, and what it reaches #
You give the descriptor as SDDL, and mke2fs writes it to the security.peios.sd extended attribute on the root directory and on lost+found:
mke2fs -t ext4 -E root_sddl="O:SYG:SYD:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)" /dev/vda2
That descriptor makes SYSTEM the owner and grants both SYSTEM and BUILTIN\Administrators full control. The OICI flags on each ACE — object-inherit and container-inherit — are what make it reach further than the root directory. Every file and directory subsequently created anywhere on the filesystem derives its own SD from that one through ordinary inheritance.
That is worth stating plainly, because it cuts both ways. A single inheritable ACE on the root is, in practice, the access policy of the entire filesystem. It is a complete answer for a system tree where everything should be administrator-owned. It is not a way to express "readable system tree, private home directories" — one inherited ACL cannot say two different things, and the ACEs that would make /home/alice private have to come from somewhere else.
When you read an SD, treat the ID flag as a diagnostic: ID is INHERITED_ACE, so that descriptor was derived rather than stored. When you see it, the file's policy is not on the file — go and look at the ancestor it came from.
Populating a tree with per-node descriptors #
Inheritance from the root covers files created on the filesystem. It does not cover a tree copied onto it, where the nodes already have descriptors of their own that need preserving.
mke2fs -d populates a new filesystem from a directory on the build host, and on Peios it is security-descriptor aware. For each node it copies, it computes the SD the node should have in its new home, by combining two inputs:
- The explicit descriptor the source node carries — what the creator of that node wanted for it.
- The parent's descriptor in the new filesystem, which supplies the inheritable ACEs.
The two are merged by the same rules that govern inheritance on a live system: explicit ACEs first, inherited ACEs appended after them. A node whose source carries no explicit descriptor is left alone, and inherits normally on first access instead. A node whose parent has no descriptor keeps its explicit one verbatim.
The walk is top-down, and directories are stamped before their contents are visited, so every child reinherits against a parent whose descriptor has already been written.
Why the source uses a different xattr #
The explicit descriptor on the source tree is read from user.peios.sd, not security.peios.sd. This looks like an inconsistency and is not.
security.peios.sd is the canonical, on-disk home for a descriptor — and precisely because it is canonical, it is protected. On a live Peios filesystem the kernel seals it: direct extended-attribute operations on it are refused unconditionally, and access goes through kacs_get_sd and kacs_set_sd instead. On a Linux build host, writing anything in the security.* namespace needs CAP_SYS_ADMIN.
Neither is available to a tool staging a tree. user.peios.sd is subject to neither restriction, which makes it the portable carrier: any user can attach it, on any host, and it travels with the tree through ordinary archive and copy operations. mke2fs -d reads it, computes the result, and writes the answer to the canonical security.peios.sd on the new filesystem. It also skips both names when copying the node's other extended attributes across, so neither the carrier nor a stale canonical value is propagated verbatim.
This all works offline #
None of the above needs a running Peios kernel. The reinheritance computation is pure userspace, and mke2fs writes the extended attributes through the ext2 library, addressing the image directly rather than going through the host's filesystem layer. That bypasses the host's own security module and the Peios seal alike, for the simple reason that neither is in the path.
The practical consequence is that a Peios filesystem, with its full security policy in place, can be built on a machine that is not running Peios.
Where to go next #
For the exact syntax of the extended options and the defaults mke2fs applies on Peios, read mke2fs.
For how a descriptor is physically stored once written, including when an oversized one needs its own inode, read SD storage by filesystem.
For what the mount policy does with the descriptor you stamped — and what happens on a filesystem that has none — read Policy classes.
mke2fs
Peios / Using Peios / Disks and filesystems
mke2fs creates an ext2, ext3 or ext4 filesystem. Peios packages it from upstream e2fsprogs, so the generic surface — -t, -b, -L, -O, -i, -m, the full extended-option list — is exactly the upstream one and its canonical documentation is the mke2fs(8) man page shipped with the package.
This page documents only what Peios adds, which is security descriptors and a changed set of defaults.
mke2fs [-t ext4] [-E root_sddl=SDDL | root_sd_file=PATH] [-d DIRECTORY] DEVICE
Extended options #
Peios adds two options to -E. Both set the security descriptor written to the new filesystem's root directory and to lost+found, in the security.peios.sd extended attribute.
| Option | Meaning |
|---|---|
root_sddl=SDDL | The descriptor as an inline SDDL string. |
root_sd_file=PATH | The descriptor as SDDL read from PATH. |
root_sd_file exists because -E takes a comma-separated list and SDDL contains commas. Any descriptor with more than one ACE, or with a conditional expression, is easier to pass in a file than to quote on a command line.
The file holds SDDL text, not binary — it is the same string root_sddl would take, in a file. Trailing newlines are stripped, so an ordinary one-line text file works. Anything else in the file is part of the descriptor: there is no comment syntax and no blank-line handling.
Both options are parsed in the order they appear. If you give both, or the same one twice, the last one wins.
If the SDDL does not parse, mke2fs prints Invalid root SDDL: followed by the string it was given, and exits without creating a filesystem.
Neither option has a default. Omit both and the filesystem is created with no security descriptor on its root, which is upstream behaviour — mount it under a synthesising policy or it will be unreachable.
Example #
mke2fs -q -t ext4 -E root_sddl="O:SYG:SYD:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)" /dev/vda2
Confirm the result without mounting the filesystem:
debugfs -R "ea_list <2>" /dev/vda2
Extended attributes:
security.peios.sd (96)
Inode 2 is the root directory. To read the descriptor back as bytes rather than just confirm its presence, use debugfs -R "ea_get -V <2> security.peios.sd".
Security-descriptor-aware population #
-d DIRECTORY populates the new filesystem from an existing directory. On Peios this pass also computes a security descriptor for each node it creates.
The source node's explicit descriptor is read from its user.peios.sd extended attribute — not security.peios.sd, which is sealed on a live Peios filesystem and requires CAP_SYS_ADMIN on a Linux build host. Both names are excluded from the generic extended-attribute copy, so neither is propagated verbatim.
For each node:
| Source node | Parent in the new filesystem | Result |
|---|---|---|
Has user.peios.sd | Has a descriptor | Explicit and inherited ACEs merged, written to security.peios.sd. |
Has user.peios.sd | Has none | The explicit descriptor written verbatim. |
Has no user.peios.sd | Either | Nothing written. The node inherits on first access instead. |
Directories are stamped before their contents are visited, so a child always reinherits against a parent whose descriptor is already on disk.
The merge follows the same rules as inheritance on a live system, and runs entirely in userspace — no kernel and no KACS are involved, so a populated Peios filesystem can be built on a host that is not running Peios.
Peios defaults #
mke2fs reads its defaults from a profile. Peios changes three of them, and the changed profile is compiled into the binary, so it applies whether or not a configuration file exists.
| Setting | Upstream | Peios | Reason |
|---|---|---|---|
inode_size | 256 | 512 | Keeps a typical security descriptor in the inode's inline extended-attribute space, where reading it costs no extra I/O. |
default_mntopts | acl,user_xattr | user_xattr | Peios uses security descriptors, not POSIX ACLs. |
| ext4 features | — | + ea_inode | Lets an oversized descriptor spill into its own inode rather than failing to fit. |
base_features, blocksize, inode_ratio and enable_periodic_fsck are unchanged from upstream.
The ext4 quota feature is deliberately not enabled. The Peios kernel is built with CONFIG_QUOTA and CONFIG_QUOTACTL but without CONFIG_QFMT_V2, and ext4 refuses to mount a filesystem carrying the quota feature unless the vfsv1 on-disk format is compiled in. Enabling it in the profile therefore produced filesystems the kernel would not mount. Turning it on again is a pair of changes that have to land together — the kernel option, then the feature.
Resolution order for the profile, highest first:
- The file named by the
MKE2FS_CONFIGenvironment variable, if set. /etc/mke2fs.conf, if present.- The compiled-in Peios profile.
Peios ships no /etc/mke2fs.conf, so the compiled-in profile is what you get unless you deliberately supply a file. Supplying one replaces the profile wholesale — the compiled-in values are a fallback, not a layer underneath — so a partial configuration file silently reverts every setting it does not mention to the upstream default.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Failure. Includes an unparseable root_sddl/root_sd_file value, an unreadable root_sd_file, and a failure to write the descriptor to the new filesystem. |
mke2fs distinguishes no further; unlike e2fsck, it has no bitwise-summed status. When the security-descriptor options fail they report the offending value on standard error before exiting, and no filesystem is created.
See also #
For the model behind the two options — why stamping beats mount-time synthesis, and what one inheritable ACE on the root does and does not express — read Formatting with security descriptors.
For reading and rewriting descriptors on a mounted filesystem, read The sd command.
For how the descriptor is stored, and when ea_inode becomes load-bearing, read SD storage by filesystem.
Installing to disk
Peios / Using Peios / Disks and filesystems
A live Peios runs from a read-only squashfs with a tmpfs stacked on top, so every write it accepts is discarded at reboot. Installing to disk replaces that arrangement with a writable filesystem that survives — and, less obviously, replaces a security policy chosen at mount time with one the filesystem carries itself.
Installation is five steps and one retirement. Nothing about it is magic, and all of it can be done by hand.
Two ways to invoke it #
peios-install --yes --whole-disk /dev/vda # partition the disk, then install
peios-install --yes /dev/vda1 /dev/vda2 # install onto partitions that exist
The first form runs part before anything else — a fresh GPT, a 512 MiB ESP, and a root filling the remainder — and then proceeds exactly as the second. The whole disk is erased.
If the disk already carries a partition table part did not create, the install stops rather than overwriting it; --force is how you say you meant it. That refusal happens before the first mkfs, so nothing has changed when it does.
The second form is for any layout other than the one above: partition however you like with part, then name the two partitions.
What the installer does #
1. Format the EFI system partition. UEFI requires FAT, so this is mkfs.vfat -F 32. A FAT filesystem cannot hold a security descriptor and never will, so the ESP's access policy comes entirely from its mount — necessarily one of the synthesising classes.
2. Format the root, with a descriptor. This is the step with no equivalent on other systems:
mke2fs -t ext4 -E root_sddl="O:SYG:SYD:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)" /dev/vdb2
The descriptor is written to the root directory's security.peios.sd at format time, so the filesystem is administrable from the instant it exists. Because both ACEs are inheritable, everything created inside it derives its own descriptor from that one. See Formatting with security descriptors.
3. Copy the system. cp -ax, which preserves owner, DACL, SACL, timestamps, links and extended attributes, and stops at filesystem boundaries. Every one of those is required rather than best-effort, so a descriptor that cannot be carried across stops the install instead of quietly downgrading it. Mountpoints are recreated as empty directories rather than copied into: /proc, /sys and /dev are mount-moved into the new root by prelude at boot, and /bin, /etc, /lib and the rest are StrataFS views mounted over theirs. What lives behind those views — /usr and /lcl — is ordinary content on the root filesystem, and is copied in full.
4. Write the kernel command line. The installed system needs root=, naming the filesystem that did not exist until step 2. That cannot be package data, so it is generated: the disk-boot package ships a template at /usr/share/disk-boot/cmdline carrying everything stable, and the installer appends root=UUID=<the new root> and writes the result to /lcl/etc/boot/cmdline on the target. Package data supplies the general part; the operator tree holds the per-install part.
5. Build the boot artifact. mkuki bundles the kernel, the initramfs and that command line into a single EFI binary, written to the ESP at EFI/BOOT/BOOTX64.EFI.
That last path is the removable-media fallback, which UEFI firmware boots without an NVRAM entry. A Peios installation therefore needs no bootloader and no boot manager — there is nothing between the firmware and the kernel.
What it refuses before it starts #
Both partitions you name are about to be formatted, so the installer checks them before it does anything irreversible. It stops if either is not a block device, if you name the same partition twice, or — the one worth stating plainly — if either is currently mounted:
# peios-install --yes /dev/vda1 /dev/vda2
peios-install: /dev/vda2 is mounted; refusing to format it
That last check is what stands between you and naming the partition you are running from. All of it happens before the first mkfs, so a rejected install has changed nothing.
The first-account service is retired, the account is not #
Your accounts come across. What does not is the thing that creates them.
A live image ships lpsd-first-account: a oneshot service that runs lps add at boot to create the development account. It is written for exactly one situation, which is a live image — the root there is a tmpfs, so every boot starts from an empty store and the provisioner has work to do.
An installed system is the other situation. The accounts themselves live in lpsd's store at /var/state/lpsd/principals, which is ordinary content on the root filesystem and is copied like everything else. So the installed machine already has the account before it first boots, and re-running a provisioner against a populated store is not a harmless no-op: the script carries no idempotence guard, so lps add fails on the name that already exists and the service crashes on every boot. Once installation grows a "choose a password" step, it would be worse than noisy — a provisioner that reasserts the image's credential would undo the operator's choice at the next reboot.
So before it copies anything, the installer deletes the service from the registry:
reg del 'Machine\System\Services\lpsd-first-account'
Note which registry. It removes the key from the live system it is running on, not from the target — the registry is a live database served by registryd, and the only thing that can safely edit it is the registryd currently holding it open, so the removal happens at the source and the copy never carries it. Running the installer again, or running it from an already-installed system, finds nothing to remove and says so. The removal is checked afterwards and a failure stops the install, which happens before either partition is formatted and therefore costs nothing.
Use UUIDs, not device names #
Step 4 records the root by UUID, and the reason is worth understanding rather than copying.
The disk that is /dev/vdb while you install — second device, behind the install medium — is /dev/vda when you boot it with the medium removed. A device name baked into the command line is a name for where a disk was plugged in, not for the disk, and it stops being true the moment the arrangement changes.
The initramfs resolves the UUID by probing block devices directly rather than reading /dev/disk/by-uuid, because no device manager runs in the initramfs and those directories do not exist there — see Stable device names.
How the installed system boots #
The initramfs contains two hooks that can mount a root, and both are present in every image:
| Hook | Mounts |
|---|---|
mount-root.sh | a live medium's squashfs, with a tmpfs overlay above it |
mount-root-disk.sh | an installed root partition, directly |
Both run at every boot. root= on the command line decides which one acts; the other exits successfully having done nothing. Boot hooks covers the mechanism.
Two differences in what the disk hook does are worth calling out, because they are the point of installing at all.
It mounts the partition directly — no overlay. The live path stacks a tmpfs because the layer beneath it is read-only. An installed root is writable, so an overlay would serve only to throw away everything written to it.
It mounts policy=deny-missing, and runs no seed-sd. A live root has no descriptors at all — the squashfs ships none — so the live system mounts it synth-ephemeral and has KACS invent one per inode, then seeds a single inheritable descriptor onto the tmpfs above. An installed root needs none of that: it carries a real descriptor, written at format time, so a file without one is a fault worth surfacing rather than a gap to paper over.
This is the substantive difference between the two, and it is easy to check on a running installed system:
# sd show /
Owner: LocalSystem (S-1-5-18)
DACL: (2 ACEs)
[0] allow LocalSystem (S-1-5-18) 0x10000000 [CI,OI]
[1] allow BUILTIN\Administrators (S-1-5-32-544) 0x10000000 [CI,OI]
No ID flag on either ACE. ID is INHERITED_ACE, so its absence means this descriptor is explicit — stored on the root inode by mke2fs, not derived from an ancestor and not synthesised at mount. The filesystem's access policy is a property of the filesystem.
What the installer does not do #
It does not choose your partition layout. --whole-disk writes one specific arrangement — a 512 MiB ESP and a root filling everything else — and that is all it will ever write. Anything else is a job for part followed by the two-partition form of the installer.
It does not choose a layout. One ESP, one root, no separate /home, no swap, no encryption. Each of those is a real thing to want and none of them exists yet.
It does not express a per-directory access policy. The whole installed tree inherits from that single descriptor on the root. That is a complete answer for a system tree where everything is administrator-owned, and it is not a way to say "readable system tree, private home directories" — one inheritable ACL cannot say two different things. Per-subtree descriptors are the work that makes that expressible.
Where to go next #
For the format-time descriptor and how a populated tree gets its own, read Formatting with security descriptors.
For the hook mechanism the two root-mounting hooks share, read Boot hooks.
For what deny-missing does with a file that has no descriptor, and why the live path cannot use it, read Policy classes.
StrataFS
Peios / Using Peios / Disks and filesystems / StrataFS
StrataFS presents several ordinary directories as one merged directory tree. The directories remain ordinary and independently manageable at their real paths; the StrataFS mount only supplies the merged view.
Strata are ordered from highest to lowest precedence. For each name, the first stratum that holds it is its provider. Directories at the same name merge, so their children are resolved in the same precedence order. A non-directory provider masks every lower object at that name, including a lower directory's whole subtree.
A /bin example #
Suppose /usr/bin contains packaged programs and /lcl/bin is for local
changes. Mount this stack at /bin:
/lcl/bin has higher precedence and receives newly-created objects. The +ro
on /usr/bin means “do not modify this stratum through /bin”. It does not
make the real /usr/bin mount read-only: an authorised writer can still modify
/usr/bin/tool directly at /usr/bin/tool.
Reading /bin/tool uses /lcl/bin/tool when it exists, otherwise
/usr/bin/tool. Writing an existing packaged tool through /bin copies it to
/lcl/bin first and changes the copy. Removing that copy through /bin
restores the unchanged /usr/bin version to view, because StrataFS does not use
whiteouts.
The base Peios topology #
The fsbase package installs the mount-rootfs-stratafs-base.sh boot hook
in the initramfs. It runs after the deployment-specific hook has mounted the
real root and before prelude hands off to it, mounting the conventional
root-level views as one boot step:
| View | Strata, highest precedence first |
|---|---|
/bin | /lcl/bin+create, /usr/bin+ro+am |
/sbin | /lcl/sbin+create, /usr/sbin+ro+am |
/lib | /lcl/lib+create, /usr/lib+ro |
/libexec | /lcl/libexec+create, /usr/libexec+ro+am |
/share | /lcl/share+create, /usr/share+ro+am |
/include | /lcl/include+create, /usr/include+ro+am |
/etc | /system/retc, /lcl/etc+create, /usr/etc+ro+am |
/conf | /lcl/conf+create, /usr/conf+ro+am |
am permits an optional vendor directory to be absent when the system boots
and makes it participate automatically if a later package creates it. The
operator create directories are provisioned by fsbase; their absence is a
boot error rather than something StrataFS silently creates, because their own
security descriptors govern creation through each view.
/lib views /usr/lib rather than the architecture triplet directory beneath
it, so /lib/modules and /lib/firmware resolve. Both matter: kmod has
/lib/modules compiled in, and the kernel's firmware loader searches
/lib/firmware, and neither can be told to look elsewhere. Shared libraries are
unaffected — the loader finds them through its own absolute system search path
rather than through this view — and /lib/x86_64-linux-peios/ still resolves,
one level down, which is the shape a foreign binary expects.
/lib64 is not a StrataFS view. On x86-64 it remains the package-owned relative
symlink lib64 -> usr/lib/x86_64-linux-peios, because the psABI dynamic-loader
path must work before any hook can mount the base topology. It is a distinct
object from the /lib view above and is unaffected by what that view maps.
The mount option grammar is:
strata=<stratum>[:<stratum>]...
<stratum> := <path>[+<flag>]...
<flag> := create | ro | am
Paths are absolute. create selects the one stratum that receives creations
and copy-up, ro prevents modification through the merged view, and am
allows the stratum directory itself to be temporarily absent. Literal :,
+, ,, and \ in a path are escaped with \.
Inspecting a stack #
The stratafs command is a read-only inspector. It does not mount, modify, or
clean anything, and it never gains extra authority. Every direct stratum read
runs with your normal access rights. If it cannot see all the information
needed for a complete answer—especially every participant of a protected
merged directory—it fails instead of showing a misleading partial result.
List every StrataFS mount in the current mount namespace, or one exact mount:
Typical output is:
/bin
[0] /lcl/bin+create (present)
[1] /usr/bin+ro (present)
The order is the precedence order. A stratum is present, absent, or
not_directory; the mount itself is also labelled when generically mounted
read-only.
Explaining one path #
Use resolve when you want to know why a name looks the way it does and where
a mutation would go:
The per-stratum states are:
| State | Meaning |
|---|---|
provider | This object currently provides the name. |
participant | This lower directory contributes to the merged directory. |
shadowed | A higher object of the same type wins. |
masked | A provider of another type hides this object. |
absent | This stratum does not hold the path. |
The report also explains write and delete. A write can route in_place,
copy_up, or create; it can instead report erofs, a missing parent, an
unknown immutable-attribute state, or that content I/O follows a symlink.
Deletion identifies the real provider entry to remove and names a lower object
that will resurface. Removing a directory is still conditional on the complete
merged directory being empty.
These are routing answers, not authorisation promises. KACS checks the actual operation against the caller when it is attempted.
For the kernel's answer without the explanation, print the synthetic origin attribute:
A non-directory prints its one real provider path. A merged directory prints every participating real directory in precedence order, one escaped path per line.
Inspecting local state #
The create stratum is deliberately easy to audit. sweep recursively reports
every object in it:
Each result is classified as:
gap: only the create stratum has this path;override: a lower stratum also has it; orshadowed: a higher stratum, or a higher non-directory ancestor, makes it unreachable.
Directories are included. For a directory, override describes structural
presence; corresponding directories still merge. When sweep says
create stratum is empty, the local stratum has no entries to reconcile. The
command never deletes anything—remove a reviewed entry through the merged view
or at its real create-stratum path, according to the result you intend.
To inspect the content of an override:
diff compares the create-stratum object with the first lower default. It
supports regular files and symbolic links, reports type changes, refuses other
object types, and bounds each regular-file read to 16 MiB. Binary files are
reported only as different.
Structured output and status #
list, resolve, and sweep accept --json. JSON is the stable scripting
interface; it includes the same paths, flags, states, object types, and routing
actions. A non-UTF-8 path is represented losslessly in human output but causes
JSON mode to fail rather than substitute a lossy name.
Exit status has probe-friendly meaning:
| Status | Meaning |
|---|---|
0 | Success; for sweep, no entries; for diff, no difference. |
1 | sweep found entries or diff found a difference. |
2 | Usage, visibility, malformed-state, or operational error. |
Where to go next #
For the generic command that creates this and other mounts, read
mount. For how access to every real object is
decided, start with File access.
Package management
Peios / Using Peios / Package management
peipkg is the command that manages software on a running Peios system. It installs packages and the dependencies they need, upgrades them, removes them, and answers questions about what is installed and where it came from.
$ peipkg install nginx
$ peipkg upgrade
$ peipkg list
This page explains what a package is, where packages come from, and the design principle that peipkg holds no authority of its own. It then names every command and points you at the rest of the topic.
What a package is #
Peios distributes software as .peipkg files: signed, self-contained archives. Each one carries a manifest — a name, a version, a target architecture, the other packages it depends on, the packages it conflicts with — and a payload, the files that land on disk when it is installed.
A package is a low-level primitive. It is the unit peipkg installs and tracks. The curated, user-facing concepts that most operators think in — bundles of software, "the web-server role", applications — are built above packages and are out of peipkg's scope: peipkg installs nginx the package; it does not know what a "web server role" is. This topic is about the primitive.
What peipkg does coordinate at the package level is claims: a claim is a single shared name that exactly one installed package may hold, so that two packages offering the same role — for example, registryd and loregd both providing the registry — do not silently collide.
Every installed package is tracked in a private database. peipkg records, for every package, which files it owns — the record that makes a clean removal, a correct upgrade, and the verify check possible.
Where packages come from #
Packages come from repositories: HTTP or HTTPS locations that serve a set of .peipkg files alongside signed indexes describing them. A Peios system is configured with one or more repositories, each one anchored to a signing key the operator has chosen to trust.
peipkg keeps a local, verified copy of each repository's metadata. peipkg refresh updates that copy; peipkg install and peipkg upgrade plan against it. The trust model — how a repository is anchored, how its signing keys rotate, and how unsigned repositories are handled — is covered in Repositories and trust.
A package can also be installed straight from a .peipkg file on disk, with no repository involved. That path trades the repository's trust guarantees for convenience; Installing and removing packages covers what it keeps and what it gives up.
flowchart LR
A["Repository<br/>signed packages + indexes"] -->|"refresh"| B["Cached metadata"]
B -->|"install / upgrade"| C["peipkg<br/>resolve → verify → commit"]
C -->|"file changes"| D["Your system"]
C -->|"operation events"| E["Audit stream"]
peipkg has no authority of its own #
The key design point is that peipkg works differently from package managers on most systems.
peipkg is not a privileged daemon. It is not setuid. It has no service account, no special identity, no broker it asks to do privileged work. It is an ordinary program, and it runs as you — under your token, with your rights.
The question "may I install this?" is therefore not a question peipkg answers. It is the same question Peios asks of any attempt to write a file: it compares your token against the security descriptor on the directory being written. If the security descriptors on /usr, /opt, and the rest say your token may write there, the install succeeds. If they do not, it fails — at the file operation, the same way any unauthorised write fails.
"Who may install software" is therefore not a peipkg setting. It is just the access rules on the system directories — Administrators, by default. To grant someone install rights, you grant them write access to the directories packages land in; to scope what they may touch, you scope those security descriptors. This is the ordinary access-decision machinery, with nothing package-specific layered on top.
One consequence follows directly: a package cannot grant its installer rights the installer did not already have. Any security descriptors a package asks to set on its own files can only be descriptors the caller already had the authority to set. There is no confused-deputy problem, because peipkg holds no authority that a package could misuse.
Every change is a transaction #
An install, an upgrade, a downgrade, a removal — each is a transaction, and each is atomic. There is a single instant, the commit, before which any failure, interruption, or power loss leaves the system exactly as it was, and after which the operation is complete. There is no partially installed intermediate state for a transaction to get stuck in.
Transactions are also reversible. peipkg keeps a history of them, retains the data needed to walk one back, and offers undo and recover to do it. Transactions and recovery covers the model in full.
Every operation is audited #
peipkg records each operation it performs — what was installed, upgraded, or removed, and the outcome — to the Peios audit stream. When a plan contains an action that needs deliberate authorisation, the authorising act itself is recorded too.
These events are a semantic summary: a readable account of what peipkg set out to do. They are not the security boundary. The authoritative record is the kernel's own audit of the actual file operations — and because peipkg runs with no special authority, it cannot suppress that record.
The command surface #
Every command is invoked as peipkg <command> [arguments].
| Command | Does |
|---|---|
install | Install packages, with dependencies, from a repository or a local .peipkg file. |
remove (alias uninstall) | Remove installed packages. |
upgrade | Move installed packages to their newest available version. |
downgrade | Move one package to a specific older version. |
undo | Reverse the most recent transaction. |
claim | Show or change which package holds a claim — a shared name that exactly one package may own. |
refresh | Update the cached metadata of the configured repositories. |
repo | Configure repositories — add, list, remove. |
root | Manage named roots — add, list, remove, show. |
list | List the installed packages. |
info | Show one installed package's details. |
files | List the files a package owns. |
owns | Report which package owns a given path. |
search | Search the configured repositories for a package. |
verify | Check installed files against what was recorded at install. |
history | Show the transaction log. |
recover | Roll back a transaction left pending by an interruption. |
clean | Delete cached metadata for repositories no longer configured. |
One global option sits before the command: --root TARGET makes peipkg operate on a Peios installation other than the running system at /. TARGET can be a literal path — a Peios installation mounted at DIR, the form used by image builders and offline maintenance — or the name of a named root. Named roots are more than a convenience: they are how peipkg keeps components such as the initramfs current. See Named roots for the detail.
The producer side #
peipkg is the consumer half of the Peios packaging story — the tool that runs on a deployed system and consumes packages. It has a counterpart it never shares a process with: the producer tools (peipkg-build, peipkg-repo, peipkg-manager) that turn source into signed .peipkg files and assemble the repositories peipkg fetches from. Building packages and running a repository are a separate job with their own documentation, the Peios Packages guide. This topic is for the operator of a Peios system, not the operator of a build farm.
peipkg has one consumer-side companion of its own: peipkg-compose, a separate binary — not a peipkg subcommand. Where peipkg mutates a running system in place, peipkg-compose builds a fresh, package-owned root directory offline from a declarative manifest, the form image builders use when they assemble a system from nothing. It consumes the same packages peipkg does. See Composing a root.
Where to start #
For the everyday work — putting software on a system and taking it off — read Installing and removing packages.
For keeping a system up to date, and for walking a change back, read Keeping a system current.
To configure where packages come from and how their authenticity is established, read Repositories and trust.
To understand why an interrupted install is always safe, read Transactions and recovery.
To understand how peipkg decides which versions of which packages a request implies — and why some actions require a second, separate authorisation — read Dependency resolution.
To understand how two packages that offer the same shared name settle on a single winner, read Claims.
To inspect what is installed and check that it is intact, read Inspecting and verifying.
For the more specialised work of operating on a Peios installation other than the running one — and how peipkg keeps things like the initramfs current — read Named roots.
For the specialised work of assembling a fresh root offline from a manifest — the image-builder's path, run with the separate peipkg-compose binary — read Composing a root.
Installing and removing packages
Peios / Using Peios / Package management
peipkg install puts packages on the system; peipkg remove takes them off. They are the two commands you reach for most, and they share one flow — peipkg works out the full set of changes, shows it to you, and waits for your approval before touching anything.
$ peipkg install nginx
$ peipkg remove oldtool
Installing packages #
peipkg install <package|file.peipkg>...
Each argument is either the name of a package to fetch from a configured repository, or the path of a local .peipkg file (recognised by its .peipkg suffix). You can mix the two in one command.
Installing a package rarely means installing just that package. peipkg works out everything the request implies — the dependencies the package needs, and the dependencies of those in turn — and presents the whole set. How that set is computed is the subject of Dependency resolution; this page is about the flow around it.
The plan-and-confirm flow #
install, remove, and the commands on Keeping a system current all work the same way. peipkg first produces a plan — the ordered list of changes that satisfy your request — and prints it:
$ peipkg install nginx
the following changes will be made:
install pcre2 10.44
install zlib 1.3.1
install nginx 1.27.4
proceed? [y/N]
Nothing has been downloaded and nothing on the system has changed. peipkg waits for an answer. Anything other than y or yes — including pressing Enter, or end-of-input — is a refusal, and the command exits having done nothing.
Answer y and peipkg carries the plan out as a single transaction: it downloads and verifies every package, then commits the change atomically.
| Option | Effect |
|---|---|
--dry-run | Produce and print the plan, then stop — never prompt, never change anything. |
--yes, -y | Skip the proceed? prompt and apply the plan. |
--no-claim | Install a provider without taking any claim it offers. |
--allow-stale | Proceed although a repository's trust state exceeds its maximum trusted age. See Repositories and trust. |
--claim <names> | Comma-separated claims to force-claim, overriding the current holder(s). |
--claim-all | Force-claim every claim the installed packages provide, overriding incumbents. |
--dangerously-bypass-path-restrictions | Permit packages that declare special_system_package to install outside the payload layout rules. Exempts nothing that has not declared itself special, and never reaches /lcl/policy. Needed only for the handful of packages whose job is to lay down the filesystem structure those rules protect. |
--dry-run is the safe way to see what a command would do. --yes is for scripts and unattended runs — but note that it skips only the routine prompt. A plan that contains an action needing deliberate authorisation will still stop and ask; --yes does not override that. See Elevated authorisation for which actions those are and why.
--claim-all cannot be combined with --claim or --no-claim. Claims — shared names exactly one package may hold — are covered in Claims.
An install can also target a root other than the current one — either explicitly with --root, or because a package declares its own default root. See Named roots for how roots are named and nested.
Installing from a local file #
When an argument is a path ending in .peipkg, peipkg installs that file directly:
$ peipkg install ./nginx-1.27.4.peipkg
This is a raw install, and it differs from a repository install in one specific way: it skips the repository trust layer. There is no signed index to check the file against, no signing key to verify it under, and none of the freshness or rollback protection a repository provides. You are vouching for the file yourself.
Everything else still happens. The package format is fully verified: the archive structure, the manifest, the integrity manifest, and the hash of every payload file are all checked before anything is staged. A corrupt or truncated .peipkg is rejected in the same way as one from a repository. The file's dependencies still resolve normally against your configured repositories — a locally-installed package can pull in repository packages to satisfy what it needs.
A package supplied as an explicit local file takes precedence over any repository's version of the same package, so install ./foo.peipkg installs that file even if a repository offers foo too. In the plan, a local-file operation is marked so the choice is visible:
install nginx 1.27.4 (local file)
In the future, peipkg will be able to consult system policy to decide whether raw installs are permitted at all, and that gate will be configurable. For now a raw install is always allowed; the verification above is what stands behind it.
Removing packages #
peipkg remove <package>...
peipkg uninstall <package>...
remove and uninstall are the same command. Each argument names an installed package; peipkg plans the removal — the files to take off disk — and runs the plan-and-confirm flow described above.
A removal leaves shared directories in place and removes only the files the package owns. peipkg knows which files those are from its database, so a removal is clean and complete.
Removing something that is depended on #
peipkg will not, by default, leave the system inconsistent. If you ask to remove a package that another installed package depends on, the plan is refused: peipkg tells you what still needs it, and stops.
| Option | Effect |
|---|---|
--cascade | Also remove every installed package that depends on the ones named. |
--dry-run | Print the plan and stop. |
--yes, -y | Skip the proceed? prompt. |
--cascade turns that refusal into a wider plan: peipkg computes the full set of packages that would be left with a broken dependency and adds them to the removal. The plan then shows everything that will be removed. Review it before approving, because a cascade can reach further than expected.
$ peipkg remove --cascade libfoo
the following changes will be made:
remove toolA 2.1
remove toolB 1.0
remove libfoo 3.3
proceed? [y/N]
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — or, for --dry-run, the plan was produced. A declined prompt is also 0: nothing failed. |
1 | The operation failed — a package was not found, a dependency could not be satisfied, a download or verification failed, or a file operation was denied. |
2 | A usage error — an unknown command or a malformed option. |
Keeping a system current
Peios / Using Peios / Package management
Keeping a Peios system current is two commands in sequence — refresh to learn what is available, then upgrade to move to it. The other two commands here, downgrade and undo, go the other way: they walk a change back when an upgrade turns out badly.
Refreshing repository metadata #
peipkg refresh [repository]...
peipkg plans against a local, verified copy of each repository's metadata. That copy does not update itself. peipkg refresh is what updates it: for every configured repository — or just the ones you name — peipkg fetches the current signed descriptor and index, verifies them, and replaces the cached copy.
$ peipkg refresh
refreshed "official"
refreshed "internal"
Refresh has two properties worth knowing:
- Repositories are independent. If one repository is unreachable or fails verification, peipkg reports it and carries on with the rest. One bad repository never blocks a refresh of the others.
- A failed refresh changes nothing. If a repository cannot be refreshed, peipkg keeps the metadata it already had. It never falls back to unverified or stale-but-unchecked content. The worst a failed refresh does is leave you on yesterday's view.
What "verified" means here — signing keys, freshness floors, the handling of an unsigned repository — is the subject of Repositories and trust.
Run refresh before an upgrade. An upgrade plans against whatever metadata is cached, so an upgrade without a recent refresh moves you to the newest version peipkg last heard about.
Skip it for long enough and peipkg stops waiting for you: an install, upgrade, or downgrade against a repository whose trust state has passed its maximum trusted age (30 days by default) refreshes that repository itself, and refuses to proceed against one that stays stale — unreachable, or frozen on an unchanging index — unless you pass --allow-stale. The bound and its configuration are covered in Repositories and trust.
Upgrading #
peipkg upgrade [package]...
With no arguments, upgrade considers every installed package and moves each one that has a newer available version forward to it. With one or more package names, it upgrades only those — and still pulls in any new dependencies they need.
$ peipkg refresh && peipkg upgrade
the following changes will be made:
upgrade zlib 1.3.1 -> 1.3.2
upgrade nginx 1.27.4 -> 1.27.5
proceed? [y/N]
upgrade uses the same plan-and-confirm flow as install — peipkg shows the full set of changes and waits for approval — and the same options:
| Option | Effect |
|---|---|
--dry-run | Print the plan and stop. A good way to preview what an upgrade would move. |
--yes, -y | Skip the proceed? prompt. |
--no-recurse | Confine the upgrade to the current root only — disable the cascade into nested named roots. |
--allow-stale | Proceed although a repository's trust state exceeds its maximum trusted age. Warned and audited. |
By default, when named roots are configured, upgrade cascades: it reconciles the current root and every named root nested under it, each as an independent continue-on-error transaction with its own summary. --no-recurse disables that and upgrades the current root alone. See Named roots for the named-roots model.
If there is nothing to do — every package already at its newest version — peipkg says so and exits.
Downgrading #
peipkg downgrade <package> <version>
downgrade moves one package to a specific older version. You name the package and the exact version you want:
$ peipkg downgrade nginx 1.27.4
Older versions are not in a repository's active index — that index lists current versions only. They live in its archive index, which peipkg fetches on demand when you ask for a version that is not current. The package must still exist in some configured repository's archive for the downgrade to be possible.
A downgrade is treated as a deliberate move. Going backward — onto a version that may have known issues a newer one fixed — is one of the actions peipkg flags for explicit authorisation: beyond the routine proceed? prompt, peipkg asks you to authorise the specific downgrade, and --yes does not stand in for that answer. See Elevated authorisation for the full set of actions that work this way.
downgrade accepts --dry-run, --yes, -y, and --allow-stale with the same meaning as elsewhere.
Undoing the last change #
peipkg undo
undo reverses the most recent committed transaction. If that transaction installed a package, undo removes it; if it upgraded, downgraded, or removed packages, undo restores each one to the version it had before.
$ peipkg undo
undoing transaction 47 (upgrade nginx, zlib)
the following changes will be made:
downgrade nginx 1.27.5 -> 1.27.4
downgrade zlib 1.3.2 -> 1.3.1
proceed? [y/N]
Be precise about what undo is. It is not a rollback of committed state — the previous transaction happened and stays in the history. undo computes the inverse of that transaction and applies it as a new transaction of its own. The history grows; it does not rewind. That new transaction can itself be undone, and so on.
Because restoring an older version is a backward move, undo carries the same explicit-authorisation requirement as downgrade for any package it walks back. It accepts --dry-run and --yes, -y.
To undo something other than the most recent transaction, or to see the history undo works against, use peipkg history — and revert a specific package directly with downgrade.
The routine cycle #
For day-to-day maintenance the loop is:
$ peipkg refresh # learn what is available
$ peipkg upgrade --dry-run # preview the move
$ peipkg upgrade # apply it
downgrade and undo are the recovery commands — use them when an upgrade has brought in a change you want to remove. Every one of these commands is a transaction, so every one of them is atomic and itself reversible.
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — including a dry run, a plan with nothing to do, and a declined prompt or authorisation (nothing failed). |
1 | The operation failed — a repository could not be refreshed, a repository's trust state exceeded its maximum age and a forced refresh could not clear it, resolution or a download or verification failed, a root in an upgrade cascade failed, there is no committed transaction to undo, or a command's arguments were wrong (downgrade without a package and version, an unparsable version, a malformed option). |
2 | A usage error before any command ran — no command, an unknown command, or a malformed global option (including a --root reference that does not resolve). |
Repositories and trust
Peios / Using Peios / Package management
A repository is where packages come from: an HTTP or HTTPS location that serves .peipkg files alongside signed indexes describing them. A Peios system has zero or more repositories configured, and the peipkg repo commands manage that configuration.
Beyond configuration, this page covers trust: establishing that the packages a repository serves are genuinely the ones its operator published, and not something a network attacker or a compromised mirror substituted.
What a repository serves #
At its base URL a repository serves three signed documents:
- A descriptor — the repository's identity: the set of signing keys it uses, each with a status. This is the root of trust for everything else.
- An active index — the catalog of current package versions: names, versions, dependencies, hashes, and download locations.
- An archive index — the catalog of older versions, kept so a downgrade can reach them. peipkg fetches it only on demand.
The descriptor and the indexes each carry a detached signature. peipkg verifies all of them; an unverifiable document is discarded, never used.
Adding a repository #
peipkg repo add <name> <base-url> --anchor <fingerprint>
repo add does two things: it records the repository in your configuration, and it runs the trust ceremony that anchors it.
$ peipkg repo add official https://pkgs.peios.org \
--anchor ef86709c4b1d8a02e5f3c719d640aa8b7c2e9105f8d3b6470a1c2e9d8b5f3a04
added repository "official"
The --anchor value is a signing-key fingerprint, and it is the part of the command that establishes trust. peipkg cannot tell you whether a repository is genuine; only you can, by obtaining its fingerprint through a channel you trust (the project's website over HTTPS, a colleague, a printed reference) and supplying it here. The anchor is your out-of-band statement that the key is trusted.
Given an anchor, the ceremony runs:
- peipkg fetches the repository's descriptor and its signing keys.
- It checks the fetched keys against the anchors you supplied — a fingerprint must match bit for bit.
- Only if the descriptor's signature verifies against an anchored key does peipkg accept it and record the trust state.
If the fetched descriptor does not verify against any anchor, repo add fails and leaves nothing behind — no half-added repository, no configuration file. You can supply --anchor more than once to trust several keys at first contact.
| Option | Default | Effect |
|---|---|---|
--anchor FINGERPRINT | — | A signing-key fingerprint to trust. Repeatable. |
--priority N | 50 | Resolution priority — a lower number wins. See below. |
--policy required|optional | required | Whether a valid signature is required. See Signature policy. |
--min-index-version N | 0 | A freshness floor for the index. See Freshness. |
--max-trusted-age-days N | 30 | How stale the repository's trust state may grow before operations force a refresh. See Freshness. |
--max-index-staleness-days N | 90 | How old the index's own generated_at may be before operations force a refresh. See Freshness. |
--insecure | off | Permit a plain http:// base URL. |
--insecure exists for a repository on a trusted local network with no TLS. It lowers transport confidentiality and integrity; the package signatures still protect authenticity, but prefer https:// whenever it is available.
Adding a repository your system already carries #
An image can ship a repository's configuration — its base URL, its policy, and its trust anchors — as a .repo file in /conf/peipkg/ (stored at /lcl/conf/peipkg/, which is what that view exposes). A Peios installation medium does exactly that for the offline repository it carries.
Configuration alone is not trust. peipkg does not trust a configured repository on sight: until the ceremony has run there is no recorded trust state, and every operation skips the repository with a warning rather than quietly using it. The decision to trust a key is yours, never a default.
What a baked-in .repo file changes is only where the anchor comes from. It arrived with the image rather than being typed at the prompt, which is still out-of-band — it did not come from the repository it authenticates. So the ceremony can run against it without you retyping a 64-character fingerprint that is already on disk:
$ peipkg repo add peios-medium
added repository "peios-medium"
Given a name and nothing else, repo add reads that repository's existing configuration and runs the same ceremony as the two-argument form. It is still an explicit act — nothing here happens on its own.
Two differences from the full form are worth knowing:
- It refuses a configuration with no
trust_anchorsunder therequiredpolicy, because there would be nothing to verify the descriptor against. - A failed ceremony leaves the
.repofile alone. The two-argument form wrote that file and removes it again on failure; here it came from somewhere else, and deleting another party's configuration because a ceremony failed would turn a retryable problem — a medium not mounted yet, a repository not published — into lost settings.
How trust survives key rotation #
A repository operator will, over time, rotate signing keys — retiring an old one, bringing a new one into use. If trust were pinned permanently to the fingerprint you first anchored, every rotation would break every consumer.
Rotation does not break trust, because the descriptor carries the keys. Each key in the descriptor has a status:
| Status | Meaning |
|---|---|
active | In current use for signing. |
transitioning | Being phased in or out — honoured until its stated expiry. |
revoked | Withdrawn — never honoured again. |
Each time peipkg refreshes a repository, it verifies the new descriptor against the keys in the descriptor it already trusts, and then adopts the new descriptor's key set as the current trust state. A rotation that is itself signed by a still-trusted key propagates automatically: you anchored once, and the chain carries forward from there without you re-anchoring. A revoked key is dropped and never accepted again.
The anchor therefore matters only at first contact. After that, trust is a chain, and each refresh extends it.
Signature policy #
--policy chooses how strict peipkg is about signatures for this repository.
required(the default) — every descriptor and index must carry a valid signature from a trusted key. An unverifiable document is rejected. This is the correct setting for any repository reached over a network.optional— combined with no trust anchors, this puts the repository into unsigned mode: peipkg fetches and uses its metadata and packages without cryptographic verification.
Unsigned mode is a deliberate escape hatch for a scratch repository on a build host, or a local mirror you fully control. Every operation that touches an unsigned repository prints a warning:
peipkg: warning: repository "scratch" is unsigned — its metadata and
packages are not cryptographically verified
In unsigned mode, only the transport stands between you and a substituted package. Do not use it for anything reachable from an untrusted network.
Priority #
--priority is a number — lower is stronger, default 50 — that decides which repository wins when more than one offers the same package. If both an official repository at priority 10 and an internal one at priority 20 carry nginx, the resolver takes the official build. Priority does not restrict anything; it only breaks ties. Its full role in version selection is covered in Dependency resolution.
Priority also feeds one of the safety checks there: a package from a lower-priority repository that tries to displace a package installed from a higher-priority one is flagged for explicit authorisation, so a low-trust repository cannot quietly take over a package you got from a trusted one.
Freshness — defeating a stale-repository attack #
A signature proves a document is authentic; it does not prove the document is current. An attacker who cannot forge a signature can still serve you a genuine, correctly-signed, but old index — one from before a security fix was published — and a naive consumer would accept it.
peipkg closes that gap by tracking, per repository, the highest index version it has ever seen, and refusing any index that goes backward. Once you have seen version 5 of an index, version 4 — however well signed — is rejected. A repository can only ever move forward.
--min-index-version N lets you set that floor explicitly, out of band: if you know the current index is at least version N, anchoring that number means peipkg will reject anything older even on the very first fetch, before it has a history to compare against.
Going backward is one half of the attack; standing still is the other. A frozen repository — one that serves the same, correctly-signed index forever — never trips the rollback check, yet it can hold you on a view from before a security fix indefinitely. Against that, peipkg tracks when each repository last refreshed with progress and enforces a maximum trusted age: 30 days by default, tunable per repository with max_trusted_age_days in its .repo file or --max-trusted-age-days at add time.
When an install, upgrade, or downgrade finds a repository's trust state older than its maximum, peipkg refreshes that repository first. If it cannot — the repository is unreachable, or it refreshes without progressing — the operation is refused rather than planned against outdated metadata. Passing --allow-stale to the operation overrides the refusal; the override is warned about and recorded in the audit stream. A maximum above 180 days draws a warning on every operation, because a bound that loose effectively disables the check.
There is a third way to stand still, and it needs its own check. Maximum trusted age asks how long since I last refreshed successfully — and a repository can keep that answer flattering forever by bumping its index version on every publication while stamping the index with an ancient generated_at. Every refresh looks like progress; the metadata never actually moves. So peipkg separately enforces a maximum index staleness measured from the index's own generated_at: 90 days by default, tunable per repository with max_index_staleness_days in its .repo file or --max-index-staleness-days at add time.
The two are deliberately independent, and raising one does not widen the other — a max_trusted_age_days of 180 still leaves the 90-day staleness window in place. An index past that window triggers a refresh before any install proceeds, and the same --allow-stale override applies, with the same warning and audit record. A staleness bound above 365 days draws a warning on every operation.
Listing and removing repositories #
peipkg repo list
peipkg repo remove <name>
repo list prints the configured repositories — name, base URL, priority, signature policy. It accepts --json.
$ peipkg repo list
official https://pkgs.peios.org priority=10 required
internal https://pkg.corp.example priority=20 required
repo remove deletes a repository's configuration and the trust state peipkg recorded for it. Packages already installed from that repository stay installed — removing a repository controls where future packages come from; it does not remove past ones. Those packages simply no longer have a source for upgrades until you add a repository that carries them again.
Where the configuration lives #
Each repository is one file: /lcl/conf/peipkg/<name>.repo, in flat TOML.
= "https://pkgs.peios.org"
= 10
= "required"
= ["ef86709c4b1d8a02e5f3c719d640aa8b7c2e9105f8d3b6470a1c2e9d8b5f3a04"]
The files are hand-editable, and editing one is a legitimate way to configure a repository — a trust anchor written into the file is you supplying that anchor out of band. peipkg repo add is the convenient front end: it runs the ceremony and writes the file for you. Who may edit these files is, like everything else on Peios, the security descriptor on /lcl/conf/peipkg/.
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — the repository was added, listed, or removed. |
1 | The operation failed — the trust ceremony failed (repo add backs out and leaves nothing behind), or the subcommand was missing, unknown, or given the wrong arguments (a missing repo add <name> <base-url>, a malformed option). Removing a name that is not configured is not a failure. |
2 | A usage error before any command ran — no command, an unknown command, or a malformed global option. |
Transactions and recovery
Peios / Using Peios / Package management
Every change peipkg makes — an install, an upgrade, a downgrade, a removal — is a transaction. A transaction is atomic: it either happens completely or not at all. There is no state in which a package is half-installed, and no failure, signal, or sudden power loss that can leave one there.
This page explains how that guarantee is built, what an interrupted run leaves on disk, and the two commands — recover and history — that exist because of it.
The three phases #
Every operation moves through the same three phases.
flowchart LR
P["Plan<br/>read-only, no lock"] -->|"you approve"| S["Stage<br/>lock held; fetch + verify"]
S --> C["Commit<br/>the atomic flip"]
C --> D["Done"]
P -.->|"abort"| X["Nothing changed"]
S -.->|"abort / crash"| X
Plan. peipkg reads your request, reads the installed set, reads the cached repository metadata, and computes the ordered list of changes — or rejects the request. This phase is entirely read-only and takes no lock. It is why --dry-run and the query commands never block, even while another transaction is running, and why a plan can always be abandoned for free.
Stage. Once you approve the plan, peipkg takes a single-writer lock — only one transaction touches the system at a time — and prepares everything. It downloads every .peipkg in the plan and verifies all of them before staging any one of them, so no package's contents can influence another's verification. Verified payloads are written into a staging area, each file hash-checked as it lands. Nothing the system uses has changed yet.
Commit. peipkg moves the staged files into place and records the new package state. This is the phase that changes the system.
The one instant that matters #
Within the commit there is a single instant — the moment peipkg records the new state in its database — that divides the entire operation in two:
- Before it: any failure, any signal, any power loss rolls everything back. The staged files are discarded, any displaced files are put back, and the system is exactly as it was.
- After it: the operation is complete and durable.
There is no third outcome. A transaction is never "partly applied". The rest of this page describes the machinery that delivers this guarantee.
Backups make rollback free #
When peipkg replaces or removes a file, it does not overwrite or delete it. It renames the old file aside — to a sibling name in the same directory — and puts the new file in place. The old contents are untouched, just under a different name.
Rolling back is then simply renaming everything back. No data is copied, no contents are reconstructed; the rollback is the same cheap rename operation in reverse. This is why a failed transaction recovers to a byte-exact prior state.
Those set-aside files also outlive a successful commit for a while — retained so that an undo of a recent transaction is fast and needs no network. They are cleaned up automatically as they age out.
What an interruption leaves behind #
If a transaction is interrupted mid-stage or mid-commit, you may find files with these names near where a package was being installed:
| Name | Is |
|---|---|
<name>.peipkg-staged-<id> | An incoming file that had been staged but not yet moved into place. |
<name>.peipkg-backup-<id> | A file that had been renamed aside to make room — the backup. |
These names are deliberate. They have no leading dot, so they are visible rather than hidden — you are meant to find them and understand them. The <id> is the transaction number; look it up with peipkg history to see which operation left it.
You do not clean these up by hand. peipkg knows about them and resolves them itself — see recover, next.
Recovering an interrupted transaction #
peipkg recover
When a transaction is interrupted before it commits, peipkg records it as pending. The next time peipkg starts any transaction it checks for a pending one first and rolls it back automatically before doing anything else — so in normal use recovery just happens, and you never see it.
peipkg recover runs that step on demand. Use it when a transaction was interrupted and you want the system put right immediately, without waiting for the next install or upgrade.
$ peipkg recover
recovered: the interrupted transaction was rolled back
Recovery only ever rolls back. A transaction that was interrupted after its commit instant is already complete — there is nothing pending and nothing to recover. Recovery deals exclusively with the "before the instant" case, and it always resolves it the same way: back to the prior state.
If there is no pending transaction, recover says so and exits cleanly.
Across more than one root #
When an operation spans more than one named root, those roots commit as a unit under a single cross-root transaction, and undo reverses all participating roots together. recover reconciles pending cross-root transactions across every reachable root: a torn commit is rolled back, or — if it had already passed the commit instant — rolled forward to completion. See Named roots for how multiple roots come about.
The transaction log #
peipkg history
history prints the transactions peipkg has carried out, most recent first — each with an id, a timestamp, its state (committed, rolled-back, or pending), and a short summary.
$ peipkg history
49 2026-05-19T14:02:10Z committed upgrade nginx, zlib
48 2026-05-19T09:31:55Z committed install nginx
47 2026-05-18T22:14:03Z rolled-back install brokenpkg
| Option | Effect |
|---|---|
-n N | Show at most N transactions. -n 0 shows all of them. The default is 20. |
--json | Emit JSON. |
The history is what undo reads to find the most recent transaction, and what ties a stray *.peipkg-backup-<id> file back to the operation that created it.
Configuration files on upgrade #
Upgrading a package raises a question for any configuration file under /etc/ that the package owns: the new version ships a new default, but you may have edited the old one. peipkg decides per file, by comparing the file on disk against the hash it recorded at install:
-
Unchanged since install — peipkg replaces it with the new default. You wanted the package's settings, and you get the current ones.
-
Edited since install — peipkg keeps your file untouched and writes the new default beside it as
<name>.peipkg-new. The upgrade report notes it:peipkg: warning: /etc/nginx/nginx.conf has been modified since install — keeping it; the new default was written to /etc/nginx/nginx.conf.peipkg-new
Your edits are never silently discarded, and the new defaults are never silently lost — you are simply told the two diverged and left to merge them when you choose.
Side effects run after commit #
A few packages need a system-wide step after their files are in place — refreshing the shared-library cache, the kernel-module map, or the manual-page index. peipkg runs those steps after the commit instant, once per transaction.
Because they run after the operation is already complete and durable, a side-effect step that fails is reported as a warning, not a failure. The transaction stands; the step is one that corrects itself the next time it runs. An install is never rolled back over a stale cache.
Why this shape #
The pay-off of the three-phase model is concrete:
- An install interrupted by a crash or a power cut never leaves a broken package — it leaves either the old state or the new one.
- The plan you approve under
--dry-runis the plan that executes — resolution is read-only and deterministic. - Queries and dry-runs never wait on an in-flight transaction, because only staging and commit take the lock.
- Every transaction is reversible, by
undofor a recent one ordowngradefor a specific package.
The model rests on one principle: all the fallible work — downloading, verifying, staging — happens before the commit instant, so the commit itself is the only point at which the system changes state.
Where to go next #
For the commands that reverse a committed transaction, read Keeping a system current.
For how a transaction that spans several named roots commits and recovers as a unit, read Named roots.
For the events every transaction emits, read Auditing.
Dependency resolution
Peios / Using Peios / Package management
When you ask peipkg to install nginx, the request does not yet describe the full work. nginx needs other packages; those need others; some version of each has to be chosen; everything has to be installed in an order where a package's dependencies come before it. Turning the short request into that concrete, ordered plan is resolution, and it is the first thing every change command does.
Resolution is a pure calculation #
peipkg resolves from metadata alone — the package descriptions in the cached repository indexes and in installed packages' manifests. It does not download any .peipkg files to work out a plan. Downloading happens later, only once a plan is approved.
That makes resolution a pure calculation over three inputs — your request, the set of installed packages, and the set of available packages — and gives it a property worth relying on: it is deterministic. The same inputs always produce the same plan. The plan you inspect with --dry-run is the plan that will execute; nothing is re-decided between previewing and applying.
If a request cannot be satisfied — a dependency that no repository can provide, two requirements that contradict — resolution rejects it, with an explanation, before anything is fetched or changed.
The relationships between packages #
A package's manifest declares how it relates to others. Four relationships drive resolution.
Dependencies. A package can require other packages, each by a version range. peipkg pulls every dependency into the plan, and their dependencies in turn, until the request is closed.
Conflicts. A package can declare that it cannot coexist with another. If a plan would put two conflicting packages on the system at once, resolution rejects it.
Provides. Several packages can advertise the same capability — a virtual name that is not itself a package. A dependency written against that name is satisfied by any package that provides it. This is how "needs a mail transport agent" can be met by whichever one you actually install.
You can ask for a virtual name directly, too: peipkg install coreutils works even when nothing is named coreutils, and installs whichever package provides it. Because you asked for one name and got a package with another, peipkg says which one it chose. Upgrades and removals are the exception — those name a package you already have, so they match by name only and never substitute one package for another.
Replaces. A package can declare that it supersedes another — the usual case being a rename, or a merge of two packages into one. Installing a package that replaces another causes the replaced package to be removed as part of the same plan.
provides has a stronger cousin: a claim, a single shared name that exactly one package may hold at a time. Where any number of packages can advertise the same provides name at once, a claim has one holder — see Claims.
A dependency can also be routed into a different root, written Depends: foo IN <root>, so that a whole root can be composed through the dependency graph — see Named roots.
Choosing a version #
When more than one version of a package could satisfy the plan, peipkg chooses by a fixed rule:
- Prefer the highest version that satisfies every constraint on that package.
- When two repositories offer the same version, prefer the one with the stronger (lower-numbered) priority — see Repositories and trust.
When the candidates are competing to fill a virtual name rather than being versions of one package, "highest version" means the version each one advertises for that name, not its own. Two packages providing coreutils are unrelated software numbered on unrelated scales, so peipkg compares what each offers the role — otherwise the role would go to whichever project numbers its releases higher. If everything above still ties, package name order settles it, so the answer never depends on the order packages happened to be read in.
For an upgrade, the installed version anchors the search: peipkg looks for something newer and stays put if there is nothing newer to move to.
Optional dependencies are never chosen automatically. A package can suggest companions that are useful but not required; peipkg surfaces those as suggestions and leaves the decision to you. A plan only ever contains what is genuinely needed plus what you asked for.
The plan #
The output of resolution is the ordered list of operations you see at the proceed? prompt — installs, upgrades, downgrades, and removals, sequenced so every package's dependencies are in place before it.
A removal gets one extra step. peipkg first computes the reverse-dependency closure — everything that would be left needing a package you are removing — and either refuses the plan or, with --cascade, widens it to include them. That logic is covered in Installing and removing packages.
Resolution is also bounded. A dependency graph cannot be crafted to make peipkg search forever; the resolver works under a hard cap and gives up cleanly rather than hanging if a graph is pathological.
Elevated authorisation #
Most of a plan is routine, and the single proceed? prompt covers it. A plan can also contain an action that a routine yes should not cover — one you must review and approve individually, on its own.
peipkg detects three such actions and, for each one in a plan, asks a separate question:
this operation requires elevated authorisation:
nginx would move backward from 1.27.5 to 1.27.4
authorise this specific action? [y/N]
The three actions that trigger it:
| Action | Why it is elevated |
|---|---|
| A downgrade | Moving a package backward can reintroduce a problem a newer version fixed. It should be a conscious choice, never a side effect of some larger plan. |
A foreign replaces | A package from a lower-priority repository using replaces to displace a package you installed from a higher-priority one. Left unguarded, a low-trust repository could quietly take over a package you trusted a better source for. |
A low-trust provides | A package from a low-trust repository advertising a virtual capability in a way that shadows a package from a more-trusted source — satisfying a dependency with its own build instead of the one you would expect. |
Two things make this prompt different from the routine one:
--yesdoes not satisfy it.--yesskips the routineproceed?prompt; it has no effect on an elevated-authorisation question.- It fails closed. With no terminal to answer on — an unattended script, a pipeline — there is no answer, so the action is not authorised and the operation is cancelled. An elevated action is never authorised by default.
Each elevated action is presented and authorised on its own; approving one does not approve the next. And the authorising act is itself written to the audit stream — the record shows not just what was done, but that it was specifically authorised and what was authorised.
If an elevated action is not one you want, the plan as a whole is the thing to reconsider: where the package is coming from, whether the repository priorities are right, whether the downgrade is really what you meant.
Where to go next #
For the flow that presents and applies the plans resolution produces, read Installing and removing packages.
For the single-holder extension of provides, read Claims.
For the repository priorities and trust states resolution consults, read Repositories and trust.
Claims
Peios / Using Peios / Package management
Some filesystem names can be provided by more than one package. Two registry daemons — loregd and an alternative — both install a working binary, but only one of them can own /usr/bin/registryd. peipkg calls that shared name a claim, and it owns the machinery that decides which package holds it.
Three words are used precisely on this page:
- A claim is a single shared filesystem name that many installed packages may be able to answer, but exactly one may hold at a time.
- An eligible provider is an installed package able to answer a given claim.
- The holder is the one package that currently answers it.
(This is unrelated to token claims — the security-token attribute concept in the identity docs. The same word names a different subsystem.)
What a claim is #
A claim materialises as a symlink on disk. The link lives at the claim path — the shared name, such as /usr/bin/registryd — and points at a file inside the holder's payload, the target, such as /usr/sbin/loregd. Ask the system for the shared name and you reach whichever provider currently holds it.
/usr/bin/registryd -> /usr/sbin/loregd
The claim symlink is owned and managed by peipkg. It is not shipped inside any package's payload; no provider installs it, and removing a provider does not remove it out from under peipkg. peipkg creates, repoints, and tears down the link as part of the transactions that install, remove, grant, and revoke.
The two sides of a claim are declared in package manifests:
- A provider package declares the target file it offers for a claim — the real binary the shared name would resolve to.
- A consumer package references the claim path where it expects the shared name to appear.
peipkg joins the two. A provider can offer a target for a claim, a consumer can depend on the name being present, and peipkg mediates between them, keeping exactly one provider wired to the shared path at any moment.
Claims and provides/replaces #
A claim is the "exactly one owner of a shared name" extension of the provides and replaces relationships covered in Dependency resolution. There, several packages can advertise the same virtual name and any one of them satisfies a dependency written against it — that mechanism answers "is something here that provides this?". A claim goes one step further: it answers "which single provider owns this concrete filesystem name right now?", and enforces that the answer is never more than one. Provides establishes that a name can be answered; a claim decides which package answers it on disk.
Auto-claim on install #
Installing an eligible provider auto-claims every claim it provides that is currently unheld. If no package yet holds registryd, installing loregd makes loregd the holder and materialises the link as part of the same transaction — you get a working shared name without a second step.
The rule is strictly unheld-only. Auto-claim never overrides a claim that is already held by another package. Install a second registry daemon while loregd holds registryd and the newcomer is installed as an eligible provider but takes nothing; loregd stays the holder. Reassigning a held claim is always a deliberate act — see the claim command below, or the install flags that force it.
Install flags #
peipkg install accepts flags that override the default auto-claim behaviour for the packages in that install:
| Option | Effect |
|---|---|
--no-claim | Claim nothing. Install the provider(s) without taking any claim, even ones that are currently unheld. |
--claim <names> | Force-claim the named claims (comma-separated), overriding the current holder of each. |
--claim-all | Force-claim every claim the installed packages provide, overriding incumbents. |
Two combinations are hard errors:
--claim-alltogether with--claim.--claim-alltogether with--no-claim.
--claim and --claim-all are how you take a claim that is already held during an install; without them, an install only ever fills claims that are empty.
The claim command #
peipkg claim inspects a claim and reassigns its holder. It has three forms.
peipkg claim <claim>
peipkg claim <claim> grant <package>
peipkg claim <claim> revoke
Report status. With just a claim name, peipkg prints the current state of the claim: the current holder, the materialised links shown as path -> target, and the installed eligible providers.
$ peipkg claim registryd
holder: loregd
links:
/usr/bin/registryd -> /usr/sbin/loregd
eligible providers:
loregd
altregd
Grant. grant <package> makes an installed eligible provider the holder. peipkg atomically repoints all of the claim's links to that package's targets — every path the claim covers moves together, or none does. The named package must be an installed eligible provider for the claim.
$ peipkg claim registryd grant altregd
Revoke. revoke removes the grant. The claim becomes unheld and its links are torn down. peipkg does not automatically promote another provider — a revoked claim has no holder until you grant one.
| Option | Effect |
|---|---|
--yes, -y | Skip the confirmation prompt. Applies to grant and revoke. |
grant and revoke each run as a standalone transaction. Like every other peipkg change they appear in history, can be reversed with undo, and are fully auditable — each emits a claim event to the audit stream. A reassignment is never a silent, unrecorded edit to a symlink; it is a first-class, reversible operation.
What happens on uninstall #
Uninstalling the current holder auto-withdraws the claim. The holder is going away, so peipkg tears down its links and the claim becomes unheld as part of the removal.
peipkg does not auto-promote another provider in its place — an automatic promotion would be the kind of silent reassignment claims exist to prevent. Instead it surfaces the remaining eligible providers and hands you a ready-to-run command to reassign the claim yourself:
$ peipkg remove loregd
...
claim 'registryd' is now unheld. eligible providers: altregd
to reassign it, run:
peipkg claim registryd grant altregd
If the holder was the only eligible provider, the claim is left unheld with nothing to promote, and any consumer relying on the shared name will find it absent until a new provider is installed.
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — the status was reported, or the grant or revoke was applied (a declined prompt is also 0: nothing failed). |
1 | The operation failed — the claim has no eligible provider, the named package is not an eligible provider, or a named package or claim is not installed. |
2 | A usage error — an unknown subcommand (the only subcommands accepted after the claim name are grant and revoke) or a malformed option. |
Inspecting and verifying
Peios / Using Peios / Package management
The commands on this page change nothing. They report what is installed, look up a package in the repositories, check that installed files are still intact, and tidy the metadata cache. Use them to see what is on a system and to check that it is still as it should be.
Listing what is installed #
peipkg list
list prints every installed package — name, version, and architecture.
$ peipkg list
nginx 1.27.5 x86_64
pcre2 10.44 x86_64
zlib 1.3.2 x86_64
With --json it emits the same set as JSON, with each package's origin repository included.
Showing one package's details #
peipkg info <package>
info prints the full record of one installed package: its version and architecture, the repository it came from (or (local file) if it was installed from a .peipkg directly), when it was installed, and — from its manifest — its description, license, and homepage.
$ peipkg info nginx
name: nginx
version: 1.27.5
architecture: x86_64
origin: official
installed: 2026-05-19T14:02:10Z
description: HTTP and reverse proxy server
license: BSD-2-Clause
homepage: https://nginx.org
Listing the files a package owns #
peipkg files <package>
files prints every filesystem object the package owns — the files, directories, and symlinks that were placed by its install and are tracked against it. This is the record peipkg uses to remove the package cleanly and to verify it.
$ peipkg files zlib
/usr/lib/libz.so.1
/usr/lib/libz.so.1.3.2
/usr/include/zlib.h
Finding which package owns a path #
peipkg owns <path>
owns is the reverse lookup: given a path, it reports which installed package placed it.
$ peipkg owns /usr/lib/libz.so.1
zlib
If no installed package owns the path, owns says so and exits non-zero. A path that nothing owns is either not part of any package or was created outside peipkg.
Searching the repositories #
peipkg search <term>
search looks through the configured repositories' active indexes for packages whose name or description contains the term, case-insensitively. It searches what is available, not what is installed — it is how you find a package before installing it.
$ peipkg search proxy
nginx 1.27.5 [official] HTTP and reverse proxy server
haproxy 2.9.7 [official] Reliable, high-performance TCP/HTTP load balancer
search reads the cached repository metadata, so run peipkg refresh first if you want it to reflect the latest catalog. A repository with no usable cached metadata is skipped with a warning rather than failing the search. --json emits the matches as JSON.
Verifying installed files #
peipkg verify [package]...
verify checks that what is on disk still matches what was recorded when each package was installed. For every file a package owns it checks:
- a regular file — that it is present and its content still hashes to the recorded value;
- a symlink — that it is present and still points where it was recorded to point;
- a directory — that it is still present and still a directory.
With no arguments, verify checks every installed package. With package names, it checks only those.
$ peipkg verify nginx
nginx: /etc/nginx/nginx.conf has been modified since install
verify: 1 problem(s) found
A reported file is not necessarily a fault. A configuration file you edited on purpose will show up — verify is telling you the file diverged from the package's version. verify reports; it does not judge intent and it does not change anything.
If every checked file is intact, verify says so and exits 0. If anything diverged, it lists each problem and exits non-zero — which makes it usable as a check in a monitoring script.
Cleaning the metadata cache #
peipkg clean
peipkg keeps a verified copy of each repository's metadata in a local cache. When you remove a repository, its cached metadata is no longer needed. clean deletes only those orphaned cache files — the ones belonging to repositories that are no longer configured.
$ peipkg clean
removed 2 orphaned cache file(s)
clean never touches the metadata of a repository that is still configured, so it cannot leave you needing a refresh. It is pure housekeeping, and it is optional; run it whenever you like.
Exit status #
| Code | Meaning |
|---|---|
0 | The command succeeded. For verify, every checked file was intact. |
1 | The command failed — a named package is not installed, a path is owned by nothing, or, for verify, at least one file had diverged. |
2 | A usage error — an unknown command or a malformed option. |
Named roots
Peios / Using Peios / Package management
Every peipkg command runs against a root — a subtree it treats as a complete Peios installation. By default that root is /, the running system. The --root DIR global option points peipkg at a different one: an image mounted at DIR, an offline system under maintenance, a tree being built.
A single bare path is enough when there is exactly one alternate tree and you always spell it out in full. It stops being enough when a system is several cooperating trees that are built and kept current together. Peios's dynamic initramfs is the case that motivates this page: a real system root at / and an initramfs image at boot/initramfs, both assembled from ordinary packages, both upgraded by the same package manager. Passing --root boot/initramfs on every command that touches the image — and remembering that path everywhere — is the friction named roots remove.
So --root generalises. A root can register other roots under it by name, reference them by that name instead of a path, compose those names by dotting, install a dependency into a different root than its depender, and upgrade every nested root in one command. The mechanism is deliberately general — the initramfs is one arrangement it expresses, not a special case wired into the tool.
What a named root is #
A named root is a registered name for a subtree, recorded in the registry of the root it lives under. The name initramfs bound to the path boot/initramfs is a named root of /: it says "within this root, the name initramfs means the subtree at boot/initramfs, and that subtree is itself a root."
Two ideas follow from that.
Every root has its own registry. The bindings a root knows about are its own. / may register initramfs; what initramfs registers is recorded inside initramfs, not in /. Paths in a registry are stored relative to the root that owns them, so a registry travels with its tree — an image built in one place and mounted in another resolves the same way.
References are resolved from the current root. The current root is whatever --root points at (or / when it is omitted). A reference is resolved by starting there and walking its registry.
Referencing by name #
Once initramfs is registered under /, these two commands target the same tree:
$ peipkg --root boot/initramfs install live-boot
$ peipkg --root initramfs install live-boot
The second names the root instead of spelling out its path. The name is looked up in the current root's registry and resolved to the bound path.
Dotted composition #
Names compose by dotting. initramfs.subroot resolves left to right: from the current root, walk into initramfs's registry, then from there into subroot. Each dot is one more step outward through one more registry.
$ peipkg --root initramfs.subroot install ...
Every dotted segment must match the grammar [a-z0-9][a-z0-9_-]* — a lowercase-alphanumeric lead followed by lowercase alphanumerics, hyphens, and underscores. A segment that names no registered root is a hard error, not a silent miss. A registry arrangement that loops back on itself is detected and reported as a resolution cycle rather than followed forever.
Path or name: how --root decides #
--root accepts either form and tells them apart by a single rule:
- Any value containing
/is a filesystem path and is used exactly as before —--root boot/initramfs,--root /mnt/image,--root ./tree. This is unchanged behaviour. - A bare dotted identifier is a named reference —
--root initramfs,--root initramfs.subroot— resolved through the registries as above.
The / is the discriminator. If you mean a literal directory, include a slash (even ./name); if you mean a registered name, use the bare identifier.
The root command #
root manages named roots. Every subcommand operates on the current root's registry — the registry of whatever --root points at. To manage the initramfs's own named roots, aim --root at the initramfs first.
peipkg root add <name> <path>
peipkg root remove <name> [--purge]
peipkg root list [--json] [--tree]
peipkg root show <reference> [--json]
root with no subcommand is a usage error — a subcommand is required (add, remove, list, show). An unknown subcommand is a usage error too.
root add #
peipkg root add <name> <path>
Register a named root in the current root's registry. <name> is a single root segment — it must match [a-z0-9][a-z0-9_-]* and must not contain a dot; you register one name at a time, not a dotted chain. <path> is stored relative to the current root. There are no flags.
$ peipkg root add initramfs boot/initramfs
root remove #
peipkg root remove <name> [--purge]
Unregister a named root. By default this removes only the registry entry — the files on disk are left in place, so the subtree survives and can be re-registered. --purge additionally deletes the named root's filesystem tree.
| Option | Effect |
|---|---|
--purge | Also delete the named root's filesystem tree, not just its registry entry. |
root list #
peipkg root list [--json] [--tree]
Print the current root's registry. Plain output lists the names registered directly under the current root.
| Option | Effect |
|---|---|
--json | Emit JSON — for each entry its name, its stored path, its resolved_path, its status, and its children. |
--tree | Recurse into each child's registry and print the whole nested arrangement. The recursion is cycle-guarded, so a registry that loops is reported rather than followed endlessly. |
root show #
peipkg root show <reference> [--json]
Resolve one reference and report on it. <reference> is either a dotted named-root reference or a path — the same path-or-name rule as --root. show reports the reference's resolved path, its status, and the number of packages installed in it.
The status is one of two words:
present— the resolved path exists on disk and is a real root.dangling— the reference resolves through the registries, but the path it names is not there. The binding exists; the tree it points at does not.
| Option | Effect |
|---|---|
--json | Emit the same report as JSON. |
present and dangling are the vocabulary the rest of this page uses for "the binding points at something real" versus "the binding is valid but its target is missing".
Automatic target selection with default_root #
A package's manifest can declare the root a top-level install of it should land in — its default_root. When you install such a package without saying where, peipkg reads that field and re-roots the install onto it for you.
$ peipkg install live-boot
If live-boot's manifest sets default_root: initramfs, this installs into the initramfs root even though no --root was given. The package's manifest records where it belongs, so you do not have to specify it.
Two rules keep this predictable:
- It applies only when no explicit
--rootwas given. Passing--rootwith any value, including a named one, states the target outright and suppressesdefault_rootre-rooting entirely. An explicit root always wins. - The packages in one command must agree. If the packages named in a single install declare two or more distinct default roots, peipkg cannot pick one and does not guess — the command is rejected as an error. Split it into one command per target.
default_root only chooses a target; it does not create or resolve anything the registry could not already reach. The chosen root is resolved through the registries like any other named reference.
Cascading upgrade #
By default peipkg upgrade reconciles the current root and every named root nested under it, recursively — the whole tree of roots, in one command. Run it against / on a dynamic-initramfs system and both / and boot/initramfs are brought current together.
Each root is upgraded as an independent transaction that continues on error: peipkg walks the tree, upgrades each root on its own, and prints a per-root summary. One root's failure does not abort the others — a broken upgrade in initramfs leaves /'s upgrade to complete and report normally, and vice versa. You get one summary per root and a clear picture of which succeeded.
Each of those per-root upgrades is an ordinary transaction with the full atomic guarantee of Transactions and recovery; the cascade runs several of them, it does not weaken any one.
| Option | Effect |
|---|---|
--no-recurse | Confine the upgrade to the current root only. The cascade into nested roots is disabled. |
Use --no-recurse when you deliberately want to move just one root — for example to upgrade the initramfs on its own without touching /, by pointing --root at it and disabling the cascade.
Cross-root dependencies #
A dependency can be routed into a different root than the package that declares it. A manifest writes this with IN:
Depends: foo IN initramfs
This says "I depend on foo, and foo belongs in the initramfs root" — the root name is resolved through the registries from the depending package's root. It is what lets a whole root be composed out of ordinary packages through the dependency graph: a single package installed into / can pull the pieces of the initramfs into initramfs as its dependencies, so the image is built by the same resolver and the same packages as everything else. An image builder starting from nothing can assemble an entire multi-root arrangement like this offline, from a declarative manifest, with the separate peipkg-compose binary — see Composing a root.
install is the only verb that crosses roots. When resolution produces a plan whose changes land in more than one root, that plan is committed as a two-phase commit across the participating roots, under a single generated cross-root transaction id that ties the per-root pieces together. Either the change lands in every participating root or in none — the multi-root plan is atomic as a whole, not merely per root.
A plan that reaches beyond the current root announces it before you approve:
- a
note: this also changes other roots: ...line naming the other roots the plan touches, and - a per-line
-> <root>tag on each plan entry that lands outside the current root, so you can see at a glance which change goes where.
You are never taken across a root boundary silently; a cross-root plan always shows its routing.
Cross-root undo and recovery #
Because a cross-root install commits as a unit, it reverses as a unit.
undo on a cross-root transaction reverses all of its participating roots together. There is no way to walk back only the / half of a change that also touched initramfs — the cross-root transaction id binds the pieces, and undo reverses the whole. See Keeping a system current for undo in general.
recover understands cross-root transactions too. It reconciles them across every reachable root, walking the registry outward from the current root to find each participant. A cross-root commit that was torn by an interruption is resolved the same way a single-root one is — with one addition that follows from the two-phase commit: a torn commit is rolled back if it was interrupted before its point of no return, or rolled forward to completion if it had already passed that point. Either way every participating root ends in the same, consistent state.
This is the single-root recovery model of Transactions and recovery extended across roots: the same commit-instant guarantee, now applied to a commit that spans several trees at once. Read that page for the underlying transaction model this builds on.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Failure — an unregistered name, a dangling or otherwise invalid reference, a resolution cycle, a per-root transaction that failed, or a cross-root commit failure. |
2 | Usage error — a missing or unknown root subcommand, a malformed option, or the wrong number of positional arguments. |
Composing a root
Peios / Using Peios / Package management
peipkg mutates a running system in place: every change is an atomic, reversible transaction against a live root. peipkg-compose is its counterpart on the assembly side. Instead of changing a system that already exists, it builds a fresh, fully package-owned root directory from nothing — offline, deterministically, and verifiably, with no live system involved anywhere in the process.
peipkg-compose is a separate binary, not a peipkg subcommand. You run peipkg-compose, and its two verbs (lock and build) are the whole of its command surface.
Given a declarative TOML manifest that names a package set and the repositories to draw it from, compose produces a populated root directory: package payloads laid out at their installed paths, a seeded peipkg state database at var/lib/peipkg/db.sqlite, and each repository written out as lcl/conf/peipkg/<name>.repo. The result is a legitimate peipkg-managed system — once booted it can manage itself, and repository trust bootstraps on its first refresh.
It is meant for image builders — an outer image-assembly tool, or a person standing one up by hand. Because it only ever writes inside the output directory and never touches the host /, it is safe to run on a non-Peios host, and even on a host of a different architecture from the image being built.
The mental model #
Compose runs in two stages, and the two verbs map onto them.
Stage 1 — resolve. Read the manifest, resolve the requested package set against the declared repositories into a complete transitive closure, verify each repository's trust chain, and pin the result. This stage needs repository metadata; its output is the lock file.
Stage 2 — assemble. Fetch the pinned .peipkg payloads, verify them against the lock's hashes, and lay out the root directory. This stage needs only package bytes, not metadata, and can run fully air-gapped once a lock exists.
lock does Stage 1 alone. build does Stage 2, running Stage 1 first when it needs to. Everything build-stamped in the output is derived from the manifest's source_date, so given the same manifest, lock, and packages, the output is reproducible.
peipkg-compose lock #
peipkg-compose lock <manifest> [-o <lock>]
Resolve the manifest against its repositories, verify the trust chain, and write the lock file. lock builds nothing — it produces only the lock.
The <manifest> positional is required. Flag order is not significant: the manifest may appear before or after -o.
| Option | Effect |
|---|---|
-o <lock> | Output lock path. Defaults to the manifest path with a trailing .toml replaced by .lock.toml — so image.toml locks to image.lock.toml. Only the single-dash -o form exists here; there is no --out for lock. |
peipkg-compose build #
peipkg-compose build <manifest> --out <dir> [--locked | --update]
[--dangerously-bypass-path-restrictions]
Assemble the root directory. The <manifest> positional is required; flag order is not significant.
--out <dir> is required — omitting it is a usage error. The directory named by --out must not already exist: compose creates it, and refuses to build into or over an existing tree.
| Option | Effect |
|---|---|
--out <dir> | The output root directory. Required. Must not already exist. |
--locked | Require an existing lock and do not resolve. Fails if no lock is present. Fetches only package bytes, taking integrity from the lock's hashes — the air-gap-friendly path. |
--update | Re-resolve from scratch, overwrite the lock, then build. |
--dangerously-bypass-path-restrictions | Permit packages that declare special_system_package to compose payloads outside the payload layout rules. An image built from a package set including the base filesystem needs this; peiso passes it through from bypass_path_restrictions in the image's build spec, so the grant stays a visible decision of the image rather than something the composer assumes. |
--locked and --update are mutually exclusive.
With neither flag, build follows Cargo's build / Cargo.lock behaviour:
- if the sibling lock is missing, it resolves, writes the lock, and builds;
- if the sibling lock is present, it verifies the lock still matches the manifest and then builds.
Use --locked for reproducible or air-gapped rebuilds where no metadata resolution should happen at all, and --update when you want to pick up newly-available versions and refresh the lock in the same run.
The manifest #
The manifest is a TOML file describing the package set to assemble and the repositories to draw it from. Any filename works — it is passed positionally — and its sibling lock is <stem>.lock.toml. The manifest and the lock are tool-local build artifacts; they are not an on-wire Peios format.
The parser is strict: an unknown key anywhere in the manifest is a hard error, not a warning. A mistyped field name fails the build rather than being silently ignored.
= 1
= "x86_64"
= "2026-07-01T00:00:00Z"
= ["./bootstrap/*.peipkg"]
[[]]
= "core"
= "https://repo.example.org/core"
= 10
= "required"
= ["a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"]
[[]]
= "initramfs"
= "boot/initramfs"
[[]]
= "base-system"
[[]]
= "nginx"
= ">=1.27, <1.28"
= "core"
[[]]
= "live-boot"
= "initramfs"
Top-level fields #
| Field | Type | Required | Meaning |
|---|---|---|---|
schema | int | yes | Manifest schema version. Must be 1. |
arch | string | yes | Target architecture; becomes the image's primary architecture. |
source_date | string | yes | An RFC 3339 timestamp. Fixes every build-stamped time in the output, for reproducibility — the manifest's SOURCE_DATE_EPOCH. |
local_packages | array of strings | no | Paths or globs of local .peipkg files. A bootstrap path for packages that live in no repository. |
[[repository]] | array of tables | no | Package sources. Also written into the image as .repo files. |
[[root]] | array of tables | no | Named roots, for a multi-root image. |
[[package]] | array of tables | yes | The packages to install. Must be present and non-empty — an empty package set is an error. |
[[repository]] #
A repository table has the same shape as a .repo file. These entries drive metadata fetch and trust verification during the build, and are written verbatim to lcl/conf/peipkg/<name>.repo, so the booted system inherits the same repositories and the same trust anchors.
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | Repository name; also the .repo filename stem. |
base_url | string | yes | Where the repository's index and packages are served from. |
priority | int | no | Selection priority. Default 50. |
signature_policy | string | no | required (default) or optional. |
trust_anchors | array of strings | no | Hex key fingerprints trusted to sign this repository's index. |
allow_insecure_transport | bool | no | Permit non-TLS transport. Default false. |
min_index_version | int | no | Reject an index older than this version. |
[[package]] #
Each package table names one package to install. Package identity is (root, name) — the same package may be requested in more than one root, but a duplicate (root, name) pair is an error.
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | The package to install. |
version | string | no | A version constraint — a range like ">=1.27, <1.28" or an exact pin like "9.9.1". Omitted, or "*", means any version and the resolver picks the newest. |
repository | string | no | Pin the source repository. Must name a declared [[repository]]. |
root | string | no | Target a declared [[root]] — like peipkg install --root. Empty means the package's own default_root if it has one, otherwise the anchor root. |
There is no default_root key in the manifest. default_root is a property each package carries from its repository index, and compose honours it automatically whenever a package's root is unset — just as the live consumer does.
[[root]] #
Each root table registers a named root in the built image.
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | A single named-root segment matching [a-z0-9][a-z0-9_-]* — no dots. |
path | string | yes | Location relative to the output root. May not be absolute, may not escape with .., and may not be .. |
The lock file #
The lock is generated by lock, or written implicitly by a default or --update build. Its header reads generated by peipkg-compose — do not hand-edit; it is never edited by hand.
For the full transitive package closure, the lock pins each package's exact version, architecture, source repository (or local for a local .peipkg), the fetch URL, and the content SHA-256 hash of the .peipkg. It records each package's target root when that is not the anchor, along with the manifest's arch and source_date and a digest of the manifest.
A build refuses a lock that no longer matches its manifest. A mismatched arch, a mismatched source_date, or a changed manifest digest all reject the lock. On the default path the error tells you to re-run with --update to refresh it.
The two explicit flags pin down the two ends of this behaviour:
build --lockedrequires the lock to already exist and does no resolution at all — the reproducible, air-gapped rebuild.build --updatere-resolves from scratch and rewrites the lock.
An unattended compose run cannot authorise the elevated actions that the live consumer would stop and prompt for. Rather than fail the build, compose surfaces those as stderr warnings. See Elevated authorisation for what those actions are.
Named roots and claims in a composed image #
A composed image can be multi-root, and both mechanisms settle more simply here than on a live system — the detail lives on their own pages; this is only how compose relates to them.
Named roots. Each [[root]] becomes a named-root registry entry in the built image's database, so the booted system resolves --root <name> and cascades upgrades into those roots. Every root is a subdirectory nested under the single --out tree, and the whole multi-root image is assembled as one tree — compose needs none of the consumer's cross-root transaction machinery. The repositories and the named-root registry live at the anchor root.
Claims. A fresh build is the simplest case of claim reconciliation: there are no incumbents and exactly one known, closed package set, so every provided claim is auto-claimed. When two packages provide the same claim, the holder is chosen deterministically — the lexicographically smallest package name, matching peipkg install. Claim symlinks are written with targets relative to the link's own directory, so the root stays self-contained and relocatable.
What ends up in the root #
A successful build leaves a directory that is a valid peipkg-managed system:
- every package's payload laid out at its installed paths;
- a seeded peipkg state database at
var/lib/peipkg/db.sqlite, recording the installed set, the named-root registry, and the settled claims; - each declared repository written to
lcl/conf/peipkg/<name>.repo, so the booted system inherits its repositories and their trust anchors.
The build is atomic as a whole tree. Because --out must not already exist, compose assembles into a sibling staging directory on the same filesystem and, on success, renames it into place atomically. A failed or interrupted run leaves the staging directory behind for inspection rather than a half-populated --out — there is no partial output to clean up or mistake for a finished one.
What compose does not do #
Compose's contract stops at producing a valid peipkg root. It is deliberately narrow.
- Not a full image builder. It produces only a directory tree — no bootloader, kernel, initramfs contents, registry seed, or peinit wiring. Those belong to whatever assembles the image around it.
- Not a live-system tool. It never touches the host
/. There is no three-phase transaction, no commit boundary, no rollback journal, and no crash-recovery — the output is disposable, and its only atomicity is the single whole-tree rename above. - Not the producer side. It does not build or sign
.peipkgfiles and it does not serve repositories — those are the separatepeipkg-build,peipkg-repo, andpeipkg-managertools. Compose consumes ordinary peipkg packages and repositories unchanged, exactly as PSPU §5 defines them. - No side effects.
ldconfig,depmod, andman-dbare not run; no KACS security descriptors are applied; no audit events are emitted. A booted system runs those itself.
No environment variables are read, and there is no --version flag.
Exit status #
| Code | Meaning |
|---|---|
0 | Success — or the help output (-h, --help, help). |
1 | A compose operation failed — resolution, trust verification, a fetch, a hash mismatch, a stale lock a --locked build could not use, or a file operation. |
2 | A usage error — no arguments, an unknown verb, a missing manifest, a missing --out, extra arguments, --locked with --update, or a bad flag. |
The registry
Peios / Using Peios / Concepts
The registry is the structured configuration store for a Peios system. It is a hierarchy of named keys, each holding values — typed pieces of data. Subsystems read their operational settings from it, services keep their definitions in it, and policy is delivered through it. Where a file holds an opaque stream of bytes, a registry value holds a small, named, typed datum that some part of the system knows how to act on.
It is also kernel-mediated. There is no file under /etc you edit and no daemon socket you talk to directly. Every registry operation — opening a key, reading a value, writing configuration, watching for change — goes through a dedicated set of system calls. Userspace never touches the store underneath; the kernel is always in the path, which is what lets the registry carry the same access control, the same identity model, and the same auditing as every other protected object in Peios.
This page sketches the shape, names the handful of ideas that make the registry its own thing, and points at the pages that cover each one.
The registry in one sentence #
The registry is a tree of keys and typed values, reached only through kernel system calls, where every key is an access-controlled object — and every value you read is the effective result of resolving a stack of layered writes.
Ignore that last clause for now. For almost everything in this section you can picture the registry as a plain hierarchical store: one key per path, one value per name, the value you wrote is the value you read. That simpler picture is correct as far as it goes, and it is the right way to learn the model. The layering underneath — what "effective" really means — is the deepest idea here, and it gets its own page once the rest is solid.
The shape #
Three entities make up the namespace.
| Entity | What it is |
|---|---|
| Hive | A top-level namespace — the first component of every path. Machine\ holds system-wide configuration; Users\<SID>\ holds per-user configuration. In practice a running system exposes just a few. |
| Key | A node in the tree. Keys are containers: they hold child keys (forming the hierarchy) and values (holding data). Every key is a secured object with its own security descriptor. Keys are roughly the registry's directories. |
| Value | A named, typed datum living inside a key — a REG_DWORD number, a REG_SZ string, and so on. A key can hold many values, each with a distinct name. Values are roughly the registry's files. |
flowchart TD
M["Machine\ (hive)"] --> S["System"]
S --> K["KMES (key)"]
K --> V1["BufferCapacity = 4194304 (REG_QWORD)"]
K --> V2["MaxEventSize = 65536 (REG_DWORD)"]
M --> R["Registry"]
U["Users\<SID>\ (hive)"] --> US["per-user keys..."]
Paths are written with backslashes — Machine\System\KMES — and compared case-insensitively while preserving the case you wrote. A handful of well-known roots anchor everything: Machine\ for the machine, Users\<SID>\ per principal, and CurrentUser\ as a convenience alias the kernel rewrites to the caller's own Users\<SID>\.
We will return to one subtree throughout this section as a running example: the KMES event subsystem reads its tuning parameters from values under Machine\System\KMES. It is small, real, and exercises every idea — types, meaning, security, and change notification.
What the registry is not #
The registry borrows a familiar shape, and the familiarity is a trap. Three wrong mental models attach themselves immediately; clearing them is most of understanding what the registry actually is.
It is not a filesystem. It looks like one — hierarchical paths, separators, containers and leaves — but it behaves differently in ways that matter. You reach it through dedicated registry system calls, not the file API. Values are typed data, not byte streams. Access is checked once, against the single key you open, with no traversal check on the keys above it — a process can read Machine\System\Services\Jellyfin without any access to Machine\System\Services. And security is attached per key; there is no such thing as a per-value permission.
It is not a dumping ground of opaque settings. Every key is a first-class secured object and every value is typed, access-controlled, and watchable. Nothing in the registry is unmanaged or hidden — there is no "miscellaneous junk" tier. It is a deliberate, governed surface, not a place things accumulate.
It is not self-describing. The registry stores a value's type tag but never interprets its bytes. It does not know that BufferCapacity must be a power of two, what its default is, or which subsystem reads it. That knowledge — the meaning of a value — lives entirely outside the registry: in the subsystem that owns the value, and, for a human looking it up, in regman, the registry's manual. regman is the man of the registry — give it a path and it tells you a value's type, default, valid range, when a change takes effect, and what the setting is for. The registry cannot describe itself, so its manual is shipped beside it. This separation of storage from meaning is the first idea worth slowing down for, and it has consequences — a write the registry accepts can still be refused by the subsystem that reads it — so it gets its own page.
It is not flat. Underneath the single value you read, the registry keeps a stack of writes, each tagged with a layer, and resolves the winner on every read. That is what makes configuration revert cleanly, role install and uninstall work, and domain Group Policy apply and lift without residue. It is powerful and it is the one idea worth deferring — the Layers page pulls the curtain back once the rest of the model is clear.
One registry, built from two parts #
When precision matters, "the registry" is really two cooperating components: a kernel subsystem that owns the data model, path resolution, access control, change notification, and layer resolution; and a userspace store that persists the data on disk. They talk over a private protocol, and the kernel is the only authority — the store never sees who is asking and never makes a security decision.
For the conceptual model you do not need that split, and these pages mostly treat the registry as one thing. The division becomes useful only when you care about how the registry is backed, swapped, or recovered — so it waits for LCS and sources, under Administration.
Where to start #
If you want the data model — hives, keys, the value types, case rules, and why a value is "typed but opaque" — read Keys, values, and types.
If you want the idea that the registry stores values it does not understand — what reject-or-keep means, and why the value the registry shows and the value a subsystem is actually using can legitimately differ — read Configuration, not storage.
If you are ready for the layered truth under the effective view — precedence, the base layer, how deleting a layer automatically reverts its changes, and how roles and Group Policy ride on it — read Layers.
If you want the security model — why every key carries a security descriptor, the registry-specific access rights, and the rule that security changes are not undone by layer removal — read Access control on keys.
If you want to know how a service reacts to a configuration change instead of polling for it, read Watching for changes.
For the two command-line tools, read regman — the registry's manual, which tells you what a key means — and reg — the scripting interface that reads and writes what a key is. You consult the first to decide what to set, and use the second to set it.
Keys, values, and types
Peios / Using Peios / Concepts
The registry's data model has exactly two kinds of thing in it: keys and values. A key is a node in the tree — a container that holds child keys and values. A value is a leaf — a named, typed piece of data living inside a key. There is no third kind of thing, and the nesting only ever happens through keys. That smallness is deliberate, and it is worth getting precise about before anything else, because every later idea is built on this shape.
Two levels, and only two #
Keys nest; values do not. A key can contain other keys (its subkeys) and it can contain values, but a value cannot contain anything — it is always a leaf. You cannot put a value "inside" another value, and there is no record type between a key and a value.
| Holds | Held by | Role | |
|---|---|---|---|
| Key | Subkeys and values | Its parent key | The container — the registry's directories |
| Value | Nothing (a leaf) | Exactly one key | The data — the registry's files |
This two-level rule is a hard constraint, not a convention. When you want structure, you make subkeys; when you want data, you make values. There is no other axis.
Keys #
A key is identified by its path — Machine\System\KMES names a key three levels down from the Machine\ hive root. Each key holds its children and its values, and each key is a first-class secured object with its own security descriptor; that is what makes "who can read or change this configuration" a per-key decision.
A few naming rules apply to each component of a path (each segment between separators):
- Any UTF-8 is allowed in a key name except backslash, forward slash, and the null byte. Backslash is the separator; forward slash is accepted on input and normalised to a backslash; null is never allowed.
- Components cannot be empty.
Machine\\System(a doubled separator) and a trailing separator are both invalid.
Every key also has one default value — the single value whose name is the empty string. It is the key's "main" value, the one you get when you read the key without naming a value. Most keys also carry additional named values alongside it.
Values #
A value is a (name, type, data) triple stored in a key. A key can hold many values, each with a distinct name, plus the one unnamed default value.
Value names follow almost the same rules as key names, with one difference: backslash and forward slash are allowed in a value name. Value names are not paths — they are not hierarchical — so a separator inside one has no special meaning. Only the null byte is forbidden, and the empty string is reserved for the default value.
The value types #
A value's type is a small tag stored alongside its data. The full set:
| Type | Holds |
|---|---|
REG_DWORD / REG_QWORD | A 32-bit / 64-bit integer. |
REG_DWORD_BIG_ENDIAN | A 32-bit integer in big-endian byte order. |
REG_SZ | A string. |
REG_EXPAND_SZ | A string containing references to be expanded (e.g. environment variables) by whatever reads it. |
REG_MULTI_SZ | An array of strings. |
REG_BINARY | Raw bytes with no further structure. |
REG_LINK | A symbolic-link target. The one type the registry acts on itself — see Registry links. |
REG_NONE | No type / no meaningful data. |
There are also three hardware-resource types carried over for format fidelity. Peios assigns them no meaning — they behave exactly like REG_BINARY and the registry never produces them itself. You will essentially never author one.
Typed, but opaque #
Here is the property the rest of the topic leans on. The registry stores a value's type tag and its raw bytes, and that is all it does with them. It does not check that the bytes match the type. It does not parse a REG_DWORD into a number. It does not know that Machine\System\KMES\BufferCapacity is supposed to be a power of two, what its default is, or which subsystem reads it. The single exception is REG_LINK on a link key, which the kernel follows during path resolution.
So "typed" here means tagged, not validated. The type travels with the value so that a reader knows how to interpret the bytes — but the interpreting, the validating, and the deciding-what-to-do are all somebody else's job.
Two consequences follow immediately, and both get their own treatment:
- The registry can hold a value for a subsystem that is not even running, or a setting nobody has read yet. Storage does not require a reader.
- Whether a value is valid is never the registry's verdict. That belongs to whatever reads it — which is the whole subject of Configuration, not storage.
Names, case, and paths #
Paths use the backslash as their canonical separator. A forward slash is accepted on input and normalised to a backslash, so Machine/System/KMES and Machine\System\KMES name the same key; the stored, canonical form always uses backslashes.
Comparison is case-insensitive but case-preserving. KMES and kmes resolve to the same key, but the registry keeps whatever case you wrote for display. The matching uses a fixed Unicode case-folding rule, so it does not depend on locale.
One sharp edge: there is no Unicode normalisation. Two different byte sequences that render as the same character (a precomposed é versus e plus a combining accent) are two different keys. Case is folded; representation is not.
Volatile keys #
A key can be created volatile, meaning it is stored only in memory and disappears on reboot or when its store unloads. It is the registry's home for runtime-only state that should never survive a restart. Volatility is fixed at creation, and there is one structural rule: the children of a volatile key must themselves be volatile (you cannot place a persistent key under a non-persistent one).
Watch the word, because it collides with a different idea. "Volatile" describes storage persistence — does this key survive a reboot. It says nothing about how quickly a configuration change takes effect — whether editing a setting applies live, on service restart, or only on reboot. That second property belongs to each individual setting and is recorded in regman as its applies field. A value can live in a perfectly persistent (non-volatile) key and still only take effect on reboot. Keep the two apart.
Where to go next #
If you want the idea that the registry stores values it does not understand — what regman documents, what "reject-or-keep" means, and why the value the registry shows can differ from the value a subsystem is using — read Configuration, not storage.
If you want the layered truth beneath the single value you read — precedence, the base layer, and automatic revert — read Layers.
If you want the security model — why every key carries a security descriptor and what the registry-specific access rights are — read Access control on keys.
Configuration, not storage
Peios / Using Peios / Concepts
A registry value is a type tag and a pile of bytes. The registry stores it, returns it on request, and protects it with a security descriptor — but it never asks what the value means. As Keys, values, and types put it, the registry is typed but opaque: it knows the tag, not the meaning. This page is about what follows from that, because it is the single most counterintuitive thing about the registry and the thing that most often trips people up.
Configuration and meaning, in one sentence #
The registry holds configuration; it does not understand it. Meaning lives in the subsystem that reads the value and in regman, the manual that documents it — never in the registry itself.
Every config system you have used before bundles storage and meaning together: the file holds the setting and the program that parses the file knows what the setting means, in one place. The registry splits them on purpose. Storage is the registry's job. Meaning lives in two other places.
Where meaning lives #
In the subsystem that owns the value. The code that reads Machine\System\KMES\BufferCapacity is the thing that knows it must be a power of two, knows the compiled-in default, and knows what to do when it changes. That knowledge is in KMES, not in the store. Ask the registry "is this a sensible buffer capacity?" and it has no answer — it was never told what the value is for.
In regman, for a human. regman is the registry's manual — the man of the registry. Give it a path and it tells you what a key or value actually does:
$ regman Machine\System\KMES BufferCapacity
Type REG_QWORD
Default 4194304 (4 MB)
Valid 65536–268435456 bytes (64 KB–256 MB), power of two
Applies live — ring-buffer swap
Per-CPU ring buffer capacity, in bytes. Must be a power of two; values
that are not are treated as invalid and ignored.
regman is shipped documentation that lives beside the registry, not inside it — because the registry cannot describe itself, the manual has to sit next to it. This is the backbone of configuring a Peios system: before you touch a knob, regman is how you find out what it is, what values are legal, and what changing it will cost you (the Applies line — live, on restart, or on reboot). regman reads its own shipped docs; it does not read the live registry, so it tells you what a setting is, not what it is currently set to.
Reject-or-keep #
Because the store never validates, validation happens where the value is read. This produces the rule that surprises people:
A write the registry accepts is just stored bytes. The subsystem that owns the value validates it on read — and a value it judges invalid is ignored, not applied. The subsystem keeps its last known-good value. It never clamps the bad value to the nearest legal one, and never silently corrects it.
flowchart LR
A["Admin writes a value"] --> B["Registry stores the bytes (always succeeds)"]
B --> C["Owning subsystem reads it"]
C --> D{"Valid?"}
D -->|yes| E["Apply the new value"]
D -->|no| F["Keep last known-good value + log the rejection"]
KMES is the worked example. Write a BufferCapacity that is not a power of two and the write succeeds — the registry has no opinion about powers of two. When KMES reads it, it rejects the value, keeps the capacity it was already using, and emits an event naming the key, the value it rejected, and the value it is still running on.
Written versus used #
That leaves two sources of truth that can legitimately disagree:
- The registry shows what was written. Read the key back and you see the value the admin set — including a rejected one, sitting there looking authoritative.
- The log shows what is actually in use. When a subsystem rejects a value and keeps its previous one, it says so in the event log. That record, not the registry read, is the truth about what the system is running on.
So "what is this subsystem actually configured to right now?" is not always answered by reading the registry. If a change does not seem to have taken effect, the registry will happily show you the value you wrote; the audit and event log is where you find out it was refused and why. This is why observability matters here: the registry is the intent, the log is the reality, and they are allowed to differ.
The registry does this to itself #
The cleanest demonstration is the registry subsystem configuring itself. It reads its own tuning parameters from Machine\System\Registry\ and applies exactly this discipline to them: a valid value is hot-swapped into effect; an invalid one (out of range, wrong type) is ignored, the previous known-good value is retained, and an audit event is emitted naming the key, the rejected value, and the value still in force. Values are never clamped. The subsystem that owns the entire registry treats its own configuration as "stored bytes I must validate before I trust" — see How the registry boots and configures itself.
There is no invalid registry #
Put plainly: a value is never "invalid" at the registry level, because the store has no standard to judge it against. Validity is a verdict, and the reader makes it. The same bytes could be valid to one subsystem and meaningless to another; the registry holds them either way.
So "is this configuration valid?" is not a question you ask the registry. You ask the subsystem that owns the value — or you read the log to see what verdict it already reached.
Why it is built this way #
Briefly, because it is worth knowing the trade rather than dwelling on it: keeping the store meaning-free keeps it small and uniform, lets it hold a value for a subsystem that is not running yet or a key nobody has read, and lets configuration be delivered from outside — a domain Group Policy, say — without the kernel needing a built-in schema for every subsystem on the machine. The price is that the store cannot tell you whether a value is sensible. That price is paid by regman (which documents what should be there) and the logs (which record what the system actually did).
Where to go next #
If you want to see how a subsystem notices a configuration change so it can re-validate and re-apply, read Watching for changes.
If you want the registry's own bootstrap and self-configuration story — the purest example of reject-or-keep — read How the registry boots and configures itself.
If you are ready for the layered model beneath the single value you read, read Layers.
Watching for changes
Peios / Using Peios / Concepts
Configuration changes while the system is running — an administrator edits a value, a role is installed, a policy is applied. A subsystem could poll the registry to notice, but Peios does not make it: the registry can tell a process when something it cares about changes. This is what lets services react to configuration instead of repeatedly re-reading it, and it is how the registry behaves as a live configuration backbone rather than a passive store.
Watching, in one sentence #
A process arms a persistent watch on a key — optionally covering its whole subtree — and is notified whenever the effective state there changes, so it can react to configuration instead of polling for it.
Persistent, not single-shot #
A watch is armed on an open key handle. Once armed, it stays armed until the handle is closed: events keep flowing, with no need to re-arm after each one. This matters because re-arming would open a race — a change slipping through in the gap between one notification and the next registration. There is no such gap here. The handle is also pollable: a process waits on it the same way it waits on any other file descriptor, and reads structured change records off it when they arrive.
A watch can cover just the one key, or the key and its descendants (a subtree watch), so a service can watch an entire configuration area with a single registration.
What a watch reports #
Events describe what changed, by kind:
| Event | Meaning |
|---|---|
| Value set | The effective value at a name changed or appeared. |
| Value deleted | The effective value at a name went away. |
| Subkey created / deleted | A child key became visible / became invisible. |
| SD changed | The watched key's security descriptor was modified. |
| Key deleted | The watched key itself is no longer reachable by path. |
| Overflow | Events were dropped — re-read to recover (below). |
Watchers see effective state, not layers #
Here is the important tie-back to Layers. A watcher is told about changes to the effective value — the winner of the contest — and nothing about the machinery underneath. Delete a layer and cause a value to revert, and the watcher sees a plain "value set" event for its new effective value. Apply a higher-precedence policy that overrides a local setting, and the watcher sees the value change. It never sees "a layer was added" or "a write lost a contest" — only that the answer it would get from a read is now different.
This is exactly right: the layering is an implementation of how the effective value is chosen, and a watcher only cares that it changed. The curtain stays down; the watcher watches the front of it.
Committed state only #
Watch events fire when a change commits, never mid-flight. A transaction that writes many values produces its events as one batch at commit time; a watcher never observes a half-applied transaction, and an aborted one produces no events at all. What a watcher sees is always a state the system actually reached.
Best-effort: be ready to re-read #
A watch is a change notification, not a guaranteed-complete journal. Events are queued for the watcher, and the queue is bounded. If a watcher falls behind — events arriving faster than it reads them — the queue overflows: the oldest events are dropped and a single overflow marker is delivered in their place. The contract on overflow is simple and firm: re-read the watched key (and subtree) to recover the current state, then carry on with subsequent events.
So a correct watcher is written to do two things — apply individual events when it is keeping up, and fall back to a full re-read when it is told it fell behind. It never assumes the event stream is a complete edit history; it treats it as "something changed, here is a hint, and if the hint says you missed some, go look." The same overflow-and-re-read recovery covers the bigger disruptions too — a large layer operation, or the backing store restarting — where computing an exact per-change list is not worthwhile.
The reaction loop #
Put watches together with reject-or-keep and you get the pattern that runs throughout Peios. A subsystem watches its own configuration subtree; when a value changes, the watch fires; the subsystem re-reads, re-validates, and either applies the new value or keeps its last known-good one and logs the rejection. KMES does this for its tuning; peinit does it for service definitions; the registry itself does it for its own parameters (see How the registry boots and configures itself). Configuration is something you react to, and watches are the mechanism.
One subtlety: a watch follows the object #
A watch is bound to the specific key object you opened, not to the path string. If layers later cause a different key to appear at the same path, your watch stays with the original object — it does not jump to the newcomer. If the original key is removed, you get a "key deleted" event; to watch whatever now lives at that path, you reopen the path and arm a fresh watch. This follows from keys having an identity of their own, distinct from the name that currently points at them.
Where to go next #
If you want the registry's own use of watches — how it picks up changes to its own configuration without restarting — read How the registry boots and configures itself.
If you want the validation half of the reaction loop — why a changed value might be read and then refused — read Configuration, not storage.
If you want what it takes to be allowed to watch a key, read Access control on keys.
Layers
Peios / Using Peios / Layers
So far the registry has been presented as if each value had a single stored entry: you write it, you read it back. That was the effective view — true as far as it goes, and the right way to learn everything up to here. This page replaces it with what is actually underneath, because the registry is a layered store, and the layering is both the reason the subsystem exists and the part most likely to be misunderstood.
The misunderstanding is worth naming up front. It is natural to picture layers as a clean stack — sheets of glass, one above another, the top sheet's value showing through. That picture is wrong in the case that matters most, and unlearning it is most of this page.
Layers, in one sentence #
A layer is a label stamped on a write and a handle for removing a group of writes together — not a position in a stack. The registry orders individual writes, not layers, and it chooses a winner separately for every single value.
Hold onto both halves. First: the thing that has a position is the write, not the layer. Second: resolution is per value — the contest is run again, from scratch, for every value name.
Every write is kept #
When you write a value, the registry does not overwrite anything in place. It records your write, tagged with the layer you wrote it in. If a different layer writes the same value, that write is stored alongside yours, with its own tag. So a single value name can hold several stored writes at once — one for each layer that has ever written it. (Within one layer there is only ever one: writing a value again in the same layer replaces that layer's entry. One layer, one opinion per value.)
A read, though, returns exactly one value — the effective value. Several stored writes, one answer: there is a contest, and the rest of this page is its rules.
How the winner is chosen #
Each stored write carries two ordering keys:
- precedence — a number it inherits from its layer. Higher wins.
- recency — its place in a single, global, monotonic write-order counter. Later wins.
For one value, the registry gathers every active write to it and picks the winner by those keys in order: highest precedence first; among writes that tie on precedence, the most recent. That is the whole rule.
flowchart TD
W1["write · layer base · seq 300"] --> R{"tie on precedence — most recent wins"}
W2["write · layer role-jellyfin · seq 50"] --> R
R --> E["effective value = base (seq 300)"]
The subtlety is entirely in how those two keys play out in practice — because in practice, one of them almost never varies.
Precedence is the exception; recency is the rule #
Here is the fact that breaks the stack picture: almost every layer has the same precedence. The base layer is precedence 0. Role layers are precedence 0. Only a few things — chiefly domain policy — ever sit higher. So for the overwhelming majority of contests, precedence is a tie, and the winner is decided purely by recency, value by value.
That has a consequence the stack picture cannot express. Two layers at the same precedence have no fixed pecking order between them. For one value, layer A might win because it wrote that value most recently; for the value right beside it, layer B wins because it wrote that one last. Same two layers, same precedence, different winners — at the same instant.
A worked example. An administrator has made some manual edits (which land in the base layer) and has installed a role (role-jellyfin), both at precedence 0:
| Value | base wrote (seq) | role-jellyfin wrote (seq) | Effective value | Why |
|---|---|---|---|---|
MaxEventSize | 300 | 50 | base | tie on precedence → most recent write wins |
BufferCapacity | — | 250 | role-jellyfin | base never wrote it |
MaxNestingDepth | 100 | 280 | role-jellyfin | role wrote it more recently |
There is no "top layer" here to point at. base owns one value, role-jellyfin owns two, and which is which is decided one value at a time by who wrote last. This is what "interwoven, not stacked" means, and it is the normal case, not an edge case.
So the stack-of-glass-sheets picture is only ever right when precedences genuinely differ — which is the minority. Drop it as your default image.
A picture that fits #
If you want one image to keep, use a shared document where, for each cell, the last edit wins — many editors, no locking, the most recent edit showing. That is the same-precedence case exactly: per-cell (per-value) last-write-wins, with no editor inherently above another. Precedence, when it appears, is an administrator who can lock a cell: once locked, that cell holds the locked value no matter who edits afterwards. Every metaphor leaks, but this one leaks in the right places — it foregrounds that resolution is per-cell and recency-driven, and casts precedence as the rare override it actually is.
When precedence really does differ #
When two writes have different precedence, recency stops mattering between them: higher precedence always wins, even over a more recent lower-precedence write. A value set by a precedence-1 layer long ago still beats one written to a precedence-0 layer a moment ago.
This is precisely what makes precedence worth having. Recency is fine for cooperating local configuration, but it cannot express "this setting must win even if someone writes it again later" — and that is exactly what policy needs. A domain Group Policy is delivered as a higher-precedence layer for this reason: a local administrator cannot defeat it by re-writing the value, because their write lands at precedence 0 and loses to the higher tier regardless of how recent it is. Precedence is the mechanism behind "you cannot override this locally". (Creating a layer that outranks others is itself a privileged action, so the tiering cannot be forged from below — more on that in Access control.)
"Most recent" is write order, not the clock #
One precision worth stating, because it is what makes the model trustworthy: recency is a monotonic write-order counter, not a wall-clock timestamp. Every write is handed the next number from a single counter that only ever increases. The registry does record a wall-clock "last write time" on each key, but only as human-facing metadata — it is never used to resolve a contest. So "most recent" means "later in the actual order of writes", which cannot be moved backwards or spoofed by a clock change.
It is not only values #
Everything above is framed around values, but the same contest decides whether a key exists at a path. Each layer can make its own claim about a name — "a key lives here" — and the winner is chosen by the same precedence-then-recency rule. A layer can even claim that nothing lives there, masking a key another layer provides. That is how a layer adds, replaces, or hides a key, and it resolves exactly as a value does. The markers that express absence — tombstones for values, hidden entries for keys — are the subject of the next page.
Where to go next #
If you want the payoff — why all of this exists — read What layers are for: the base layer, tombstones, the automatic-revert property, and how roles and Group Policy are built on it.
If you want the one thing layers do not revert — security — read Access control on keys. A security change made while a layer existed is not undone when the layer is removed, and the reason is worth understanding.
If you want to know how a watcher sees a layer change — it observes effective-state changes, with the layering made invisible — read Watching for changes.
What layers are for
Peios / Using Peios / Layers
Layers explained how the registry resolves competing writes. This page is the reason it bothers. The layered model buys one thing above all: configuration you can add and remove as a unit, with revert that is automatic and leaves nothing behind.
The payoff, in one sentence #
Because every write is tagged with a layer and nothing is overwritten in place, deleting a layer makes its writes disappear and the next-best write for each value resurface on its own — so installing a bundle of configuration and removing it again are just creating and deleting a layer.
Everything below is a consequence of that.
The base layer #
Most of the time you are not thinking about layers at all, and the base layer is why. It is the default layer — precedence 0 — and it is where writes go when you do not ask for anything else. Manual administrative edits land here; so do the system's own defaults. It always exists, and it cannot be deleted or disabled; it is the floor the rest of the model stands on.
When earlier pages showed you "write a value, read it back", that was the base layer doing its job. You can work with the registry for a long time and only ever touch base.
Saying "no value", not just "another value" #
A plain write competes to be the value. But configuration sometimes needs to say something a plain write cannot: this value must not be set at all. Overriding MaxEventSize with a different number is easy; asserting that MaxEventSize should be absent is a different statement.
A layer makes it with a tombstone — a write whose meaning is "no value here". It enters the same per-value contest as any other write (Layers); if it wins, a read of that value returns "not found" rather than falling through to some other layer's write. Like any write, it belongs to a layer — so when that layer is removed, the tombstone goes with it and whatever it was suppressing comes back.
A blanket tombstone is the same idea applied to a whole key at once: a marker that enters the contest as a "no value" candidate for every value name on the key. A layer can set a blanket tombstone and then write the specific values it does want — the effect is "clear everything here, then set these". It is how a layer declares the complete contents of a key instead of merging into whatever was already there.
The key-level equivalent is hiding: a layer can claim that no key exists at a path, masking one that an older or lower-precedence write provides. Remove the layer and the key reappears.
In every case the marker is just another write — owned by a layer, removed with the layer.
Automatic revert #
This is the property the whole design exists to deliver. Because layers are removable and nothing is ever destroyed in place, deleting a layer cleanly undoes everything it did — with no undo script, no bookkeeping, no leftover state:
- Its writes vanish from every contest they were in.
- Its tombstones and hidden-key markers lift.
- For each affected value or key, the registry simply re-runs the contest without the deleted layer's writes, and the next-best survivor becomes effective.
There is no separate "revert" operation. Revert is what naturally happens when a layer's writes stop existing, because the effective value was always just the winner of a live contest.
flowchart LR
A["role-jellyfin present: MaxNestingDepth = role's value"] -->|delete the layer| B["role's write gone: MaxNestingDepth reverts to the base value underneath"]
There is, in particular, no tattooing — the failure mode where removing configuration leaves its changes burned in. In a system that overwrites in place, uninstalling something means trusting that its installer recorded the previous values and restores them correctly. Here there is nothing to restore: the previous value never left.
Roles #
A role is a bundle of configuration deployed as a layer. Installing a role creates a layer and writes the role's keys and values into it (atomically, as one transaction, so the role never appears half-installed). Uninstalling a role deletes the layer — and by automatic revert, every value it set and every key it added simply falls away, and whatever the system looked like before resurfaces on its own. Clean uninstall is not something a role's authors have to implement carefully; it is a property of the layer.
Group Policy #
Domain Group Policy is configuration delivered from outside the machine, and it rides on layers too — but at a higher precedence than local layers. That choice is deliberate, and Layers explained why: a higher-precedence write wins regardless of recency, so a domain setting beats local configuration even if a local administrator writes the value again afterwards. Applying a policy adds the layer; lifting it deletes the layer, and the local configuration it had been overriding resurfaces automatically — the same revert, one tier up. (High-precedence layers are privileged to create, so policy cannot be forged by an unprivileged process; see Access control.)
What layers are not #
- Not a transaction. Layers decide which write wins and let you remove a group of writes together; atomicity — making several writes commit all-or-nothing — is a separate mechanism. A role install uses both: a transaction to apply the writes atomically, a layer to make them removable.
- Not access control. A layer does not decide who may read or change a value; the security descriptor on each key does. And the two do not mix: a security change made while a layer existed is not reverted when the layer is deleted. Security is operational state, not configuration overlay — Access control on keys is where that distinction lives.
- Not a browsable history. Layers are not a version-control timeline you can scroll through. You see the effective view — the current winners — not a log of every write that ever competed.
Where to go next #
If you want what "deleting a key" really does once names are layered — and why there is no recursive delete — read Deleting keys and values.
If you want the security model and its sharp interaction with layers — why deleting a layer reverts its values but not a security change made under it — read Access control on keys.
If you want to see how a watcher experiences a layer being added or removed — it sees the effective values change, with the layer machinery invisible — read Watching for changes.
For the sandboxing case — per-thread private layers that only one caller sees — read Private hives and layers.
Deleting keys and values
Peios / Using Peios / Layers
Deletion is one more thing the layered model quietly reshapes. "Delete this key" sounds absolute, but in a store where a name is the winner of a per-layer contest, removing a key really means withdrawing one layer's claim to that name. Several behaviours follow that are worth knowing before you delete anything.
Deletion, in one sentence #
Deleting a key withdraws one layer's claim to a name; the key disappears only if no layer still claims it — and even then, anything holding it open keeps working until it lets go.
Deleting withdraws a claim #
When you delete a key, you remove its name in a particular layer — the base layer unless you say otherwise. Nothing about the key is special-cased; its claim in that layer is simply withdrawn, and it drops out of the contest for that name.
If another layer still names the key, it stays visible through that layer. So deleting a key that a role also provides removes only your claim — the role's key remains until the role does. To remove a key everywhere, every layer's claim has to go. For anything delivered by a role or a policy, that means removing the layer (which reverts cleanly) rather than deleting the key — deleting it in the base layer would not touch the role's claim anyway.
(The hive roots themselves — Machine\, Users\<SID>\ — cannot be deleted or hidden. They are the anchors the namespace hangs from.)
There is no recursive delete #
You cannot delete a key that still has visible child keys; the deletion is refused. There is no "delete this whole subtree" primitive. Removing a populated subtree is a deliberate walk from the leaves upward, performed by whatever tool you are using — not a single sweep in the kernel.
This is a safety property, not a limitation to work around. A mistaken delete cannot take a populated subtree down with it; you have to mean it, key by key.
Deleting out from under an open handle #
A key can be deleted while a process still has it open, and the registry handles that the same way Linux handles deleting an open file (the unlink model). The open handle keeps working — reads, writes, and watches all continue against the now-unnamed key — and the key is only truly discarded once the last handle closes. Meanwhile, new attempts to open it by path fail at once: the name is gone, even though the object lingers for whoever still holds it.
So "deleted" means "no longer reachable by name", not "destroyed this instant". A service that had the key open does not break mid-operation; it holds the last reference until it closes, and only then does the key go away.
Deleting values #
A value works the same way one level down. Deleting a value withdraws a layer's write for that value name. If a lower-precedence or older layer also wrote that value, its write resurfaces as the new effective value — the ordinary revert. Remove a value's only write and the value simply becomes absent.
Permission #
Deleting (or hiding) a key requires delete permission on it — see Access control on keys. As everywhere in the registry, the check is against the key you are deleting, decided by its security descriptor, with no check on the keys above it.
Where to go next #
For the contest that "withdrawing a claim" feeds back into, read Layers.
For removing configuration in bulk — deleting a layer, which is what you do instead of deleting a role's or policy's keys — read What layers are for.
Access control on keys
Peios / Using Peios / Security
Every registry key is a secured object. It carries a security descriptor — owner, DACL, and optionally a SACL — exactly like a file, a process, or a token. And the registry does not invent its own access logic: opening a key runs the same AccessCheck pipeline that governs every other protected object in Peios, against the same tokens and the same SD format. If you understand access control for files, you already understand most of it for the registry.
Access control on keys, in one sentence #
Every key carries a security descriptor, and access to it is decided by the same AccessCheck that governs everything else — evaluated once when you open the key, then cached on the handle for the life of that handle.
Security is per key, not per value #
A security descriptor lives on the key. Values do not have their own — they inherit their key's access control. "Who can read this value, who can change it" is answered by the SD on the key that contains it, and there is no finer-grained permission than that. If two values need different access rules, they belong in different keys.
The handle model #
Access is checked at open, not on every operation. When you open a key for some set of rights, AccessCheck runs once; if it grants them, you get a key handle (a file descriptor) with a granted access mask baked in. Every later operation through that handle is a cheap bitmask check against the cached mask — is this operation's required right in what I was granted? — not a fresh AccessCheck.
The consequence is the same check-at-open rule that files and central access policies follow: changing a key's SD affects future opens, not handles that are already open. An administrator who tightens a key's DACL does not retract access from a service that already has the key open — the recourse is to make the service reopen (typically by restarting it). The handle is a snapshot of the decision made at open time.
The registry-specific rights #
A key's DACL is written in terms of rights specific to keys:
| Right | Gates |
|---|---|
KEY_QUERY_VALUE | Reading values. |
KEY_SET_VALUE | Writing and deleting values. |
KEY_CREATE_SUB_KEY | Creating child keys. |
KEY_ENUMERATE_SUB_KEYS | Listing child keys. |
KEY_NOTIFY | Arming a watch. |
KEY_CREATE_LINK | Creating a link key (privileged). |
DELETE | Deleting the key, or hiding it in a layer. |
READ_CONTROL | Reading the SD and key metadata. |
WRITE_DAC / WRITE_OWNER | Changing the DACL / the owner. |
ACCESS_SYSTEM_SECURITY | Reading or changing the SACL (audit policy). |
The usual convenience bundles apply — KEY_READ (query, enumerate, notify, read the SD), KEY_WRITE (set values, create subkeys), and KEY_ALL_ACCESS.
Ordinary access — reading a value, writing one, creating or listing subkeys — is decided entirely by the key's SD through AccessCheck. No privilege grants ordinary registry access. A few privileges appear only where a write means more than storage: establishing a layer that outranks others (policy — see Layers), creating a link, or bulk backup and restore. Those are special operations, covered where they arise; everyday access is the SD's job alone.
No traversal check #
One surprise sets the registry apart from a filesystem: only the SD on the key you open is checked. The keys above it on the path are not. A process can open Machine\System\Services\Jellyfin with no access whatsoever to Machine\System\Services or Machine\System. There is no "execute/traverse" right that you must hold on every ancestor the way a filesystem demands. Access is decided at the destination, full stop.
This matters when you reason about exposure: locking down a parent key does not lock down what is beneath it. If a subtree must be protected, the protection has to be on the keys that hold the data, not merely on a key somewhere above them.
Where a key's SD comes from #
A key's SD is computed once, at creation, by inheriting from its parent — the same eager, static inheritance files use. It is then a complete value stored on the key; the parent is not consulted again at access time. Changing a parent's SD does not ripple down to children that already exist — re-applying inheritance to an existing subtree is a deliberate administrative walk, not something that happens on its own.
The chain has to start somewhere, and it starts at the hive roots, whose SDs are the seeds for everything below:
| Hive root | Default access |
|---|---|
Machine\ | SYSTEM and Administrators: full control. Authenticated Users: read. (All inheritable.) |
Users\<SID>\ | That user, SYSTEM, and Administrators: full control. |
Subsystems that need something tighter than "Authenticated Users can read" set an explicit SD on their own subtree root at creation, overriding the inherited default for everything beneath it.
Watching requires permission to read #
Arming a watch needs KEY_NOTIFY, so a process cannot monitor a key it could not otherwise observe. One deliberate asymmetry: a subtree watcher learns that a descendant key was created or deleted — structural facts — without holding any access to that descendant. It learns that something appeared; it must still open it (and pass AccessCheck) to read what is inside. Structure visibility is intentionally weaker than content visibility.
The sharp edge: security is not layered #
This is where the registry's two big ideas meet, and the result surprises people. Layers revert cleanly — delete a layer and its values fall away. A security change does not.
A key's SD is a direct property of the key object. It is not tagged with a layer and it is not a write in the per-value contest. So when you change a key's DACL — tightening access, say — you are mutating the key itself, permanently. If a role layer existed at the time and is later uninstalled, the values it set revert, but the security change you made stays exactly where it is.
The reasoning is deliberate: security is operational state, not configuration overlay. Imagine the alternative — an administrator locks down a sensitive key while some unrelated role happens to be installed, and then removing that role silently reopens the key. Configuration is the kind of thing you want to revert in bundles; an access decision is not. So the registry keeps them on different tracks: values and key existence are layered and revertible; ownership and permissions are mutations on the object that outlive any layer.
The short version: layers revert what the system is configured to do; they never revert who is allowed to do it.
Where to go next #
If you want to see how a process reacts to a change instead of polling for it — and how watch events report effective-state changes with the layering invisible — read Watching for changes.
If you want the kernel/store split underneath all of this — who actually holds the SDs, and the trust boundary that follows — read LCS and sources.
For the broader access model these rights plug into — the AccessCheck pipeline itself — read Access decisions.
Default security descriptors
Peios / Using Peios / Security
Some software stamps security descriptors onto objects it creates at runtime — a freshly mounted filesystem root, a state file, a spool directory. Each of those descriptors is a policy decision: who can enumerate this directory, who can read this file, what everything created beneath this root will inherit. Decisions like that are configuration, and in Peios configuration has one home. The SdDefaults convention is the standard place a component keeps these descriptors, so that an operator can inspect them, change them, and trust that every component publishes them the same way.
Default security descriptors, in one sentence #
A component keeps each security descriptor it stamps at runtime as a named SDDL value under Machine\Software\<Software>\SdDefaults\, with a compiled-in copy as the fallback — a value that is present and valid overrides the compiled default; a value that is invalid is ignored, loudly.
The convention #
Machine\Software\<Software>\SdDefaults\<SD Name>
Machine\Software\<Software>is the component's own configuration key — the same key the rest of its settings live under.SdDefaultsis the literal subkey name.<SD Name>names the object the descriptor protects:SpoolDirectory,StateFile,Run. One value per descriptor, stored as a string containing SDDL.
A fictional spooler that creates its spool directory at startup would publish:
Machine\Software\ExampleSpooler\SdDefaults\SpoolDirectory
REG_SZ "O:SYG:SYD:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)"
The defaults are per component, deliberately. A single shared tree of default descriptors would need every SD name to be unique across every application ever written — a namespacing promise nobody can keep. Scoping the names under the component that reads them dissolves the problem, and it keeps discovery unsurprising: a component's descriptors live where the rest of its configuration lives.
Compiled default, registry override #
The registry value is an override, never the only copy. Every component that follows the convention carries a compiled-in default for each named descriptor, and resolves the one to use at the point where it stamps it:
State of SdDefaults\<SD Name> | Descriptor in force |
|---|---|
| Absent | The compiled-in default. This is the normal state — the key need not exist at all. |
| Present, valid SDDL | The registry value. |
| Present, invalid | The compiled-in default. The stored value is ignored, and an event records the key, the rejected value, and the descriptor actually in force. |
This is the registry's general reject-or-keep rule applied to descriptors, and here it is doing its most important work. A security descriptor that fails to parse is never repaired, never approximated, and never replaced with something broader: the descriptor in force is always one that was compiled in and reviewed. A typo in an SdDefaults value costs you your customisation, not your system.
When a change takes effect #
A default descriptor is read when the component stamps it — typically when the object is created. Two consequences follow:
- Changing a value affects objects stamped after the change. It does not rewrite objects that already exist.
- Where the stamped object is a directory whose descriptor carries inheritable ACEs, everything created beneath it derives its descriptor at creation time (inheritance is computed once, when the child is born). Changing the default later does not re-derive existing children.
So the honest answer to "when does my change apply?" varies by descriptor — next boot, next mount, next time the object is recreated — and the component's regman documentation is where that answer lives. Every <SD Name> a component publishes has a regman entry, and its Applies line states exactly this.
The values are access policy — protect them #
Whoever can write a component's SdDefaults values decides what access the component will grant on the objects it creates. These values are not settings about security; they are the security. A component's SdDefaults key therefore carries a tight security descriptor of its own — writable by Administrators and SYSTEM, no wider — which the registry enforces like any other key. The arrangement is self-hosting: the store that holds the descriptors is protected by the same mechanism the descriptors configure.
What the convention does not cover #
Bootstrap seeding. The very first descriptors of a boot — the seed stamped onto a fresh root before any registry source is attached — are compiled into the tools that write them, and are not customisable through the registry. There is no registry to read at that point in boot; that is not a gap in the convention but the reason it has a floor.
Kernel fallbacks. The descriptors the kernel synthesises when a mount policy has no template, and the default DACL applied when a created object has no parent to inherit from, are fixed. They are the floor under a missing policy, not configuration.
Files installed by packages. A package payload entry gets its descriptor by inheritance from its destination directory, or from a declaration in the package manifest — see PSPU §5.20. SdDefaults governs what software stamps at runtime, not what the package manager installs.
For component authors #
If your component stamps descriptors at runtime, follow the convention:
- Ship a compiled default for every named descriptor. The registry value is an override; your component must work, with reviewed policy, on a system where the
SdDefaultskey has never been created. - Name each value for the object it protects, not for its content —
SpoolDirectory, notSystemOnlyOici. - Resolve with reject-or-keep. An unparseable value is ignored in favour of the compiled default, and the rejection is recorded in an event naming the key, the rejected value, and the descriptor in force. Never substitute anything broader than the compiled default.
- Document every name in
regman, including anAppliesline that says when a change is picked up.
Where to go next #
For the doctrine behind reject-or-keep — why the registry stores values it does not validate, and why readers keep their last known-good — read Configuration, not storage.
For what an SDDL string actually says — owner, DACL, ACEs, and the inheritance flags that make one descriptor govern a whole tree — start at Security descriptors and Inheritance.
For protecting the SdDefaults key itself, read Access control on keys.
How the registry boots and configures itself
Peios / Using Peios / Administration
The registry holds the configuration for everything on the system. Which raises an awkward question: what configures the registry? The honest answer is that it mostly configures itself — and doing that means breaking a circular dependency, because the registry's own settings live in the registry, and the store those settings live in is itself a service that has to start up. This page is how that knot is untied, and it doubles as the purest example of the reject-or-keep discipline.
Bootstrap, in one sentence #
The registry subsystem is fully operational on compiled-in defaults from the instant the kernel loads, and hot-swaps to registry-backed configuration once its store comes up — it never enters a "waiting for configuration" state.
The circular dependencies #
Three loops have to be broken at boot:
- The registry reads its own operational parameters (timeouts, size limits, and so on) from a key under the
Machine\hive — but that hive is held by a store that has not started yet. - Other early services want to read their configuration from the registry before that same store is up.
- On a brand-new install there is no data at all: the store's database is empty.
A configuration store that refused to function until it was configured would deadlock on the first of these. The registry is designed so that never happens.
Compiled-in defaults #
Every operational parameter the registry uses has a compiled-in default that produces correct behaviour. From the moment the kernel module loads, the registry runs on those defaults — it is immediately operational and never blocks waiting for configuration. The base layer likewise exists unconditionally, hardcoded, needing no persisted state. So before any store has registered, the machinery is already alive; it simply has no hives to serve yet.
Hot-swap, not restart #
When the store registers and the configuration keys become readable, the registry reads, validates, and swaps its parameters in place — no restart, no re-initialisation. Operations already in flight finish on the values they started with; new operations pick up the new values. The transition from "compiled-in defaults" to "registry-backed configuration" is seamless and is the only configuration transition there is.
flowchart LR
A["Kernel loads — registry live on compiled-in defaults"] --> B["Store registers its hives"]
B --> C["Read Machine\System\Registry\* parameters"]
C --> D["Validate and hot-swap into effect"]
It applies reject-or-keep to itself #
This is the cleanest demonstration of the idea from Configuration, not storage: the registry treats its own configuration as untrusted bytes it must validate. A parameter value that is valid is hot-swapped into effect. One that is invalid — out of range, wrong type — is ignored: the registry keeps the value it was already using and emits an audit event naming the key, the rejected value, and the value still in force. It never clamps the bad value to something legal. The subsystem that owns the entire registry does not trust even its own settings until it has checked them — and when stored and in-force disagree, the audit log is the truth.
First boot, with no data #
A fresh install has an empty store, and the sequence is built to cope:
- The store —
registryd, the base registry source peinit starts at early boot (see LCS and sources) — starts, finds its database empty, and creates the hive root keys with their default security descriptors — but no configuration beneath them. - The registry looks for its parameter keys, finds nothing, and keeps its compiled-in defaults. It is fully operational regardless.
- The init system (not the registry's concern) restores a seed — a backup of the system's initial configuration — that populates
Machine\with the system's real configuration. - That write trips the registry's watch on its own configuration area; it re-reads, validates, and hot-swaps to the seeded values. Normal operation continues.
At no point is there a stall. Empty store, missing keys, seed arriving later — each is handled by "use the defaults until something better shows up", driven by the same watch mechanism every other reactive consumer uses.
The self-watch #
The registry notices changes to its own configuration the same way any service notices changes — by watching the subtree — except the watcher is internal to the kernel rather than a userspace handle. It is the reaction loop turned on the registry itself: a change to a parameter key fires the watch, the registry re-reads and re-validates, and applies or rejects. There is no polling and no special-case configuration path; self-configuration is just the registry being one more consumer of the registry.
Where to go next #
If you want the store itself — what it is, how the kernel and the userspace store divide the work, and the trust boundary between them — read LCS and sources.
If you want the validation discipline this page leans on, read Configuration, not storage.
If you want the change-notification mechanism behind the self-watch, read Watching for changes.
Backup and restore
Peios / Using Peios / Administration
Beyond reading and writing individual values, the registry can move whole subtrees at once: export a key and everything beneath it to a stream, and restore such a stream back into the tree. This is the bulk-data path, and it is the mechanism behind things you have already met — first-boot seeding — as well as the disaster-recovery and migration any real deployment needs.
Backup and restore, in one sentence #
Backup streams a point-in-time copy of a subtree out; restore replaces a subtree wholesale from such a stream — both are privileged, whole-subtree operations, not value-by-value edits.
Backup is a point-in-time snapshot #
A backup captures a key and its entire subtree as it stands at one instant — a consistent snapshot, unaffected by writes happening concurrently. The result is a self-contained stream you can send to a file, a pipe, or across a network.
It is full-fidelity with respect to layers: a backup records every layer's writes, not merely the effective values. Restore it and the layered structure comes back intact — the base values, the role layers, the policy overlays, all of them, resolving the way they did before. A backup is not a flattened picture of "what the values currently are"; it is the whole stack.
The stream is also self-verifying: it carries an integrity check, so a truncated or corrupted backup is caught when you try to restore it, rather than restored as garbage.
Restore is replace, not merge #
This is the rule to internalise. Restoring into a key replaces that key's contents and entire subtree: the existing descendants are removed and the backup's contents take their place. It is not a merge and it does not add to what is there — whatever was under the target key before is gone, supplanted by the stream.
The target key itself survives as the anchor — it keeps its identity and its place in the tree — and everything beneath it is rebuilt from the backup. The whole operation is atomic: it either completes and swaps the new subtree in, or fails and leaves the original untouched. There is no half-restored state to clean up.
Both bypass per-key permissions — by design #
Backup and restore do not consult the security descriptor on each key the way an ordinary open does. They are gated by privilege instead — a backup privilege to read the whole subtree, a restore privilege to write it. This is the same model as file backup: a backup operator reads files they were never granted access to, because reading-for-backup is the privilege, not per-file permission.
(Both operations are audited every time they run, regardless of a key's own audit settings — see the auditing topic.)
What it is for #
- First-boot seeding. A fresh machine's registry is empty; its initial configuration is delivered by restoring a seed. Same mechanism, described in bootstrap.
- Disaster recovery. Snapshot a subtree — or a whole hive — and restore it after a failure or a bad change.
- Migration. Move configuration between machines by backing up on one and restoring on another; the stream is portable.
Where to go next #
For where restore comes from at first boot, read How the registry boots and configures itself.
For who actually performs these operations — the kernel coordinating the store — read LCS and sources.
For the privilege model they lean on, read Access control on keys.
LCS and sources
Peios / Using Peios / Administration
Every page until now has treated the registry as a single thing, and that was the right altitude to learn the model. This page pulls it apart, because at the implementation level "the registry" is two cooperating components — and the division between them is clean, deliberate, and worth knowing once the concepts are solid.
The split, in one sentence #
The registry is a kernel subsystem (LCS) that is the sole authority for the data model, access control, layer resolution, and change notification — plus one or more userspace sources that do nothing but persist and return data; the base source, registryd, is loregd by default and backs the Machine and Users hives.
Who does what #
The dividing line is sharp, and it is the principle the whole design rests on: the kernel decides meaning; sources only store.
| The kernel subsystem (LCS) owns | A source owns |
|---|---|
| The data model — keys, values, types | Persisting entries to disk and returning them |
| Path resolution and routing | Nothing about who is asking |
| Access control — running AccessCheck | No security decisions at all |
| Layer resolution — choosing the effective value | No idea which write is effective |
| Watch dispatch and transaction coordination | Executing storage operations it is told to |
A source never sees a caller's identity, never evaluates a security descriptor, and never resolves a contest between layers. It stores writes tagged with layer names and hands all of them back when asked; the kernel does the deciding. That is why earlier pages could say "the registry checks", "the registry resolves" — it is always the kernel doing it, never the store.
flowchart LR
P["Any process"] -->|registry system calls| L["LCS (kernel): access control, layer resolution, watches"]
L -->|private protocol| S["Source — e.g. loregd (userspace): storage only"]
S --> DB["On-disk database"]
Userspace never talks to a source directly. Every registry operation goes through the kernel; the source talks only to the kernel, over a private protocol on a dedicated device. This is what lets the registry carry the same identity model, access control, and auditing as everything else — the kernel is unavoidably in the path.
Why split it this way #
Storage is a separable concern. Putting it in userspace means the backing store can be replaced, or different stores can back different hives, without changing the kernel — and the store can be developed, tested, and hardened as an ordinary (if privileged) service. The kernel keeps the parts that must be trusted and uniform — the data model and the security model — and delegates the part that is "just a database".
Hives are how the work is parcelled out: each hive is backed by exactly one source, and a source may back several hives. In practice the base source registers Machine and Users; other sources could register additional hives.
The base source: registryd #
The standard hives have to exist before anything can read configuration, so one source is always started at boot. registryd is that base source — the registry store peinit starts in early boot, and the first thing to register hives with LCS so the rest of startup has configuration to read. Its interface is deliberately minimal: one HiveName=path argument per hive, naming the hive and the on-disk database that backs it.
registryd Machine=/var/state/registry/machine.regdb Users=/var/state/registry/users.regdb
That is the whole of its configuration — registryd is the configuration store, so it takes what it needs from its arguments rather than from a config file of its own.
registryd is a role, not a fixed program. loregd — the Local Registry Daemon, which backs each hive with a SQLite database — is the default implementation, and the one running on a stock system. But the name is a swappable slot: another source can ship a registryd claim, and installing it puts that source in loregd's place with no change to anything that depends on the registry. Whatever holds the role starts the same way and answers to the same name. (Writing an alternative source is a developer task — the Peios SDK covers the protocol one speaks to the kernel.)
The trust boundary #
It is worth being blunt about the security boundary, the same way the rest of these docs are. A source is part of the trusted computing base. The kernel runs AccessCheck, but it runs it against the security descriptor the source hands back — so a compromised source can return a permissive SD for a key and cause access that should have been denied, or fabricate the precedence of a layer and tilt every contest in the system. The kernel validates that a source's responses are structurally well-formed and rejects malformed data, but it cannot detect a well-formed lie.
This is not a gap so much as a fact about where the boundary is: trusting the store to tell the truth about what it stores is inherent in having a userspace store at all. The mitigations are operational — a source runs with tightly scoped privileges, is protected by the security descriptors on its own service definition, and is managed as a critical service (with process-integrity protection where available). The honest summary is: the store is small, privileged, and trusted, and protecting it is part of protecting the system.
Two operations the kernel coordinates #
Two registry capabilities are the kernel orchestrating a source rather than features of the data model: atomic transactions, and backup and restore. In both, the source does the storage work — committing a batch atomically, or reading out and replacing a subtree — while the kernel coordinates it and enforces the rules around it. Each has its own page; the point here is only that the same division of labour holds: the kernel decides, the source stores.
When a source goes away #
Because the store is a separate process, it can crash or restart. When a source goes down its hives become unavailable: open key handles stay valid (they hold identity and a granted mask, neither of which needs the store), but operations that need to reach the store return an error until it is back. Watches stay armed across the outage, and when the source re-registers, watchers receive an overflow so they re-read and resynchronise. The registry treats a store restart as a disruption to recover from, not a reason to lose state.
Where to go next #
You have now seen the whole model — data, meaning, layers, security, change notification, and the parts underneath.
For the tool you will reach for most when configuring a system — looking up what any key or value means — read The registry manual (regman).
Three advanced topics remain:
For grouping several writes into one all-or-nothing change — how a role installs without ever being half-applied — read Transactions.
For complete per-caller isolation — hives and layers visible only to one sandboxed process — read Private hives and layers.
For keys that point at other keys — the one place the registry follows a value — read Registry links.
The registry manual (regman)
Peios / Using Peios / Administration
Configuration, not storage established that the registry holds values but not their meaning: it keeps a type tag and some bytes, and never knows that BufferCapacity must be a power of two or what it is for. That knowledge has to live somewhere a person can look it up. It lives in regman, the registry's manual.
regman is the man of the registry. Give it a path and it tells you what a key or value actually is. It is the everyday tool for configuring a Peios system — before you touch a knob, regman is how you find out what it does and what changing it will cost.
regman, in one sentence #
regman <path> [value] documents what a registry key or value means — its type, default, valid values, when a change takes effect, and a prose description — drawn from shipped documentation, never from the live registry.
That last clause is the one to hold onto; there is a section on it below.
Looking up a value #
Give regman a key path and a value name and it prints a knob-card — an identity line, an aligned block of facts, and a description:
$ regman Machine\System\KMES BufferCapacity
Machine\System\KMES BufferCapacity documented by kmes
Type REG_QWORD
Default 4194304 (4 MB)
Valid 65536–268435456 bytes (64 KB–256 MB), power of two
Applies live — ring-buffer swap
Per-CPU ring buffer capacity, in bytes. Must be a power of two; values
that are not are treated as invalid and ignored.
Every registry value is a knob, and every knob has the same few facts worth knowing, so the card is uniform rather than free-form:
| Field | Tells you |
|---|---|
| Type | The value's registry type (REG_QWORD, REG_SZ, …). |
| Default | The value used when nothing is set. |
| Valid | The range, set, or constraint a sensible value must satisfy. |
| Applies | When a change takes effect — live, on restart, or on reboot. |
The Applies field is the one operators reach for most: it is the difference between "edit it and you're done" and "edit it and schedule a reboot". Only the fields that make sense for a given item are shown, and a knob that is being retired carries a prominent deprecation notice at the top of its card.
Looking up a key #
Give regman just a key — no value — and it does double duty as an index: the key's own description, then a list of every value documented under it, one summary line each.
$ regman Machine\System\KMES
Machine\System\KMES documented by kmes
The KMES event subsystem reads its tuning parameters from the values
under this key...
Values
BufferCapacity Per-CPU ring buffer capacity in bytes.
MaxEventSize Max total size of a userspace-emitted event.
MaxNestingDepth Max nesting depth for userspace event payloads.
MaxEmitRatePerProcess Max events per second a single process may emit.
So you can start at a subtree, see what is configurable there, and drill into any single value.
Searching, when you do not know the path #
If you don't know where a setting lives, regman -k searches names and summaries — the registry's apropos:
$ regman -k buffer
Machine\System\KMES BufferCapacity Per-CPU ring buffer capacity in bytes.
regman documents intent, not current state #
This is the boundary that matters most. regman tells you what a setting should be — what it means and which values are legal — not what it is currently set to on this machine. It reads shipped documentation, never the live registry; it does not talk to LCS at all. Ask regman about BufferCapacity and you learn it must be a power of two and defaults to 4 MB; you do not learn that this particular box has it set to 8 MB right now. Reading the current value is a separate, live query against the registry — that is reg's job. regman tells you a knob should be a power of two; reg get tells you what it is set to, and reg set changes it. Reach for regman to decide what to write, and reg to write it.
Put regman next to the other two things from Configuration, not storage and a clean division of labour appears:
| To learn… | Look at… |
|---|---|
| What a setting means, and what is valid | regman — the manual |
| What the value is set to | reg — a live query against the store |
| What the subsystem is actually running on | the event log |
regman is the one you consult first, and the only one that works before you have changed anything — because documentation ships with the software, not with the machine's state. It is the natural complement to reject-or-keep: regman tells you the valid range up front, so you set a good value the first time instead of writing one the owning subsystem will quietly refuse.
Where the documentation comes from #
regman has no built-in database of every setting. Each package ships its own documentation as files dropped into a well-known directory (/usr/share/regman/), one fragment per package documenting that package's whole configuration surface. Install a package and its registry documentation appears; remove the package and it goes away. The package that owns a set of keys is the package that documents them — the only arrangement that stays correct as the system changes.
A consequence worth knowing: regman can only describe settings that some installed package has documented. An undocumented key is invisible to it — regman is exactly as complete as the packages on the machine make it. If two packages happen to document the same item, regman shows both and flags the overlap rather than silently picking a winner.
(For the people who write that documentation, regman fmt and regman lint prepare and check fragments, and regman index keeps lookups fast on large corpora — the lookup is always correct without an index, which is purely an accelerator. These are packaging concerns rather than everyday operator ones.)
Where to go next #
For the idea regman exists to serve — why the registry holds values without holding their meaning, and what reject-or-keep means — read Configuration, not storage.
For the other half of the picture — reading what a value is actually set to, which is a live query against the store rather than a documentation lookup — read LCS and sources.
reg
Peios / Using Peios / Administration
reg is the command-line interface to the live registry — the running LCS store, reached through the registry system calls. Where regman reads shipped documentation and tells you what a key means, reg talks to the kernel and reads or writes what a key is. It is the everyday tool for scripting configuration: querying effective values, writing to layers, masking and hiding, managing security descriptors, watching for change, and taking backups.
reg <command> [options] <key> [value] [data]
reg is the read/write half of the registry toolset and regman is the lookup half. The division is exact:
| To… | Use… |
|---|---|
| Read or change what a value is set to, on this machine, right now | reg |
| Look up what a value means — its type, default, valid range, when it applies | regman |
Consult regman first to learn the legal range for a knob, then use reg set to write a value inside it. reg never checks a value's meaning and regman never touches the live store; they are complementary.
How reg treats access and privilege #
Every reg operation goes through the kernel's access check against the single key it opens — the same access-control path as any other protected object, with no traversal check on the parent keys. reg performs no identity or membership checks of its own: it does not refuse a privileged operation up front. It attempts the operation and reports faithfully whatever the kernel returns. An operation that needs a privilege you lack (creating a link, a positive-precedence layer, a backup or restore, reading a SACL) simply fails with access denied (exit 3) rather than being blocked by the tool. A single reg command may legitimately span privilege tiers.
Addressing model #
Almost every subcommand shares one addressing scheme: a key path positional, and an optional value name positional. Understanding the split is most of understanding reg.
Key paths #
The key is a positional path argument. Both / and \ are accepted as separators — neither character is legal inside an LCS key component, so there is no ambiguity, and you may use whichever your shell quotes most comfortably:
reg get Machine/System/KMES
reg get 'Machine\System\KMES' # identical
A leading separator is optional and ignored (/Machine/X is the same as Machine/X). Paths are compared case-insensitively. The first component is the hive:
| Path | Meaning |
|---|---|
Machine\… | The machine hive — system-wide configuration. |
Users\<SID>\… | A specific principal's hive. |
CurrentUser\… | A kernel alias, rewritten to the caller's own Users\<SID>\ at the syscall boundary. |
On output, reg displays paths with a backslash by default. --sep=/ (or REG_SEP=/) switches display to forward slash for that invocation; this affects display only, never how a path is parsed.
The value-name positional #
The value name is a separate positional argument — it is never folded into the key path. This is deliberate: an LCS value name may itself contain / or \ (which a key component may not), so keeping them apart removes all ambiguity.
reg get Machine/System/KMES BufferCapacity
# └──── key path ─────┘ └── value ──┘
The presence or absence of the value argument selects what the command targets:
- No value argument ⇒ the command targets the key itself — its metadata, its values as a set, its security descriptor, its children.
- A value argument ⇒ the command targets that one named value.
- The default value (the empty-name value) is addressed by the literal token
@in the value-name position, mirroring.regconvention:reg get Machine\App @.
This split applies uniformly to get, set, del, mask, and unmask.
Value literals and types #
On set (and in a batch), the value's data is a single token whose registry type is either forced by a type: prefix or inferred from its shape. The registry value types and their literal syntax:
| Prefix | Registry type | Data syntax |
|---|---|---|
sz: | REG_SZ | UTF-8 string (rest of token, verbatim). |
expand: | REG_EXPAND_SZ | UTF-8 string, %VAR% left unexpanded on disk. |
dword: | REG_DWORD | 42 or 0x2A; must fit u32. |
dword-be: | REG_DWORD_BIG_ENDIAN | 42 or 0x2A; must fit u32. |
qword: | REG_QWORD | 42 or 0x2A; must fit u64. |
multi: | REG_MULTI_SZ | Comma-separated; \, escapes a literal comma. |
hex: / bin: | REG_BINARY | Hex bytes; :, -, and space separators are ignored. |
link: | REG_LINK | An absolute key path (a symlink target). |
none: | REG_NONE | No data (token must be empty after the prefix). |
When the substring before the first : is not a recognised keyword (for example http://host), the token is not treated as typed and inference applies:
| Token shape | Inferred type |
|---|---|
all decimal digits, fits u32 | REG_DWORD |
all decimal digits, fits u64 but not u32 | REG_QWORD |
0x… hex, ≤ 8 significant hex digits | REG_DWORD |
0x… hex, ≤ 16 significant hex digits | REG_QWORD |
| anything else (including empty) | REG_SZ |
Inference is intentionally broad, so any all-digit token becomes a number — a PIN, a zip code, or 007 becomes a REG_DWORD and loses its textual form. To force a string, prefix it with sz: (sz:007 stores the string 007; sz:dword:42 stores the literal string dword:42). Because the coercion is a known trap, reg set always echoes the resolved type on success, so a surprising conversion is never silent even under --quiet.
Global options #
Every subcommand accepts the following. Each behavioural toggle also has an environment-variable equivalent, so it can be set per-invocation (the flag) or once per shell or script (the variable). The flag wins over the variable, which wins over the built-in default.
| Option | Env var | Effect |
|---|---|---|
--json | REG_JSON=1 | Emit structured JSON instead of human text. watch emits JSON-lines. |
-v, --verbose | REG_VERBOSE=1 | More detailed output. |
-q, --quiet | — | Suppress non-essential output. (A surprising type coercion is still reported.) |
--sep=CHAR | REG_SEP | Path display separator: \ (default) or /. |
--help | — | Print help for the command and exit 0. |
--version | — | Print the version and exit 0. |
Mutating subcommands additionally accept:
| Option | Env var | Effect |
|---|---|---|
--layer NAME | REG_LAYER | Target layer for the write (default: the base layer). |
-y, --yes | REG_ASSUME_YES=1 | Skip the confirmation prompt on a destructive command. |
Destructive commands (del -r, restore, layer del) prompt for confirmation when standard input is a terminal, and auto-proceed when it is not — so interactive use is guarded while scripts are never blocked. Set -y/--yes or REG_ASSUME_YES=1 to skip the prompt explicitly.
Reading #
reg get <key> [value] #
Read the effective (resolved) value. With a value, prints that value's data; with no value, lists the key's effective values (the default @ value first, then named values sorted case-insensitively) — a shorthand for ls --values-only.
| Option | Effect |
|---|---|
-L, --layers | Annotate the value with the layer it resolved from and its sequence number. |
--raw | Write the value's raw bytes to standard output verbatim — for piping REG_BINARY data. |
--no-follow | Operate on a symlink key itself rather than following it to its target. |
The single-value default form prints data bare (no quotes; one element per line for REG_MULTI_SZ) so it pipes cleanly. -L shows the winning layer's provenance:
$ reg get Machine\System\KMES BufferCapacity
4194304
$ reg get Machine\App Theme -L
Theme = REG_SZ "dark" (layer: policy, seq 7)
Only the winning value is shown; the full shadowed stack beneath it is not yet exposed by any client ABI (the -L flag will render the whole stack unchanged once that primitive lands). See Layers for what "effective" resolves against.
reg ls <key> #
One-level listing of a key's immediate subkeys and values.
| Option | Effect |
|---|---|
-l, --long | Long form: for subkeys, child and value counts; for values, type and byte size. |
--keys-only | List subkeys only. |
--values-only | List values only. |
$ reg ls Machine\System\KMES -l
BufferCapacity = REG_QWORD 4194304 (8 bytes)
MaxEventSize = REG_DWORD 65536 (4 bytes)
reg tree <key> #
Recursively list the subkey tree rooted at key.
| Option | Effect |
|---|---|
--depth N | Limit recursion to N levels. |
--values | Include each node's values, not just its keys. |
$ reg tree Machine\System --depth 2
reg info <key> #
Print a key's metadata: leaf name, subkey and value counts, last-write time, hive generation, security-descriptor size, and the volatile and symlink flags.
| Option | Effect |
|---|---|
--no-follow | Inspect a symlink key itself rather than its target. |
$ reg info Machine\System\KMES
path Machine\System\KMES
name KMES
subkeys 0
values 4
last_write_ns 1717243800000000000
hive_generation 42
sd_size 164
volatile false
symlink false
Writing #
reg set <key> <value> <data> #
Create or update a value. The value name (or @) and the data token are both required. Writes are tagged with --layer (default: base). See value literals for the data syntax.
| Option | Effect |
|---|---|
--layer NAME | Write into layer NAME instead of the base layer. |
-p, --parents | Create any missing ancestor keys. |
--expected-seq N | Compare-and-swap guard: apply only if the value's current sequence is N; a mismatch exits 6 without writing. |
$ reg set Machine\App Build 4096
set Machine\App Build = REG_DWORD 4096 (layer: base)
$ reg set Machine\App Servers multi:alpha,beta --layer policy
--expected-seq supports lock-free read-modify-write: read the value with -L to learn its sequence, then write back with --expected-seq set to that number; if another writer changed it in between, your write fails cleanly instead of clobbering theirs.
reg new <key> #
Create a key with no values. Reports whether the key was created or already existed.
| Option | Effect |
|---|---|
--layer NAME | Create the key in layer NAME. |
-p, --parents | Create any missing ancestor keys. |
--volatile | Create a volatile (RAM-only) key that does not survive a reboot. |
$ reg new Machine\App\Cache --volatile
created Machine\App\Cache (layer: base)
reg del <key> [value] #
Delete a value, or a key. (Alias: delete.)
With a value, deletes this layer's entry for that value only — lower-layer entries resurface, because a per-layer delete is not a tombstone (use mask for that). With no value, deletes the key, which must be empty unless -r is given.
| Option | Effect |
|---|---|
--layer NAME | Delete from layer NAME. |
-r, --recursive | Delete the key and all its descendants (walks and removes children). |
-y, --yes | Skip the confirmation prompt (recursive deletes prompt on a terminal). |
$ reg del Machine\App Legacy
deleted value Legacy from Machine\App
$ reg del Machine\App\Temp -r
Recursively delete key Machine\App\Temp and all its contents? [y/N] y
deleted Machine\App\Temp (3 keys)
See Deleting keys and values for how a per-layer delete differs from a tombstone.
Masking and hiding #
These commands expose LCS's tombstone and hide primitives, which mask lower-precedence state rather than removing a layer's own entry — the distinction that makes layers revertible. The layer is chosen with --layer (default: base).
reg mask <key> [value] / reg unmask <key> [value] #
mask sets a tombstone: a per-value marker in the target layer that hides that value from all layers below it. unmask clears it.
| Option | Effect |
|---|---|
--layer NAME | The layer that carries the tombstone. |
--all | Blanket tombstone: mask all lower-layer values on the key (no value argument). |
A value is required unless --all is given.
$ reg mask Machine\App ApiKey --layer policy
set tombstone on Machine\App ApiKey (layer: policy)
$ reg mask Machine\App --all --layer policy
set blanket tombstone on Machine\App (layer: policy)
reg hide <key> / reg unhide <key> #
hide installs a HIDDEN path entry in the target layer, masking the key's existence in that layer and below. unhide removes that layer's path entry, letting the key reappear.
| Option | Effect |
|---|---|
--layer NAME | The layer that hides (or unhides) the key. |
$ reg hide Machine\App\Debug --layer policy
hid Machine\App\Debug (layer: policy)
Layers #
reg layer ls #
List layers with name, precedence, enabled state, and (informational) owner SID, ordered by descending precedence.
| Option | Effect |
|---|---|
-l, --long | Long form. |
$ reg layer ls
policy prec=100 enabled owner=S-1-5-32-544
base prec=0 enabled
reg layer new <name> #
Create a layer by writing its metadata key under Machine\System\Registry\Layers\.
| Option | Effect |
|---|---|
--precedence N | Precedence (higher wins). A precedence above 0 requires SeTcbPrivilege; the tool attempts it and reports any denial. |
--owner SID | Record an informational owner SID. |
--disabled | Create the layer disabled. |
$ reg layer new policy --precedence 100 --owner S-1-5-32-544
created layer policy (precedence 100, enabled)
reg layer set <name> #
Modify an existing layer's metadata.
| Option | Effect |
|---|---|
--precedence N | Change the precedence. |
--enable / --disable | Enable or disable the layer. |
--owner SID | Change the informational owner SID. |
reg layer del <name> #
Delete a layer — removes its metadata key; LCS tears down the layer's entries. Prompts for confirmation on a terminal.
$ reg layer del policy
Delete layer policy and all its entries? [y/N] y
deleted layer policy
Managing per-process private layer views is out of scope for reg; see private hives and layers.
Security descriptors #
reg sd <key> #
Show or set a key's security descriptor as SDDL, using the same codec as the sd tool, so its output is consistent. With no scope flags, the default is owner + group + DACL (a SACL requires the privileged ACCESS_SYSTEM_SECURITY right).
| Option | Effect |
|---|---|
--set SDDL | Apply the given SDDL. Without it, sd prints the current descriptor. |
--owner | Scope to the owner. |
--group | Scope to the group. |
--dacl | Scope to the DACL. |
--sacl | Scope to the SACL (needs ACCESS_SYSTEM_SECURITY). |
$ reg sd Machine\App
O:BAG:BAD:(A;;KA;;;BA)(A;;KR;;;WD)
$ reg sd Machine\App --set 'O:BAG:BAD:(A;;KA;;;BA)' --owner --dacl
set security descriptor on Machine\App
A security-descriptor change takes effect on future opens only (existing handles keep the access mask they were granted). Security changes are not layered and are not undone by layer removal — they mutate the key object directly. See Access control on keys.
Symlinks #
reg link <key> <target> #
Create a symlink key pointing at the absolute target key path. This creates a key with the immutable symlink flag and a default REG_LINK value holding the target. Requires KEY_CREATE_LINK and SeTcbPrivilege/Administrator; the tool attempts it and reports any denial.
| Option | Effect |
|---|---|
--layer NAME | Create the link key in layer NAME. |
$ reg link Machine\App\CurrentConfig Machine\App\Config\v3
linked Machine\App\CurrentConfig -> Machine\App\Config\v3 (layer: base)
get and info follow symlinks to their target by default; pass --no-follow to inspect the link key itself. See Advanced: registry links.
Watching #
reg watch <key> #
Arm a change watch and stream one event per change until interrupted (or until --count events). Each event faithfully means "something under the filter changed — re-read"; reg does not decode the change records' contents. With --json, emits one JSON object per line.
| Option | Effect |
|---|---|
--subtree | Watch descendant keys too, not just this key. |
--filter LIST | Comma-separated subset of value, subkey, sd (default: all). |
--count N | Exit after N events. |
$ reg watch Machine\System\KMES --subtree --filter value
changed: Machine\System\KMES
changed: Machine\System\KMES
See Watching for changes for why a service watches instead of polling.
Batch, export, backup, and restore #
Two paths move a subtree around: export/apply are the portable, reviewable path (a diffable text or JSON document); backup/restore are the exact, privileged path (an opaque kernel snapshot).
reg apply <file> #
Apply a batch of operations to one hive in a single transaction — all-or-nothing. All operations must target one hive (a cross-hive batch fails with exit 4). - reads standard input.
| Option | Effect |
|---|---|
--dir DIR | Apply every batch file in DIR (sorted), each as its own transaction. A missing or empty directory applies nothing and succeeds. |
--once-delete | Delete each batch file after it applies successfully (a drain). |
-y, --yes | Skip confirmation. |
The batch format is JSON (canonical and exact) or a line-oriented text format; apply auto-detects (a leading { or [ means JSON). Text-format input is not yet implemented — supply JSON, or generate one with reg export --json. Text export works for review.
$ reg export --json Machine\App - | reg apply -
applied 4 keys
reg export <key> [file] #
Dump a subtree to the batch format. Omit file (or pass -) to write to standard output. Human-readable text by default; --json emits the canonical exact form.
| Option | Effect |
|---|---|
--layer NAME | Export one layer's view (default: the effective state). |
$ reg export Machine\App app-config.reg
reg backup <key> <file> #
Write an opaque, exact, binary kernel snapshot of a key and its subtree to file. Requires SeBackupPrivilege.
$ reg backup Machine\System\KMES kmes.snap
backed up Machine\System\KMES -> kmes.snap
reg restore <key> <file> #
Replace a key and its entire subtree from a snapshot, in one transaction. Requires SeRestorePrivilege. Prompts for confirmation on a terminal.
$ reg restore Machine\System\KMES kmes.snap
Replace key Machine\System\KMES and its entire subtree from kmes.snap? [y/N] y
restored Machine\System\KMES from kmes.snap
See Backup and restore for when to reach for each path.
Output formats #
Default output is terse, human-readable, and grep-friendly. --json switches every command to structured output — a single self-contained JSON document, except watch, which emits JSON-lines (one object per event). In any value listing, the default (@) value is emitted first, then named values sorted case-insensitively.
Type formatting on read:
| Registry type | Human form | JSON form |
|---|---|---|
REG_SZ / REG_EXPAND_SZ | the string | {"type":"sz","data":"…"} |
REG_DWORD / REG_QWORD | decimal | {"type":"dword","data":42} |
REG_MULTI_SZ | one element per line | {"type":"multi","data":["a","b"]} |
REG_BINARY | hex | {"type":"binary","data":"deadbeef"} |
REG_LINK | the target path | {"type":"link","data":"…"} |
Exit status #
| Code | Meaning | Typical cause |
|---|---|---|
0 | Success. | — |
1 | Usage error. | Bad arguments; a required option missing. |
2 | Key or value not found. | ENOENT. |
3 | Access denied. | EACCES, EPERM — the kernel refused the operation. |
4 | Invalid specification. | Bad path, type, or literal; a cross-hive batch; a name too long (EINVAL, EXDEV, ENAMETOOLONG). |
5 | Syscall or source failure. | EIO, ETIMEDOUT, ENOSPC, ENOMEM. |
6 | Compare-and-swap conflict. | EAGAIN — an --expected-seq guard did not match; the value changed under you. |
A denial (3) reports the operation, the target, and, where relevant, the access that was required — in the style of the sd and token tools.
See also #
regman— the registry manual: what a key or value means, as opposed to what it is set to. Consult it before youreg set.- Keys, values, and types — the data model
regaddresses. - Layers — what "effective" resolves against, and what
--layer,mask, andhideoperate on. - Access control on keys — the check every
regoperation goes through.
Private hives and layers
Peios / Using Peios / Advanced
Everything in the core topic describes one shared registry that every process sees the same way (subject to access control). Private hives and private layers relax that: they let a particular caller see a registry that differs from everyone else's — the building block for sandboxing and container-style isolation.
This is an advanced feature, and a partly forward-looking one. The registry defines how a private hive or layer participates in resolution; but who is allowed to see one is decided by a thread's credentials, and that credential model is part of KACS and not yet fully specified. Treat this page as the shape of the feature, not a how-to.
Private hives #
A private hive is a hive that is visible only to threads whose credentials carry a matching scope — an opaque identity that marks "you are allowed to see this hive". To everyone else it does not exist.
The powerful case is shadowing. A private hive can take the same name as a global one — Machine\, say — and for a thread that carries the scope, the private hive is what Machine\ resolves to, not the global one. A sandboxed process can therefore be given an entirely separate Machine\ while every other process continues to see the real one, with no change to the paths anyone uses. That is complete registry isolation without a parallel namespace: same paths, different contents, decided by who is asking.
Private layers #
A private layer is a layer that is globally disabled — invisible in normal resolution — but attached to a specific thread's credentials. For that thread, and only that thread, the layer is treated as active and competes in the per-value contest at its precedence exactly like any other layer. Everyone else resolves as if it were not there.
This is the lighter-weight tool, for when you want a different value here and there rather than a whole separate hive:
- Per-session overrides — run one application with experimental settings without disturbing other sessions.
- Testing — inject configuration for one process without writing it into the shared registry.
- Sandboxing — give a confined process a slightly different view without standing up a private hive.
Private layers are per thread, not per process: because the attachment rides on a thread's credentials, different threads in the same process can carry different private layers (for instance, a service thread acting on behalf of a particular client). The number a thread can carry is bounded.
Authorisation lives in KACS #
The registry defines only the resolution behaviour — private hives are checked ahead of global ones, and a thread's private layers fold into its contests. The decision about whether a thread may join a scope or attach a layer belongs to KACS's credential model. One constraint is already clear and worth stating: attaching a layer that sits above others in precedence must be privileged, or an unprivileged process could attach an existing high-precedence policy layer to itself and gain a window onto configuration it should not see or influence. The isolation is only as strong as the rule that decides who may carry a scope or a private layer.
Where to go next #
For the resolution model these build on — precedence, recency, and the per-value contest — read Layers.
For where private hives are routed and served, read LCS and sources.
Transactions
Peios / Using Peios / Advanced
Most registry writes stand alone. But sometimes a set of changes only makes sense together — a role's keys and values are one coherent configuration, and a half-written version would be worse than none at all. A transaction makes several writes atomic: all of them commit, or none do. This is a developer-facing feature — you reach for it when writing to the registry, not when operating a running system — which is why it sits among the advanced topics.
Transactions, in one sentence #
A transaction groups many registry writes into one all-or-nothing commit, so other readers only ever see the complete change, never a partial one.
All or nothing #
Inside a transaction you perform a series of writes and then commit. At commit, they all take effect together. If you abort instead — or the process dies, or the transaction sits open too long and times out — none of them take effect. There is no partial application.
External readers see only committed state. Until you commit, your in-progress writes are invisible to everyone else; the instant you commit, the whole set appears at once. A service reading configuration never catches a transaction halfway. Within your own transaction you do see your own pending writes, so you can write a value and read it back before committing.
The canonical use: installing a role #
This is why role installation is clean. A role's entire configuration — its keys, its values, its layer — is written inside one transaction and committed as a unit. The role is never observed half-installed: either the whole role is there or none of it is. (Removing a role is the other mechanism — deleting its layer — covered with layers.)
Any consistent multi-key update wants the same treatment: change several related settings as one atomic step, rather than letting readers see an inconsistent in-between.
Limits worth knowing #
- One store at a time. A transaction is scoped to a single hive's store; it cannot span hives backed by different sources. Atomicity across separate stores is not offered.
- No nesting. Transactions are flat — there are no sub-transactions or savepoints.
- Bounded lifetime. An open transaction holds a write position, so it cannot be left open indefinitely; if it is not committed in time it is aborted automatically. This stops a stalled or abandoned writer from blocking everyone else's writes to that store.
- Abandonment is safe. Closing the handle without committing aborts cleanly, and a process dying does the same — there are no orphaned transactions left holding things up.
Transactions and layers #
A transaction provides atomicity; it is not a layer and it does not change which write wins. The two compose: a role install uses a transaction to apply its writes atomically and a layer to make them removable later. Keep them distinct — atomicity is "all together", layering is "which one wins, and how to revert".
Transactions are not conflict-detected at the value level. If two committed transactions wrote the same value, both succeed, and the usual rule settles it: the more recent write wins, exactly as in layer resolution. A transaction guarantees its own writes land together; it does not lock anyone else out of the values it touched. (For the case where you must not clobber a concurrent change, a conditional write lets a single write proceed only if the value has not changed since you read it.)
Where to go next #
For how a role uses a transaction and a layer together, read What layers are for.
For the recency rule that settles competing writes, read Layers.
Registry links
Peios / Using Peios / Advanced
A registry key can be a symbolic link to another key. Open a link key by its path and the registry follows it through to the target, handing you a handle on the target key. Links let one part of the namespace point at another, the way a filesystem symlink does.
Links are an advanced feature with a small, sharp set of rules, and they are the one exception to a property the rest of the topic relies on.
A link is a flag plus a target value #
Two pieces make a link, and both are needed:
- The key is marked as a link when it is created. This is a fixed property of the key.
- The key's default value, of type
REG_LINK, holds the target path — an absolute registry path the link points at.
When path resolution reaches a link key, the registry reads that target and continues resolving from there; the handle you get back refers to the target, not the link.
The one place the registry reads a value #
Keys, values, and types made a point of it: the registry stores a value's type and bytes but never interprets them. REG_LINK is the single exception. It is the one type the registry acts on itself — following it during path resolution — rather than handing it back untouched. Everything else remains opaque; links are the lone case where the store cares what a value says.
Managing the link itself #
Normally you want to follow a link. To operate on the link key itself — to change where it points or delete it — you open it with a flag that says "open the link, do not follow it". Without that flag every open lands on the target, which would make the link impossible to manage.
Creating a link is privileged #
Because a link silently redirects whoever opens it, creating one is restricted. It requires the KEY_CREATE_LINK right on the parent and a privileged caller (the system-trust privilege, or Administrator membership). A link is a small piece of trusted plumbing, not something an ordinary process gets to introduce into a path other callers will traverse.
There is one safety rule worth knowing: a link target is followed literally, and the CurrentUser\ convenience alias is not expanded inside it. This stops a link from redirecting a privileged service that resolves CurrentUser\ into the service's own user hive — a classic confused-deputy trap. Link targets route by their literal hive name. Resolution is also bounded by a hop limit, so a cycle of links fails rather than looping forever.
Layers can redirect a link #
The target is an ordinary layered value — the link key's default value — so it plays by the same rules as any other value. A higher-precedence or more-recent layer can write a different REG_LINK target and redirect the link; remove that layer and the original target resurfaces, by the usual automatic revert. And if a layer writes a default value that is not a REG_LINK onto a key that is still flagged as a link, resolution through it fails until the offending layer is removed or overridden. The link's identity is fixed at creation; its target is just configuration, and configuration is layered.
Where to go next #
For the opaque-value rule this is the exception to, read Keys, values, and types.
For how a layer can redirect or break a link, read Layers.
Files and directories
Peios / Using Peios / Peiosutils / Files and directories
This topic covers the commands that change the file system: creating files and directories, copying and moving them, removing them, and adjusting their size. If a command brings a file into existence, takes one out of it, or duplicates one, it is here.
This page names every command, and then explains the one theme that runs through the whole topic on Peios: what happens to a file's security descriptor when the file is created or copied.
The commands #
| Command | Purpose |
|---|---|
cp | Copy files and directories. |
mv | Move or rename files and directories. |
rm | Remove files, and with -r, directories. |
rmdir | Remove directories, but only empty ones. |
mkdir | Create directories. |
mkfifo | Create named pipes (FIFOs). |
mknod | Create special files — device nodes and FIFOs. |
touch | Create empty files, or update a file's timestamps. |
ln | Create hard links and symbolic links. |
link and unlink | The single-purpose, low-level versions of "make a hard link" and "remove a file". |
shred | Destroy a file's contents by overwriting them, so they cannot be recovered. |
dd | Copy data block by block, converting it on the way. |
df | Report how much space each file system has, and how much is free. |
du | Report how much space files and directories use. |
truncate | Shrink or extend a file to an exact size. |
mktemp | Create a temporary file or directory with a safely unique name. |
Every file has a security descriptor #
On Peios, every file and directory carries a security descriptor — the record of who owns it and who may do what to it. Access to a file is decided from its descriptor; see Security descriptors for the full picture.
That fact shapes two groups of commands in this topic.
Creating a file gives it a descriptor #
When mkdir, mkfifo, mknod, or touch creates a new object, that object needs a security descriptor. By default it gets one inherited from the directory it is created in — the new object picks up the access rules its parent directory hands down.
All four of these commands share one set of options — the creation flags — that let you set the new object's descriptor explicitly instead of taking the inherited default: name an owner, set a different group, lock the object so it does not inherit, give it an integrity label, or supply a complete descriptor. The flags are documented in full on the mkdir page; mkfifo, mknod, and touch each link back to it.
Copying a file makes a fresh descriptor — unless you preserve #
cp creates new files, and a cross-file-system mv does too. A brand-new copy gets a brand-new descriptor inherited from its destination directory — it does not carry the source file's security across by default.
When you do want the copy to keep the source's owner, access rules, timestamps, or other attributes, that is the job of the --preserve family of options. cp and mv share an identical --preserve surface; it is documented on the cp page.
Removing a file checks whether you may write it #
rm and shred both make a decision based on whether you can write a file. rm prompts before removing a file you cannot write to; shred's --force overrides a file's protection so it can be destroyed. Both judge "can you write this?" by a live access check against the file's security descriptor — not by any decorative metadata on the inode. Each command's page explains exactly where that check is made.
Where to start #
For copying — and for the whole --preserve model — read cp.
For creating files and directories, and the shared creation flags, read mkdir.
For removing things safely, read rm.
cp
Peios / Using Peios / Peiosutils / Files and directories
cp copies files and directories.
cp [options] source dest
cp [options] source... directory
cp [options] -t directory source...
In the first form, cp copies one source to one dest. In the other two, it copies any number of sources into a directory, keeping their names.
$ cp report.txt report-backup.txt
$ cp report.txt notes.txt /home/jack/archive/
Copying directories #
cp refuses to copy a directory unless you ask it to descend into one:
| Option | Effect |
|---|---|
-r, -R, --recursive | Copy directories, and everything inside them, recursively. |
-a, --archive | A faithful recursive copy: copy the whole tree, follow no symbolic links, and preserve every attribute. Equivalent to -d -R --preserve-all. |
-a is the option to use when you want the copy to be as close to the original as possible — same structure, same security, same timestamps. A plain -r copies the contents but, as the next section explains, gives the copies fresh security of their own.
What a copy's security descriptor is #
This is the part of cp that is specific to Peios, so it is worth being precise.
Every file carries a security descriptor — the record of who owns it and who may do what to it. When cp copies a file, it creates a new file, and that new file needs a descriptor.
By default, a copy gets a fresh descriptor — it does not inherit the source's. Specifically, a plain cp:
- gives the copy a descriptor inherited from the destination directory, exactly as if you had created a new file there;
- makes you the owner of the copy;
- gives the copy fresh timestamps — the copy's modification time is "now".
This is usually what you want. A file copied into your home directory should be governed by your home directory's rules and owned by you, regardless of where it came from. But when you need the copy to keep something from the source, you ask for it explicitly — with the --preserve family.
The --preserve family #
--preserve carries chosen attributes from the source onto the copy. The attributes you can preserve:
| Attribute | What it carries from the source |
|---|---|
owner | The owner SID. |
dacl | The whole DACL — every access rule on the file, inherited rules included. |
sacl | The whole SACL — the audit rules and the integrity label. |
daclni | The DACL with inherited rules stripped out — only the rules set explicitly on the source. |
saclni | The SACL with inherited rules stripped out. |
timestamps | The access and modification times. |
links | The hard-link structure — files hard-linked together in the source stay hard-linked in the copy. |
security | The security.peios.* extended attributes (other than the descriptor itself). |
xattrs | All other extended attributes. |
You rarely name those individually. These options select sensible sets:
| Option | Preserves |
|---|---|
-p | Timestamps only. |
--preserve | Timestamps only — the same as -p. |
--preserve=LIST | Exactly the comma-separated attributes you name. |
--preserve-all | Every attribute in the table above. |
--sd | owner, dacl, sacl — the full security descriptor, copied verbatim. |
--sd-explicit | owner, daclni, saclni — the source's explicitly set rules only. |
--no-preserve=LIST | Turns the named attributes back off — useful to subtract from a broader option. |
--sd versus --sd-explicit #
Both carry the source's security across; they differ in how they treat inherited rules.
--sdcopies the descriptor exactly — inherited rules and all. The copy ends up with precisely the access rules the source had, even if it lands in a directory that would have handed down something different. Use it when the copy must be a security-exact duplicate.--sd-explicitcopies only the rules that were set explicitly on the source, and lets the destination directory supply inheritance for the rest. Use it when copying a file into a different directory and you want the file's own tailored rules to come along while still picking up the new location's inherited rules.
For what "inherited" versus "explicit" means, see Inheritance.
When a preserve cannot be honoured #
A requested preserve is a firm instruction. If cp cannot carry an attribute across — for example, writing the copy's SACL requires the SeSecurityPrivilege privilege, and the caller does not have it — cp treats that as a hard error and the copy fails. It does not quietly produce a copy that is missing what you asked to preserve.
There is one exception, and it is a matter of definition rather than a softening of the rule. security and xattrs name extended attributes, and some filesystems have no extended attributes at all — FAT, 9p, and a squashfs image built without an attribute table are the ones you will meet. Copying from such a filesystem carries nothing, because there was nothing there: the preserve is satisfied vacuously, and the copy succeeds.
That is narrow on purpose. It applies only to reading the source. If the source does have extended attributes and the destination filesystem cannot hold them, the preserve genuinely failed and cp still stops:
# cp -a /home/jack/notes /mnt/usb-fat/
cp: failed to set extended attributes on '/mnt/usb-fat/notes': Operation not supported
The distinction matters most for -a, which requests both attribute classes on every file it touches. Without it, one xattr-less filesystem anywhere in a tree would abort the whole copy partway through.
Overwriting an existing destination #
By default, if the destination already exists, cp overwrites it. These options change that.
| Option | Effect |
|---|---|
-i, --interactive | Ask before overwriting an existing file. |
-n, --no-clobber | Never overwrite an existing file; skip it silently. |
-u, --update[=WHICH] | Overwrite only when the source is newer than the destination. WHICH can be all, none, or older. |
-f, --force | If an existing destination cannot be opened for writing, remove it and try the copy again. |
--remove-destination | Remove the destination before opening it, always — contrast --force, which only removes on failure. |
-b, --backup[=CONTROL] | Before overwriting, make a backup of the destination. |
Symbolic links in the source #
When a source is a symbolic link, cp can copy the link itself or the file it points to.
| Option | Effect |
|---|---|
-L, --dereference | Always follow symbolic links — copy the file they point to. |
-P, --no-dereference | Never follow symbolic links — copy the link itself. |
-H | Follow only the symbolic links named directly on the command line, not links found while recursing. |
-d | Copy links as links, and preserve hard-link structure. Short for --no-dereference --preserve=links. |
Other ways to copy #
cp can also produce something other than a plain duplicate.
| Option | Effect |
|---|---|
-l, --link | Create a hard link to the source instead of copying its data. |
-s, --symbolic-link | Create a symbolic link to the source instead of copying. |
--reflink[=WHEN] | Make a lightweight copy-on-write clone where the file system supports it. WHEN is always, auto, or never. |
--sparse[=WHEN] | Control whether runs of zero bytes are stored as holes rather than written out. WHEN is always, auto, or never. |
--attributes-only | Create the destination and copy its attributes, but not its data. |
Other options #
| Option | Effect |
|---|---|
-t, --target-directory=DIR | Copy every source into DIR. Useful when the directory is not the last argument. |
-T, --no-target-directory | Treat dest as a plain file even if it is a directory. |
--parents | Recreate the source's leading directories under the destination. |
-x, --one-file-system | Do not cross into a different file system while recursing. |
--strip-trailing-slashes | Strip any trailing slashes from each source argument before using it. |
-v, --verbose | Print each file as it is copied. |
--debug | Explain in detail how each file was copied. Implies -v. |
--progress | Show a progress bar. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was copied successfully. |
1 | A file could not be copied — including a requested --preserve that could not be honoured. |
mv
Peios / Using Peios / Peiosutils / Files and directories
mv moves files and directories from one place to another. Moving a file within the same directory, under a new name, is how you rename it.
mv [options] source dest
mv [options] source... directory
mv [options] -t directory source...
$ mv draft.txt report.txt # rename
$ mv report.txt notes.txt /home/jack/done/ # move into a directory
A move is either a rename or a copy-and-delete #
mv does its job in one of two ways, and which one decides what happens to the file's security.
Within one file system, a move is a rename. Nothing is copied: the file stays exactly where it physically is, and only its name and directory entry change. The file keeps its inode, and so it keeps its security descriptor unchanged — every owner, access rule, audit rule, and timestamp is exactly as before. A rename does not re-evaluate inheritance; a file does not pick up new rules just because it landed in a new directory.
Across file systems, a move is a copy followed by a delete. When the destination is on a different file system, mv cannot just rename — it copies the data to a new file on the destination, then removes the original. That new file is a genuinely new object, so, exactly as with cp, it gets a fresh security descriptor inherited from the destination directory unless you ask otherwise.
So: rename a file and its security is untouched; move it to another file system and the copy starts fresh.
Preserving security on a cross-file-system move #
For the copy-and-delete case, mv carries the same --preserve family that cp does — an identical set of options, behaving the same way. They let a cross-file-system move carry the source's owner, access rules, timestamps, and other attributes onto the new file instead of taking the destination directory's inherited default.
The full --preserve reference — the attribute list, --sd, --sd-explicit, --preserve-all, --no-preserve, and what happens when a preserve cannot be honoured — is on the cp page. Everything there applies to mv unchanged.
On a same-file-system move the --preserve options have nothing to do: a rename already keeps every attribute, because it is the same file.
Overwriting an existing destination #
By default mv overwrites an existing destination. These options change that. When more than one of -i, -f, -n is given, the last one wins.
| Option | Effect |
|---|---|
-i, --interactive | Ask before overwriting an existing file. |
-f, --force | Do not prompt before overwriting. |
-n, --no-clobber | Never overwrite an existing file. |
-u, --update[=WHICH] | Overwrite only when the source is newer. WHICH can be all, none, or older. |
-b, --backup[=CONTROL] | Make a backup of the destination before overwriting it. |
Other options #
| Option | Effect |
|---|---|
-t, --target-directory=DIR | Move every source into DIR. |
-T, --no-target-directory | Treat dest as a plain file even if it is a directory. |
--strip-trailing-slashes | Strip any trailing slashes from each source argument. |
-v, --verbose | Print each file as it is moved. |
--debug | Explain in detail how each file was moved. Implies -v. |
--progress | Show a progress bar. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was moved successfully. |
1 | A file could not be moved. |
rm
Peios / Using Peios / Peiosutils / Files and directories
rm removes files. With -r, it removes directories and everything inside them.
rm [options] file...
$ rm draft.txt old-notes.txt
$ rm -r build/
Removal is not reversible — once rm removes a file, its directory entry is gone. rm has a few safety behaviours, below, but the basic rule stands: think before you confirm.
Removing directories #
rm will not remove a directory unless you say so:
| Option | Effect |
|---|---|
-r, -R, --recursive | Remove directories and all of their contents, recursively. |
-d, --dir | Remove a directory, but only if it is already empty. |
The write-protected prompt #
By default — when you have not passed -f and rm is running interactively — rm prompts before removing a file you cannot write to:
$ rm policy.db
rm: remove write-protected regular file 'policy.db'?
The point of that prompt is to catch mistakes: a file you cannot write is one you most likely did not mean to delete.
"Write-protected" here means a real access check. rm decides it by asking the kernel whether your token may write the file — a live check against the file's security descriptor. It is not read off any decorative metadata on the inode; it is the same authority question that any write to the file would face. So the prompt is honest: if rm says a file is write-protected, it is a file the access model would genuinely stop you writing.
(The check is about write access to the file's contents. Whether you may actually remove the file is a separate question, decided by its own access right — so you can be prompted about a write-protected file and still be allowed to delete it. The prompt is a courtesy check, not the authorisation.)
Controlling the prompts #
| Option | Effect |
|---|---|
-f, --force | Never prompt. Also ignore files that do not exist instead of reporting them. |
-i | Prompt before every removal. |
-I | Prompt just once before removing more than three files or before a recursive removal. Less intrusive than -i, but still a check against a slip. |
--interactive[=WHEN] | Set the prompting explicitly: never, once, or always. |
The root failsafe #
rm refuses to recursively remove / — the root of the file system — because doing so by accident would be catastrophic.
| Option | Effect |
|---|---|
--preserve-root | Refuse to recurse on /. This is the default; you do not need to ask for it. |
--no-preserve-root | Disable the failsafe. Required, in full and unabbreviated, if you genuinely intend a recursive operation on /. |
Other options #
| Option | Effect |
|---|---|
-v, --verbose | Print each file as it is removed. |
--progress | Show a progress bar. |
rm and shred #
rm unlinks a file — it removes the name, and the space becomes free. The file's data may still be physically present on the device until something else overwrites it. When you need the contents to be genuinely unrecoverable, use shred, which overwrites the data before removing the file.
Exit status #
| Code | Meaning |
|---|---|
0 | Every requested removal succeeded (or, with -f, the file did not exist). |
1 | A file could not be removed. |
rmdir
Peios / Using Peios / Peiosutils / Files and directories
rmdir removes directories — but only empty ones. If a directory still has anything in it, rmdir leaves it alone and reports the failure.
rmdir [options] directory...
$ rmdir old-cache/
That refusal to touch a non-empty directory is the whole point of rmdir: it is the safe way to remove a directory you believe is empty. If it is not empty, you find out instead of losing the contents. To remove a directory together with everything inside it, use rm -r.
Options #
| Option | Effect |
|---|---|
-p, --parents | Remove the directory and then its ancestors, as long as each becomes empty in turn. rmdir -p a/b/c removes a/b/c, then a/b, then a. |
--ignore-fail-on-non-empty | Do not treat "directory not empty" as a failure. Other failures are still reported. Useful when clearing out whatever directories happen to be empty and leaving the rest. |
-v, --verbose | Print a line for each directory as it is removed. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every directory was removed. |
1 | A directory could not be removed — it was not empty, did not exist, or removal was refused. |
mkdir
Peios / Using Peios / Peiosutils / Files and directories
mkdir creates directories.
mkdir [options] directory...
$ mkdir reports
$ mkdir -p projects/2026/q2
Creating parent directories #
By default mkdir creates exactly the directories you name, and fails if a parent is missing. -p removes that restriction:
| Option | Effect |
|---|---|
-p, --parents | Create any missing parent directories along the way. Also makes mkdir succeed quietly if the directory already exists. |
-v, --verbose | Print a line for each directory as it is created. |
The creation flags #
A new directory needs a security descriptor — the record of who owns it and who may do what to it. By default, a new directory inherits its descriptor from the directory it is created in: it picks up the access rules its parent hands down. For most directories that is exactly right, and you pass no extra options at all.
When you need the new directory to have a different descriptor, the creation flags set it explicitly. These five flags are shared, unchanged, by mkdir, mkfifo, mknod, and touch — whenever one of those commands creates a new object, it accepts this same set. This page is the full reference for them.
| Flag | Argument | Effect |
|---|---|---|
--owner | SID | Make this principal the owner, instead of you. |
--group | SID | Set the object's group. |
--label | level | Give the object a mandatory integrity label. |
--no-inherit | — | Do not inherit rules from the parent directory; lock the object to its owner. |
--sddl | SDDL string | Supply the entire security descriptor directly. |
A SID argument is either a literal S-1-… value or a well-known alias — BA for the built-in administrators, for example.
--label takes one of these levels, lowest to highest: untrusted, low, medium, medium-plus, high, system, protected. The label applies the standard rule for files and directories — a principal below the object's level cannot write to it.
Inherited, or explicit #
The default and --no-inherit are the two ends of a choice.
- Inherited (the default) — the new directory tracks its parent. Rules the parent hands down apply to it, and as the parent's rules change the directory follows along. This keeps a tree of directories consistent without per-directory effort.
--no-inherit— the new directory takes nothing from its parent. Its access is locked to its owner, and it does not follow the parent's rules. Use it to carve out a directory whose security stands apart from the tree around it.
See Inheritance for the full model.
--sddl for a complete descriptor #
The four flags above are shortcuts for common adjustments. When you need to specify the whole descriptor — a particular set of access rules, audit rules, owner, and group all at once — --sddl takes it as a single string in the security-descriptor definition language:
$ mkdir --sddl 'O:BAG:BAD:(A;OICI;FA;;;BA)' secure-area
--sddl is mutually exclusive with --owner, --group, --label, and --no-inherit — it already says everything those flags would say, so combining them is rejected.
How the descriptor is applied #
mkdir creates the directory first and then applies the descriptor to it. The two steps are not separately visible — mkdir either finishes with the directory created and secured as asked, or fails as a whole. If applying the descriptor fails, the half-created directory is not left behind.
Exit status #
| Code | Meaning |
|---|---|
0 | Every directory was created. |
1 | A directory could not be created, or a creation flag could not be applied. |
mkfifo
Peios / Using Peios / Peiosutils / Files and directories
mkfifo creates named pipes, also called FIFOs.
mkfifo [options] name...
$ mkfifo events
A named pipe is a file that two programs use to pass a stream of data: one writes into it, the other reads from it, and the data flows through in order — first in, first out, which is what "FIFO" stands for. Unlike an ordinary pipe between two commands, a named pipe has a name on the file system, so the two programs do not have to be started together or be related to each other.
Setting the new pipe's security #
A FIFO is a file, and a new file needs a security descriptor. By default a new FIFO inherits its descriptor from the directory it is created in.
mkfifo accepts the shared creation flags — --owner, --group, --label, --no-inherit, and --sddl — to set that descriptor explicitly instead. They behave exactly as they do for mkdir; the full reference is on the mkdir page.
Exit status #
| Code | Meaning |
|---|---|
0 | Every FIFO was created. |
1 | A FIFO could not be created — for example, the name already exists. |
mknod
Peios / Using Peios / Peiosutils / Files and directories
mknod creates special files — files that are not data but an interface to something else: a device, or a named pipe.
mknod [options] name type [major minor]
$ mknod /dev/mydisk b 8 0
$ mknod backlog p
The type argument #
The type says what kind of special file to create:
| Type | Creates |
|---|---|
b | A block device — a device addressed in fixed-size blocks, such as a disk. |
c or u | A character device — a device addressed as a stream of bytes, such as a terminal. |
p | A named pipe (FIFO). |
For a block or character device (b, c, u) you must also give a major and a minor number. The major number selects which driver handles the device; the minor number tells that driver which specific device is meant. For a pipe (p), the major and minor numbers are omitted — a pipe has no driver behind it.
A major or minor number is read as hexadecimal if it begins with 0x, as octal if it begins with 0, and as decimal otherwise.
mknod NAME p and mkfifo NAME do the same thing; mkfifo exists as the clearer way to ask for just a pipe.
Setting the new file's security #
A special file needs a security descriptor like any other file, and by default a new one inherits its descriptor from the directory it is created in.
mknod accepts the shared creation flags — --owner, --group, --label, --no-inherit, and --sddl — to set that descriptor explicitly. They behave exactly as they do for mkdir; the full reference is on the mkdir page.
Exit status #
| Code | Meaning |
|---|---|
0 | The special file was created. |
1 | It could not be created — a missing or invalid type, missing device numbers, or a name that already exists. |
touch
Peios / Using Peios / Peiosutils / Files and directories
touch does one of two things, depending on whether the file already exists:
- if the file does not exist,
touchcreates it, empty; - if the file does exist,
touchupdates its timestamps to the current time.
touch [options] [file...]
$ touch notes.txt # create notes.txt if absent, else bump its times
The "create an empty file" behaviour is the everyday use. The "update timestamps" behaviour matters to tools that decide what work to do by comparing file times — touch is how you tell such a tool that a file should be considered freshly changed.
Which timestamps, and to what #
By default touch sets both the access time and the modification time to the current moment. These options narrow or redirect that.
| Option | Effect |
|---|---|
-a | Change only the access time. |
-m | Change only the modification time. |
--time=WORD | Choose which time to change by name: access, atime, or use for the access time; modify or mtime for the modification time. |
-d, --date=STRING | Use the time described by STRING instead of the current time. |
-t STAMP | Use the time given as [[CC]YY]MMDDhhmm[.ss] instead of the current time. |
-r, --reference=FILE | Use the timestamps of FILE instead of the current time. |
-h, --no-dereference | If the named file is a symbolic link, change the link's own timestamps rather than those of the file it points to. |
Not creating a file #
To use touch purely to update timestamps, and never to create anything:
| Option | Effect |
|---|---|
-c, --no-create | Do not create a file that does not exist. A named file that is absent is silently skipped. |
Setting a created file's security #
When touch creates a file, that new file needs a security descriptor, and by default it inherits one from the directory it is created in.
touch accepts the shared creation flags — --owner, --group, --label, --no-inherit, and --sddl — to set that descriptor explicitly. They behave exactly as they do for mkdir; the full reference is on the mkdir page.
These flags apply only on the create path. If touch is updating the timestamps of a file that already exists, there is no new descriptor to set, and the creation flags have nothing to do — they do not alter an existing file's security. Pair them with -c and they never take effect at all, since -c means nothing is ever created.
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was created or updated as requested. |
1 | A file could not be created or updated. |
ln
Peios / Using Peios / Peiosutils / Files and directories
ln creates links — extra ways to reach a file.
ln [options] target link_name
ln [options] target
ln [options] target... directory
ln [options] -t directory target...
$ ln -s /opt/app/bin/app /lcl/bin/app # a symbolic link
$ ln data.csv data-archive.csv # a hard link
Hard links and symbolic links #
There are two kinds of link, and they are genuinely different things.
A hard link is another name for the same file. The file's data exists once; a hard link is an additional directory entry pointing at it. Every hard link to a file is equal — none is "the original" — and the file's data stays as long as at least one hard link remains. Hard links cannot span file systems, and cannot be made to directories.
A symbolic link is a small file that simply contains a path. When something opens a symbolic link, it is redirected to whatever that path names. The symbolic link and its target are separate files: if the target is moved or removed, the link still exists but now points at nothing — it "dangles". Symbolic links can point anywhere, across file systems and at directories.
ln creates hard links by default, and symbolic links with -s. When in doubt, -s — a symbolic link's behaviour is the easier one to reason about.
The two also differ in how they are secured. A hard link is not a new file, so creating one involves no new security descriptor — every name for the file shares the file's existing one. A symbolic link is a new file, and like any new file it inherits its own descriptor from the directory it is created in.
The forms #
ln target link_name— create one link namedlink_name.ln target— create a link totargetin the current directory, with the same final name.ln target... directory— create a link to eachtargetinsidedirectory.ln -t directory target...— the same, with the directory named first.
Options #
| Option | Effect |
|---|---|
-s, --symbolic | Make symbolic links instead of hard links. |
-f, --force | Remove an existing destination file so the link can be created. |
-i, --interactive | Ask before removing an existing destination. |
-r, --relative | For a symbolic link, write the target as a path relative to the link's own location, rather than an absolute path. |
-n, --no-dereference | If link_name is itself a symbolic link to a directory, treat it as an ordinary file — replace the link rather than create something inside the directory it points to. |
-L, --logical | When the target is a symbolic link, link to what it points to. |
-P, --physical | Make a hard link to a symbolic link itself, rather than to its target. |
-t, --target-directory=DIR | Create all links inside DIR. |
-T, --no-target-directory | Treat link_name as a plain file, never as a directory to create links inside. |
-b, --backup[=CONTROL] | Back up an existing destination before replacing it. |
-S, --suffix=SUFFIX | Use SUFFIX for backup file names. |
-v, --verbose | Print the name of each link as it is created. |
Inspecting links afterwards #
readlink prints where a symbolic link points. The low-level link command makes a single hard link with no options, for scripts that want exactly that and nothing else.
Exit status #
| Code | Meaning |
|---|---|
0 | Every link was created. |
1 | A link could not be created. |
link and unlink
Peios / Using Peios / Peiosutils / Files and directories
link and unlink are the bare, single-purpose versions of two operations that ln and rm also perform. Each does exactly one thing, takes exactly its operands, and has no options. They exist for scripts that want one precise operation with no surrounding behaviour — no prompting, no link kinds to choose, no recursion.
link #
link file1 file2
link creates one hard link: a new name, file2, for the existing file file1. file1 must exist; file2 must not.
$ link data.csv data-archive.csv
That is the whole command. It performs the link operation directly and reports whether it succeeded. For anything more — symbolic links, replacing an existing destination, linking into a directory — use ln.
unlink #
unlink file
unlink removes one file: it deletes the directory entry file. It takes a single name and removes it directly.
$ unlink stale.lock
unlink does not prompt, does not recurse, and does not remove directories. For removing several files, removing directories, or any of the safety prompts, use rm.
Why they exist #
ln and rm are the commands to use day to day — they are flexible and have the safety behaviours. link and unlink are deliberately rigid: a script that calls unlink will only ever remove a single file, and a reviewer can see that at a glance. The narrowness is the feature.
Exit status #
Both commands:
| Code | Meaning |
|---|---|
0 | The operation succeeded. |
1 | The operation failed — a missing or already-existing operand, or a refused request. |
shred
Peios / Using Peios / Peiosutils / Files and directories
shred destroys a file's contents. It does not just unlink the file — it overwrites the data, repeatedly, so that what was there cannot be read back even with effort and specialised equipment.
shred [options] file...
$ shred -u -v secret-keys.txt
rm removes a file's name; the data may sit on the device, recoverable, until something else happens to overwrite it. shred overwrites the data on purpose. Use it when a file held something that must not be recoverable.
By default shred overwrites a file three times and then leaves the file in place — empty of its old contents but still present. That default exists because shred is often pointed at device files, which usually should not be removed. To remove the file after shredding it, pass -u.
When shredding actually works #
shred relies on one assumption: that writing to the file overwrites the same physical storage the old data occupied. On a traditional file system that holds. Several common file system designs break it — anything that writes new data to a fresh location instead of in place, keeps snapshots, journals file data, caches copies elsewhere, or compresses. On those, shred cannot guarantee the old data is gone.
Backups and remote mirrors are a separate matter entirely: shred cannot reach a copy of the file that lives somewhere else.
Treat shred as effective on a plain local file system and uncertain otherwise.
Options #
| Option | Effect |
|---|---|
-n, --iterations=N | Overwrite N times instead of the default of 3. |
-u, --remove[=HOW] | Remove the file after overwriting it. HOW controls how thoroughly the name itself is obscured before the file is unlinked. |
-z, --zero | Add a final pass that writes zeros, so the file does not visibly look shredded afterwards. |
-s, --size=N | Shred N bytes, rather than the whole file. Accepts size suffixes such as K, M, G. |
-x, --exact | Do not round the shredded size up to the next full block. Without it, shred rounds up so the block's slack space is overwritten too. |
--random-source=FILE | Take the random bytes for the overwrite passes from FILE. |
-v, --verbose | Show progress as each pass runs. |
-f, --force | Override a file's protection: when a live access check shows you cannot write the file, rewrite its security descriptor so the overwrite can proceed (see below). |
--force and a file's protection #
To overwrite a file, shred has to be able to write it. If the file's security descriptor does not grant you write access, an ordinary shred cannot proceed.
-f (--force) handles that case. When --force is given and a live access check shows you cannot currently write the file, shred rewrites the file's security descriptor to one that grants full access, and then overwrites the file. The reasoning is deliberate: the file is about to be destroyed anyway, and --force is you saying "override this file's protection."
--force can do this only when you are allowed to change the file's security in the first place — that is, when you own the file or hold the right to rewrite its access rules. If you cannot change the descriptor, --force cannot grant itself write access, and the shred still fails. --force overrides a file's protection; it does not manufacture authority you do not have.
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was shredded successfully. |
1 | A file could not be shredded. |
dd
Peios / Using Peios / Peiosutils / Files and directories
dd copies data from one place to another, a block at a time, and can transform the data as it goes. It is the tool for low-level copying — writing a disk image to a device, extracting an exact byte range out of a file, copying from or to a raw device.
dd [operand]...
$ dd if=disk.img of=/dev/mydisk bs=4M status=progress
How dd is told what to do #
dd does not take options in the usual -x form. It takes operands, each written name=value, in any order. With no if= operand it reads from standard input; with no of= operand it writes to standard output.
The operands #
| Operand | Meaning |
|---|---|
if=FILE | Read input from FILE. |
of=FILE | Write output to FILE. |
bs=BYTES | Read and write BYTES at a time. Sets both the input and output block size at once. |
ibs=BYTES | Input block size (default 512). |
obs=BYTES | Output block size (default 512). |
cbs=BYTES | Conversion block size — used by the block and unblock conversions. |
count=N | Stop after N input blocks, rather than reading to the end. |
skip=N | Skip N input blocks before copying. Also spelled iseek=N. |
seek=N | Skip N output blocks before writing. Also spelled oseek=N. |
conv=LIST | Apply the comma-separated conversions below. |
iflag=LIST | Comma-separated input flags — how the input is opened and read. |
oflag=LIST | Comma-separated output flags — how the output is opened and written. |
status=LEVEL | How much to report: progress (periodic stats while copying), noxfer (final counts only), none (silent). |
A size value accepts a unit suffix (K, M, G, …). So bs=4M is four mebibytes per block.
Conversions (conv=) #
| Value | Effect |
|---|---|
ucase / lcase | Convert the data to upper-case / lower-case. |
swab | Swap every adjacent pair of bytes. |
sync | Pad each input block out to its full size — with zeros, or with spaces alongside block/unblock. |
block / unblock | Convert between newline-terminated lines and fixed-size cbs records. |
sparse | Where an output block is all zeros, seek past it instead of writing it. |
excl | Fail if the output file already exists. |
nocreat | Fail if the output file does not already exist. |
notrunc | Do not truncate the output file when opening it. |
noerror | Continue past read errors instead of stopping. |
fdatasync / fsync | Flush the data — or the data and metadata — to storage before finishing. |
When dd creates the output file — an of= file that does not already exist — the new file needs a security descriptor, and it inherits one from the directory it is created in.
Input and output flags (iflag=, oflag=) #
| Flag | Applies to | Effect |
|---|---|---|
count_bytes | input | Interpret count=N as a number of bytes, not blocks. |
skip_bytes | input | Interpret skip=N as a number of bytes. |
fullblock | input | Wait for a full ibs of data on each read. |
seek_bytes | output | Interpret seek=N as a number of bytes. |
append | output | Open the output in append mode. |
direct | both | Use direct I/O, bypassing the cache. |
dsync / sync | both | Use synchronised I/O — for data, or for data and metadata. |
nonblock | both | Use non-blocking I/O. |
noatime | both | Do not update the file's access time. |
nocache | both | Ask the system to drop the file's cached pages. |
directory | both | Fail unless the file is a directory. |
What dd prints #
Unless status=none is set, dd prints a summary when it finishes:
16+0 records in
16+0 records out
67108864 bytes (67 MB, 64 MiB) copied, 1.234 s, 54.4 MB/s
The records in / records out counts are written complete+partial — full-sized blocks plus any short final block. status=progress prints the last line periodically during a long copy.
Exit status #
| Code | Meaning |
|---|---|
0 | The copy completed. |
1 | The copy failed — a bad operand, an I/O error, or a file that could not be opened. |
df
Peios / Using Peios / Peiosutils / Files and directories
df — "disk free" — reports, for each file system, how much space it has and how much is still available.
df [options] [file...]
With no arguments, df reports on every mounted file system. Given a file, it reports on the one file system that file lives on.
$ df -h
Filesystem Size Used Avail Use% Mounted on
/dev/sda2 456G 198G 235G 46% /
/dev/sda1 511M 12M 499M 3% /boot
The default columns #
Each line is one file system:
- Filesystem — the device or source backing it.
- Size — its total capacity.
- Used — space in use.
- Avail — space still available.
- Use% — used space as a percentage.
- Mounted on — where it is attached in the directory tree.
Choosing the units #
By default sizes are shown in blocks. These options change that.
| Option | Effect |
|---|---|
-h, --human-readable | Show sizes with unit suffixes — 456G, 12M — using powers of 1024. |
-H, --si | Likewise, but using powers of 1000. |
-B, --block-size=SIZE | Show sizes scaled to SIZE — -BM reports in megabytes. |
-k | Use 1024-byte blocks. |
Choosing what to report #
| Option | Effect |
|---|---|
-a, --all | Include dummy and otherwise-hidden file systems that df would normally skip. |
-l, --local | Report only local file systems, skipping network-mounted ones. |
-t, --type=TYPE | Report only file systems of type TYPE. |
-x, --exclude-type=TYPE | Report everything except file systems of type TYPE. |
--total | Add a final line giving the grand total across everything reported. |
Changing the output #
| Option | Effect |
|---|---|
-i, --inodes | Report inode counts — total, used, and free — instead of block space. A file system can run out of inodes while it still has space. |
-T, --print-type | Add a column showing each file system's type. |
--output[=LIST] | Replace the default columns with exactly the fields named in LIST. With no list, print every available field. |
-P, --portability | Use a fixed, simple column layout that does not wrap a long device name onto its own line. |
Sync behaviour #
| Option | Effect |
|---|---|
--sync | Flush pending writes before reading the usage figures, for the most up-to-date numbers. |
--no-sync | Do not flush first. This is the default and is faster. |
Exit status #
| Code | Meaning |
|---|---|
0 | The report was produced. |
1 | A failure — for example, a named file does not exist, or the table of file systems could not be read. |
du
Peios / Using Peios / Peiosutils / Files and directories
du — "disk usage" — reports how much space files and directories occupy. Where df tells you about a whole file system, du tells you where the space has actually gone.
du [options] [file...]
$ du -sh /home/jack/projects
4.2G /home/jack/projects
With no arguments, du works on the current directory. By default it walks the directory tree, prints the cumulative size of each directory (a directory's size includes everything under it), and ends with the total for the argument itself. Individual files are not listed unless you ask for them.
What to count and how deep #
| Option | Effect |
|---|---|
-a, --all | List every file, not only directories. |
-s, --summarize | Print only a single total for each argument — no per-subdirectory breakdown. |
-d, --max-depth=N | Show totals only down to N levels below each argument. -d 0 is the same as -s. |
-S, --separate-dirs | Give each directory's own size, excluding its subdirectories. |
-c, --total | Add a final grand-total line. |
--inodes | Count inodes used rather than space used. |
What "size" means #
| Option | Effect |
|---|---|
-h, --human-readable | Sizes with unit suffixes — 4.2G — using powers of 1024. |
--si | Likewise, using powers of 1000. |
-B, --block-size=SIZE | Scale sizes to SIZE. |
-k / -m | Use 1024-byte / 1024×1024-byte units. |
--apparent-size | Report the apparent size of files — how much data they contain — rather than the disk space they occupy. The two differ for sparse files and because of block rounding. |
-b, --bytes | Report plain byte counts. Equivalent to --apparent-size --block-size=1. |
Following symbolic links #
By default du does not follow symbolic links.
| Option | Effect |
|---|---|
-L, --dereference | Follow all symbolic links and count what they point to. |
-D, -H, --dereference-args | Follow only the symbolic links named directly on the command line. |
-P, --no-dereference | Follow no symbolic links. This is the default. |
-x, --one-file-system | Do not cross into a different file system while walking. |
-l, --count-links | Count a hard-linked file every time it is encountered, instead of once. |
Limiting the output #
| Option | Effect |
|---|---|
--exclude=PATTERN | Skip files and directories whose name matches PATTERN. |
--exclude-from=FILE | Skip everything matching any pattern listed in FILE. |
-t, --threshold=SIZE | Show only entries at least SIZE (or, with a negative SIZE, at most that big). |
--files0-from=F | Take the list of files to measure from F, NUL-separated. - means standard input. |
--time[=WORD] | Also show a timestamp — by default the latest modification time anywhere in the directory. |
-0, --null | End each output line with a NUL character instead of a newline. |
-v, --verbose | Report extra detail about what is being processed. |
Exit status #
| Code | Meaning |
|---|---|
0 | The report was produced. |
1 | A file or directory could not be accessed. |
truncate
Peios / Using Peios / Peiosutils / Files and directories
truncate sets a file's size to an exact figure — shrinking it, or extending it.
truncate [options] file...
$ truncate -s 0 logfile # empty the file
$ truncate -s 1G disk.img # make a 1 GiB file
Shrinking a file discards everything past the new size. Extending it adds space that reads back as zero bytes; that added space is a hole — it costs no actual storage until something writes real data into it, which is how truncate -s 1G can create a "1 GiB file" instantly.
By default, truncate creates a file that does not yet exist (as an empty file, then sized as asked). A file created this way needs a security descriptor, and it inherits one from the directory it is created in.
Specifying the size #
-s (--size) takes the size. A plain number sets the size outright. A unit suffix scales it: K, M, G, T, … where the bare letter is a power of 1024 (K = 1024) and the letter with B is a power of 1000 (KB = 1000).
A size may also begin with a prefix, which makes it relative to the file's current size:
| Prefix | Meaning |
|---|---|
+ | Extend by this much. |
- | Reduce by this much. |
< | Shrink to this size only if the file is currently larger ("at most"). |
> | Extend to this size only if the file is currently smaller ("at least"). |
/ | Round the size down to a multiple of this number. |
% | Round the size up to a multiple of this number. |
$ truncate -s +4K notes.txt # 4 KiB larger than it is now
$ truncate -s '<1M' notes.txt # cap it at 1 MiB, leave smaller files alone
Options #
| Option | Effect |
|---|---|
-s, --size=SIZE | Set or adjust the size to SIZE, as described above. |
-r, --reference=RFILE | Base the size on the current size of RFILE instead of giving a number. Combine with a relative -s to mean "the size of RFILE, adjusted". |
-c, --no-create | Do not create a file that does not exist; skip it instead. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was resized. |
1 | A file could not be resized — a bad size, or a file that could not be opened. |
mktemp
Peios / Using Peios / Peiosutils / Files and directories
mktemp creates a temporary file — or, with -d, a temporary directory — gives it a name that is not already taken, and prints that name.
mktemp [options] [template]
$ mktemp
/tmp/tmp.QV8x3kZ1aR
$ mktemp -d
/tmp/tmp.7bNc0wPe2L
The reason to use mktemp rather than inventing a name yourself is safety. A script that picks a fixed name like /tmp/work has two problems: two copies of the script collide, and a name an attacker can predict is a name an attacker can interfere with before the script gets to it. mktemp chooses an unpredictable name and creates the file in the same step, so nothing can slip in between. It prints the name it used, for the script to capture:
work=$(mktemp)
# ...use "$work"...
rm "$work"
The temporary file or directory mktemp creates is a new object, and a new object needs a security descriptor; it inherits one from the directory it is created in.
Templates #
The name comes from a template — a name with a run of trailing X characters, each of which mktemp replaces with a random character. More Xs means a wider range of possible names.
$ mktemp report-XXXXXX.txt
report-a8Kp2Q.txt
With no template, mktemp uses a built-in default that places the file in the temporary directory. A template needs at least three Xs.
Options #
| Option | Effect |
|---|---|
-d, --directory | Create a directory instead of a file. |
-p, --tmpdir[=DIR] | Create the temporary object inside DIR. With no DIR, use the directory named by the TMPDIR environment variable, or the system temporary directory. The template is then just the final name component. |
--suffix=SUFFIX | Append SUFFIX after the random part of the name — for giving the file an extension. |
-u, --dry-run | Do not create anything; just print a name that would be free. This reintroduces the race the command exists to avoid — use it only when you genuinely cannot create the object yet. |
-q, --quiet | Print no error message if creation fails; rely on the exit status. |
Exit status #
| Code | Meaning |
|---|---|
0 | The temporary file or directory was created; its name was printed. |
1 | Creation failed — a bad template, or a directory that does not exist. |
sd
Peios / Using Peios / Peiosutils / Files and directories
sd is the command-line tool for working with security descriptors on files. Everything this topic describes — owners, DACLs, ACEs, the SACL, integrity labels, inheritance — sd is how you read it and change it from a shell.
sd subcommand path [arguments]
$ sd show ./report.txt
$ sd allow ./report.txt alice:read
$ sd owner ./report.txt BA
Where ls -l shows a file's owner and a summary, and cp --preserve carries a descriptor across, sd is the tool that edits the descriptor directly.
The subcommands #
| Group | Subcommand | Does |
|---|---|---|
| Inspect | show | Print the descriptor on a path. |
check | Simulate an access check against the path. | |
| DACL | allow | Add an allow rule for one or more principals. |
deny | Add a deny rule for one or more principals. | |
remove | Drop every DACL rule for the named principals. | |
| Auditing | audit | Add an audit rule to the SACL. |
unaudit | Drop every SACL rule for the named principals. | |
| Ownership | owner | Set the descriptor's owner. |
group | Set the descriptor's group. | |
| Integrity | integrity | Set the mandatory integrity label. |
| Inheritance | inherit | Turn inheritance protection on or off. |
reset | Drop the file's own rules and re-inherit from the parent. | |
propagate | Push inheritance down to descendants. | |
| Wholesale | set | Replace the entire descriptor at once. |
Naming a principal #
Wherever a subcommand takes a PRINCIPAL, it accepts any of:
| Form | Example | Meaning |
|---|---|---|
@self | @self | The user SID of the token running sd. |
@owner | @owner | A placeholder that the access check substitutes with the file's own owner. |
| A well-known label | Everyone, Administrators, LocalSystem | A named built-in principal. |
| A two-letter alias | WD, BA, SY | The short alias for a well-known principal. |
| A raw SID | S-1-5-32-544 | Any SID, written out in full. |
See SIDs for what these are.
Naming permissions #
Wherever a subcommand takes PERMS, several notations are accepted, and may be mixed:
| Form | Example | Meaning |
|---|---|---|
| Single letters | rwx, r, m | r read, w write, x execute, d delete, m modify, f full, c change-permissions, o take-ownership. |
| Words | read,write, modify | The same set, spelled out. |
| Fine-grained names | read-data,append,traverse | Individual low-level rights, for precise rules. |
| Raw hex | 0x1F01FF | An access mask written directly. |
Run letters together (rwx) or separate names with commas (read,write,execute).
Inspecting #
sd show #
Prints the descriptor on a path — the owner, the group, the DACL, the SACL.
$ sd show ./report.txt
| Flag | Effect |
|---|---|
--sddl | Render the descriptor as an SDDL string. |
--raw | Render SIDs in raw S-1-… form only. |
--label | Render SIDs as their labels where known. |
--all | Verbose — decode every flag and show raw masks alongside. |
--json | Emit JSON. |
sd check #
Simulates an access decision: "would this access be allowed?" — without performing it.
$ sd check ./report.txt write
$ sd check ./report.txt read --pid 4821 --explain
| Argument / flag | Effect |
|---|---|
PERMS | The access to test for. |
--pid PID | Check against process PID's token instead of your own. |
--explain | Show why the decision came out as it did — the rule-by-rule walk. |
sd check is the first thing to reach for when an access is denied and you do not know why. --explain walks the descriptor the same way the kernel does.
Changing the DACL #
The DACL is the list of allow and deny rules. These three subcommands edit it.
sd allow and sd deny #
Add an allow (or deny) rule for one or more PRINCIPAL:PERMS pairs.
$ sd allow ./report.txt alice:read bob:rw
$ sd deny ./report.txt Everyone:write
| Flag | Effect |
|---|---|
--flags LIST | ACE inheritance flags — CI container-inherit, OI object-inherit, NP no-propagate, IO inherit-only; none clears them. |
--if EXPR | Make it a conditional rule, applied only when EXPR is true. |
--replace | Drop any existing rules for this principal and kind first, instead of appending. |
--recursive, -r | Apply to every descendant of the path. |
Remember that a deny rule, when it matches, wins over any allow — see DACL evaluation.
sd remove #
Drops every DACL rule — allow and deny — for the named principals.
$ sd remove ./report.txt bob carol
| Flag | Effect |
|---|---|
--allow-empty | Permit the result to be a present-but-empty DACL, which denies everyone. Without this, sd refuses to produce one. |
--recursive, -r | Apply to every descendant. |
Auditing #
sd audit and sd unaudit #
The SACL holds audit rules — see The SACL. sd audit adds one; sd unaudit drops every SACL rule for the named principals.
$ sd audit ./secrets.db Everyone:write:failure
An audit spec is PRINCIPAL:PERMS:WHEN, where WHEN is success, failure, or both — which outcomes to log. sd audit takes the same --flags, --if, --replace, and -r options as sd allow.
Ownership #
sd owner and sd group #
Set the descriptor's owner or group SID.
$ sd owner ./report.txt alice
$ sd group ./report.txt Administrators
Both take a single PRINCIPAL and accept -r. Changing an owner is itself an access-controlled act — see Ownership.
Integrity #
sd integrity #
Sets the file's mandatory integrity label.
$ sd integrity ./report.txt high
The level is one of untrusted, low, medium, medium-plus, high, system, protected. The standard catalog is five levels (untrusted, low, medium, high, system); medium-plus (RID 8448) and protected (RID 20480) are non-standard Windows-compatibility levels — the kernel compares any S-1-16-<rid> numerically.
| Flag | Effect |
|---|---|
--policy BITS | The label's policy bits, comma-separated: NW no write-up, NR no read-up, NX no execute-up. |
--recursive, -r | Apply to every descendant. |
For what the label does, see Mandatory integrity control.
Inheritance #
sd inherit #
Turns inheritance protection on or off — the + mark ls -l shows.
$ sd inherit off ./report.txt # lock the file; stop inheriting
$ sd inherit on ./report.txt # let it inherit from its parent again
inherit on lets the file inherit rules from its parent directory. inherit off protects the file — it keeps its current rules and stops tracking the parent.
| Flag | Effect |
|---|---|
--strip-inherited | When turning protection off, also drop the inherited rules already on the file. |
--recursive, -r | Apply to every descendant. |
sd reset #
Drops the file's own explicit rules and rebuilds its DACL purely from what the parent directory hands down — returning the file to "inherits everything".
sd propagate #
Pushes this directory's inheritable rules down into its descendants, refreshing what they inherit. Use it after changing a directory's rules so the children pick up the change.
See Inheritance for the full model.
Replacing the whole descriptor #
sd set #
Replaces the entire descriptor in one step.
$ sd set ./report.txt 'O:BAG:BAD:P(A;;FA;;;BA)(A;;0x1200a9;;;BU)'
| Argument / flag | Effect |
|---|---|
SDDL | The new descriptor as an SDDL string. - reads it from standard input. |
--binary FILE | Instead of SDDL, read the raw descriptor bytes from FILE (- for standard input). |
--components LIST | Override which parts of the descriptor (owner, group, DACL, SACL) the operation writes. |
Common flags #
These apply across the subcommands:
| Flag | Effect |
|---|---|
--recursive, -r | Apply the change to every descendant of the path. |
--no-follow-symlinks, -P | Operate on a symbolic link itself, not the file it points to. |
--json | Emit JSON instead of human-readable output. |
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded — or, for sd check, the access would be allowed. |
| non-zero | The operation failed, the path was unreachable, or — for sd check — the access would be denied. |
Listing and paths
Peios / Using Peios / Peiosutils / Listing and paths
Before you can do anything with a file you usually have to find it — see what a directory holds, learn one file's details, turn a relative name into an absolute one. This topic covers the commands for that: listing directories, inspecting a single file, and resolving path names.
This page names every command in the topic, says in one line what each is for, and points you at the page that covers it in full.
The commands #
| Command | Purpose |
|---|---|
ls | List the contents of a directory. The everyday command for "what's in here?". |
dir and vdir | ls with a fixed output style. dir lists in columns; vdir lists in long format. |
stat | Show the detailed status of one file — its type, size, timestamps, and raw inode metadata. |
pwd | Print the directory you are currently working in. |
readlink | Print where a symbolic link points. |
realpath | Resolve a path to its canonical, absolute form — every symlink followed, every . and .. removed. |
basename | Strip the directory part off a path, leaving just the final name. |
dirname | The opposite of basename — strip the final name, leaving the directory. |
pathchk | Check whether a path name is valid and portable before you try to create it. |
dircolors | Produce the colour settings that ls uses when it colours its output. |
These commands and file security #
Most commands in this topic work purely on path strings — they never touch the file the path names. basename, dirname, realpath, and pathchk are all string operations.
Two report on real files, and they relate to file security differently:
ls -lsurfaces security-descriptor information directly. On Peios a file's access is governed by its security descriptor — the record of who owns the file and who may do what to it. The long listing shows the owner and the parts of the descriptor that fit in a one-line-per-file format.statis a low-level diagnostic. It dumps the raw contents of a file's inode. The inode carries some numeric fields — an owner id, a group id, a mode value — that the Peios access model does not consult.statreports them because it reports the inode verbatim, but they are not the file's access policy.
Neither command prints the full security descriptor; inspecting that is a job for the dedicated security tooling. If a line of ls -l looks unfamiliar, or you want to know why stat shows fields that "don't count", the explanation is in Security descriptors.
Where to start #
If you want the everyday "what's in this directory" command and the meaning of every column it prints, read ls.
If you need the full detail of one specific file, read stat.
If you are writing a script and need to take a path apart or canonicalise it, the string commands — basename, dirname, realpath — are what you want.
ls
Peios / Using Peios / Peiosutils / Listing and paths
ls lists the contents of a directory. It is the command you use to answer "what is in here?" — and, with the right options, "who owns these files, how big are they, and when were they last changed?".
ls [options] [file...]
Each file is a directory to list the contents of, or a single file to report on. With no file argument, ls lists the directory you are currently in.
$ ls
build.sh notes.txt projects
The default listing #
Run on its own, ls prints the names of the entries in a directory and nothing else. Three rules shape that default:
- Hidden entries are skipped. A name beginning with a dot (
.config,.cache) is hidden.lsleaves hidden entries out unless you ask for them with-aor-A. - Entries are sorted by name. Alphabetical order, case-sensitive. Other sort orders are available.
- The layout adapts to where output goes. When
lsis writing to a terminal it arranges names into columns sized to the terminal width. When output is redirected to a file or a pipe it prints one name per line, so the result is easy for another program to read.
Everything past this default is opt-in. The rest of this page is the options, grouped by what they do.
The long format #
-l (or --long) switches ls into the long format: one entry per line, each with its mode, owner, size, modification time, and name.
$ ls -l
total 28
d-- S-1-5-21-9f3a-1c4e-7b20-1001 4096 May 14 09:12 projects
-x- S-1-5-21-9f3a-1c4e-7b20-1001 8344 May 16 17:40 build.sh
--- S-1-5-21-9f3a-1c4e-7b20-1001 219 May 15 11:03 notes.txt
--+ S-1-5-18 12048 May 10 22:15 policy.db
The total line at the top reports the combined disk space used by the listed files, measured in blocks.
Each entry line has five columns: mode, owner, size, modification time, name. There is no permission-bits column and no group column — the reason is below.
The mode column #
The mode column is three characters: [type][executable][protected].
The first character is the file's type:
| Character | Type |
|---|---|
- | Regular file |
d | Directory |
l | Symbolic link |
p | Named pipe (FIFO) |
s | Socket |
c | Character device |
b | Block device |
The second character is the executable mark. It is x when the entry is a regular file that is marked as an executable, and - otherwise (it is always - for directories, links, and other non-regular entries).
The executable mark describes the file, not your relationship to it. x means "this file is runnable code." It does not mean that you, specifically, are allowed to run it — whether a given principal may execute a file is a separate access decision made against the file's security descriptor. A file can show x and still be one you cannot run, and the reverse. See Access decisions for how execution is actually authorised.
The third character is the protected mark. It is + when the file's access control is inheritance-protected, and - otherwise.
A - here means the file's access control is inherited from the directory it lives in: it tracks its parent. A + means the file's access control has been set deliberately and locked, so it no longer tracks the parent directory. The + is the signal that someone has tailored this file's security on purpose. See Inheritance.
So in the example above: projects is a directory (d--); build.sh is an executable file (-x-); notes.txt is an ordinary file inheriting its security from its directory (---); and policy.db is an ordinary file whose access control has been protected (--+).
Why there are no permission bits #
A long listing does not carry a fixed block of permission characters. On Peios, what a principal may do to a file is decided by the file's security descriptor — a record that can contain any number of entries granting or denying specific rights to specific principals. There is no small, fixed set of bits that captures that, so ls -l does not pretend there is.
The mode column tells you the file's type and two facts about it (is it executable, is its security protected). To see who may do what, look at the security descriptor itself with stat or the dedicated security tooling. The concepts are in Security descriptors.
The owner column #
The owner column is the SID of the file's owner — shown in full, in the S-1-… form. ls does not translate SIDs into account names; it prints the identifier exactly as the security descriptor stores it. A well-known owner such as the system itself appears as its fixed SID (S-1-5-18); an ordinary account appears as a longer domain-style SID.
For what a SID is and how to read one, see SIDs. For what "owner" means and what it grants, see Ownership.
If ls cannot read a file's descriptor — for a dangling symlink, say — the owner column shows ? and the mode column shows the type character followed by ??.
Choosing what to list #
By default ls shows non-hidden entries, and for a directory argument it shows the directory's contents. These options change that.
| Option | Effect |
|---|---|
-a, --all | Show hidden entries too, including . (the directory itself) and .. (its parent). |
-A, --almost-all | Show hidden entries, but leave out . and ... |
-d, --directory | List a directory as an entry in its own right, instead of listing its contents. Useful with -l to see a directory's own owner and mode. |
-R, --recursive | Descend into every subdirectory and list it too. |
-B, --ignore-backups | Skip entries whose name ends in ~. |
--ignore=PATTERN | Skip entries whose name matches the shell pattern. May be given more than once. |
--hide=PATTERN | Like --ignore, but overridden by -a or -A — so a hide rule can be cancelled by asking to see everything. |
Sorting #
ls sorts by name by default. These options change the sort key; -r reverses whatever order is in effect.
| Option | Sort order |
|---|---|
-t | By modification time, newest first. |
-S | By size, largest first. |
-X | By file extension, alphabetically. |
-v | By version: runs of digits in the name sort numerically, so f2 comes before f10. |
-U | No sorting at all — entries appear in the order the directory stores them. Fast for very large directories. |
-r, --reverse | Reverse the current sort. |
--sort=WORD | Choose the sort key by name: none, time, size, extension, version, or width. |
--group-directories-first | List directories before other entries, with each group sorted normally. |
Output layout #
When several entries fit on a line, ls has to decide how to arrange them.
| Option | Layout |
|---|---|
-C | Columns, filled top-to-bottom. The default when writing to a terminal. |
-x | Columns, filled left-to-right (across the rows) instead. |
-1 | One entry per line. The default when output is not a terminal. |
-m | All entries on as few lines as possible, separated by commas. |
-l, --long | The long format described above. |
--format=WORD | Choose the layout by name: across, commas, horizontal, long, single-column, or vertical. |
-w, --width=COLS | Assume the terminal is COLS columns wide instead of detecting it. 0 means unlimited. |
File-type indicators and colour #
These options make a listing easier to scan by marking entries with a symbol or a colour.
| Option | Effect |
|---|---|
-F, --classify | Append a type symbol to each name: / for a directory, @ for a symlink, | for a FIFO, = for a socket, and * for an executable file. |
--file-type | The same, but without the * on executables. |
-p | Append only the / on directories. |
--indicator-style=WORD | Choose the indicator set: none, slash, file-type, or classify. |
--color[=WHEN] | Colour entries by type. WHEN is auto (colour only when writing to a terminal — the usual choice), always, or never. |
The colours ls uses are configurable. dircolors generates the colour settings and explains how to install them.
File sizes #
In the long format, and with -s, sizes are printed in bytes by default. These options rescale them.
| Option | Effect |
|---|---|
-h, --human-readable | Print sizes with a unit suffix — 4.0K, 234M, 56G — using powers of 1024. |
--si | Like -h, but using powers of 1000, so the suffixes are decimal. |
-s, --size | Print the disk space each entry occupies, in blocks, before its name. |
-k, --kibibytes | Use 1024-byte blocks for the size figures and directory totals. |
--block-size=SIZE | Scale every size by SIZE (for example --block-size=1M to count in megabytes). |
Timestamps #
The long format shows the modification time by default. These options change which timestamp is shown — and, when sorting by time, which one is sorted on.
| Option | Timestamp |
|---|---|
-u | Access time — when the file was last read. |
-c | Status-change time — when the file's metadata last changed. |
--time=WORD | Choose the timestamp by name: atime/access, ctime/status, mtime/modification, or birth/creation. |
--time-style=STYLE | Choose the date format: full-iso, long-iso, iso, locale, or +FORMAT for a custom layout. |
--full-time | Shorthand for the long format with --time-style=full-iso — a complete, unabbreviated timestamp. |
Symbolic links #
By default ls reports on a symbolic link itself — its type is l, its size is the length of the link text. These options make it report on the link's target instead.
| Option | Effect |
|---|---|
-L, --dereference | Always report on the file a link points to, not the link. |
-H, --dereference-command-line | Dereference only the links named directly on the command line, not links found inside a listed directory. |
--dereference-command-line-symlink-to-dir | Dereference a command-line link only when it points to a directory. |
readlink and realpath are the dedicated tools for inspecting and resolving links.
Other options #
The long-tail options, each in one line.
| Option | Effect |
|---|---|
-i, --inode | Print each entry's index number (its inode number). |
-Z, --context | Print each entry's security context. Available only when the build enables it. |
-b, --escape | Print non-printable characters in names using C-style backslash escapes. |
-q, --hide-control-chars | Replace non-printable characters in names with ?. The default when writing to a terminal. |
--show-control-chars | Print names verbatim, control characters and all. |
-N, --literal | Print names exactly, with no quoting. |
-Q, --quote-name | Wrap each name in double quotes. |
--quoting-style=WORD | Choose the quoting scheme: literal, shell, shell-always, shell-escape, c, escape, and others. |
-T, --tabsize=COLS | Assume tab stops every COLS columns when laying out output. |
-f | List everything, unsorted, including hidden entries — equivalent to -aU. Also turns colour off unless --color is given explicitly. |
--hyperlink[=WHEN] | Emit terminal hyperlinks for file names, so a capable terminal can make them clickable. |
-D, --dired | Emit extra position markers designed for the Emacs dired editing mode. |
--zero | End each line with a NUL character instead of a newline, and list one entry per line. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success — everything requested was listed. |
1 | A minor problem — for example, a named file could not be accessed. The rest of the listing still printed. |
2 | A serious problem — for example, a directory could not be read, or an option was invalid. |
dir and vdir
Peios / Using Peios / Peiosutils / Listing and paths
dir and vdir list the contents of a directory. They are ls with one decision already made for you: the output style is fixed instead of adapting to where the output is going.
dir [options] [file...]
vdir [options] [file...]
What they fix #
ls chooses its layout based on whether it is writing to a terminal — columns for a terminal, one name per line for a pipe or file. dir and vdir each pick one layout and always use it:
| Command | Layout | Equivalent ls |
|---|---|---|
dir | Columns, regardless of where output goes. | ls -C |
vdir | Long format, regardless of where output goes. | ls -l |
Both also default to a quoting style that escapes unusual characters in names without wrapping every name in quotes.
That is the only difference. The fixed style is just a default — pass an explicit format option (-1, -m, -l, -C, …) and it overrides the built-in choice, so dir -l produces a long listing and vdir -C produces columns.
Everything else is ls #
dir and vdir accept every option ls accepts and behave identically in every other respect — sorting, filtering, the long-format columns, colour, indicators, sizes, timestamps. vdir's long format is the same Peios long format documented for ls, with the [type][executable][protected] mode column and the owner SID.
For the full option set and the meaning of every long-format column, see ls. This page exists only to explain how dir and vdir differ from it — and the answer is "they pin the output style."
Exit status #
The same as ls:
| Code | Meaning |
|---|---|
0 | Success. |
1 | A minor problem — a named file could not be accessed. |
2 | A serious problem — a directory could not be read, or an option was invalid. |
stat
Peios / Using Peios / Peiosutils / Listing and paths
stat displays the detailed status of a file. Where ls gives you a line per file, stat gives you everything the system records about one file: its type, its size, its timestamps, and the low-level metadata stored in its inode.
stat [options] file...
stat is a diagnostic tool. It dumps what a file's inode physically contains, verbatim. That makes it the right command for "tell me exactly what is recorded about this file" — and it means some of what it prints needs a word of explanation, below.
The default output #
With no options, stat prints a labelled, multi-line block per file:
$ stat notes.txt
File: notes.txt
Size: 219 Blocks: 8 IO Block: 4096 regular file
Device: 8,2 Inode: 1572931 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 1000/ jack) Gid: ( 1000/ jack)
Access: 2026-05-15 11:03:42.000000000 +0000
Modify: 2026-05-15 11:03:42.000000000 +0000
Change: 2026-05-15 11:03:18.000000000 +0000
Birth: 2026-05-15 11:03:18.000000000 +0000
Reading it line by line:
- File — the name, and for a symbolic link, what it points to.
- Size / Blocks / IO Block / type — the size in bytes, the number of allocated disk blocks, the preferred I/O block size, and the file type in words.
- Device / Inode / Links — the device the file lives on, its inode number (its unique index within that device), and the number of hard links to it.
- Access / Uid / Gid — a numeric owner id, a group id, and a mode value. See the caveat below — these are not the access policy.
- Access / Modify / Change / Birth — four timestamps: when the file was last read, last written, last had its metadata changed, and first created.
The Uid, Gid, and mode fields are decorative #
The Access: (0644/-rw-r--r--) Uid: (…) Gid: (…) line needs care.
A file's inode carries a numeric owner id, a numeric group id, and a mode value, and stat reports them because stat reports the inode verbatim. But the Peios access model does not consult these fields. What a principal may do to a file is decided by the file's security descriptor — see Security descriptors. The numbers on the Uid/Gid/mode line are inert metadata: they are stored, they are reported, and they have no effect on any access decision.
Do not read that line as a permission summary. A file showing 0644 is not "world-readable" in any meaningful sense — whether anyone can read it depends entirely on its security descriptor. stat shows these fields for completeness as a low-level diagnostic; it does not claim they mean anything.
To see the parts of a file's security that do count, use ls -l, which shows the owner SID and the mode column built from the security descriptor.
Custom output formats #
Two options replace the default block with output you control.
| Option | Effect |
|---|---|
-c FORMAT, --format=FORMAT | Print FORMAT for each file, substituting % directives. A newline is added after each file. |
--printf=FORMAT | Like --format, but interpret backslash escapes (\n, \t) in FORMAT and add no trailing newline. Include \n yourself if you want one. |
$ stat --format='%n is %s bytes' notes.txt
notes.txt is 219 bytes
File directives #
Used in the format string when stating files (the default mode):
| Directive | Substitutes |
|---|---|
%n | File name. |
%N | Quoted file name; for a symlink, with the target shown. |
%F | File type in words (regular file, directory, …). |
%s | Total size, in bytes. |
%b | Number of allocated blocks. |
%B | Size in bytes of each block counted by %b. |
%o | Preferred I/O transfer block size. |
%i | Inode number. |
%h | Number of hard links. |
%d / %D | Device number, in decimal / in hexadecimal. |
%t / %T | For a device file, the major / minor device type, in hexadecimal. |
%m | Mount point of the file system the file is on. |
%f | Raw mode value, in hexadecimal. |
%x / %X | Last access time — human-readable / seconds since the epoch. |
%y / %Y | Last modification time — human-readable / seconds since the epoch. |
%z / %Z | Last status-change time — human-readable / seconds since the epoch. |
%w / %W | File creation (birth) time — human-readable / seconds since the epoch. - or 0 if unknown. |
The directives %a and %A (the mode in octal and in symbolic form) and %u, %U, %g, %G (the owner and group, numeric and by name) report the decorative inode fields described above. They are available for completeness; they are not the file's access policy.
The %C directive (security context) is inactive — it produces no meaningful value on a standard Peios system.
File-system directives #
With -f, stat reports on the file system a file lives on rather than the file itself, and the format string uses a different directive set:
| Directive | Substitutes |
|---|---|
%n | File name. |
%i | File-system ID, in hexadecimal. |
%t / %T | File-system type — in hexadecimal / in words. |
%l | Maximum length of a file name. |
%s | Block size, for fast transfers. |
%S | Fundamental block size, used for the block counts. |
%b | Total data blocks in the file system. |
%f | Free blocks. |
%a | Free blocks available to an ordinary principal. |
%c | Total inodes (file nodes). |
%d | Free inodes. |
Options #
| Option | Effect |
|---|---|
-f, --file-system | Report on the file system containing each file, instead of the file. |
-L, --dereference | Follow symbolic links — report on the link's target rather than the link itself. |
-t, --terse | Print the information on a single line, as bare values with no labels. Useful for scripts. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was stated successfully. |
1 | A file could not be stated, or an option or format directive was invalid. |
pwd
Peios / Using Peios / Peiosutils / Listing and paths
pwd — "print working directory" — prints the absolute path of the directory you are currently in.
pwd [options]
$ pwd
/home/jack/projects
Every process has a working directory: the directory that relative path names are interpreted against. pwd tells you what yours is.
Physical and logical paths #
There are two honest answers to "where am I", and they differ only when symbolic links are involved.
Suppose /home/jack/work is a symbolic link pointing at /data/projects/jack, and you move into it. The physical path — the real location, with every link resolved — is /data/projects/jack. The logical path — the route you took to get there — is /home/jack/work.
| Option | Path printed |
|---|---|
-P, --physical | The physical path: every symbolic link resolved to its target. |
-L, --logical | The logical path: the route recorded as you navigated, taken from the PWD environment variable when it is accurate. |
By default pwd prints the physical path — -P is the default, and naming it explicitly just makes that choice visible. Use -L when you want the path as you navigated it, links and all.
If -L is requested but the recorded PWD value is missing or does not actually match the current directory, pwd falls back to the physical path rather than print something wrong.
A note on shells #
Many command shells provide their own built-in pwd, and the shell's version is what runs when you type pwd at a prompt. The two behave the same for everyday use. To be certain you are running this command rather than the shell built-in, invoke it by its full path.
Exit status #
| Code | Meaning |
|---|---|
0 | The working directory was printed. |
1 | The working directory could not be determined or could not be printed. |
readlink
Peios / Using Peios / Peiosutils / Listing and paths
readlink prints where a symbolic link points.
readlink [options] file...
A symbolic link is a file whose contents are another path. readlink reads that path and prints it:
$ readlink /home/jack/work
/data/projects/jack
By default readlink resolves exactly one level: it prints the link's immediate target, whether or not that target is itself a link, and whether or not it exists. If the named file is not a symbolic link, readlink prints nothing and reports failure.
Canonicalising a whole path #
The canonicalise options change readlink from "read this one link" into "resolve this entire path to its real, absolute form" — following every symbolic link in every component, collapsing . and ... The three differ only in how strict they are about components existing:
| Option | Resolves | Existence requirement |
|---|---|---|
-f, --canonicalize | Every link in the path. | Every component except the last must exist. |
-e, --canonicalize-existing | Every link in the path. | Every component must exist. |
-m, --canonicalize-missing | Every link in the path. | No component need exist. |
With any of these, readlink succeeds on an ordinary (non-link) file too — it simply returns the canonical path. realpath is the dedicated command for this canonicalising job and has more options for it; the canonicalise flags here exist so readlink can do it without a second tool.
Output and error control #
| Option | Effect |
|---|---|
-n, --no-newline | Do not print the trailing newline. Ignored, with a warning, when more than one file is given. |
-z, --zero | End each output line with a NUL character instead of a newline. |
-q, --quiet / -s, --silent | Suppress most error messages. This is the default. |
-v, --verbose | Report error messages that the quiet default would suppress. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every named file was resolved and printed. |
1 | A file was not a symbolic link, or a path could not be resolved under the chosen strictness. |
realpath
Peios / Using Peios / Peiosutils / Listing and paths
realpath resolves a path to its canonical form: absolute, with every symbolic link followed and every . and .. component removed. Given any path, it tells you the one true location it refers to.
realpath [options] file...
$ realpath ../work/./notes.txt
/data/projects/jack/notes.txt
A single file can be named many ways — through links, through relative paths, with redundant components. realpath reduces all of them to the single canonical path, which makes it the right tool for comparing two paths, or for turning a path a user typed into one a script can rely on.
How much must exist #
By default realpath requires every component of the path except the last to exist. These options change that requirement:
| Option | Existence requirement |
|---|---|
| (default) | Every component except the last must exist. |
-e, --canonicalize-existing | Every component must exist — including the final one. |
-m, --canonicalize-missing | No component need exist. The path is canonicalised purely as text where it cannot be walked. |
How symbolic links are handled #
| Option | Effect |
|---|---|
-P, --physical | Resolve each symbolic link as it is encountered. This is the default. |
-L, --logical | Resolve .. components before resolving the symbolic links they follow. |
-s, --strip, --no-symlinks | Do not resolve symbolic links at all — only remove . and .. components. The result is canonical as text, but a link in the path is left as-is. |
Output options #
| Option | Effect |
|---|---|
--relative-to=DIR | Print the result as a path relative to DIR instead of as an absolute path. |
--relative-base=DIR | Print an absolute path, unless the result lies inside DIR, in which case print it relative to DIR. |
-z, --zero | End each output line with a NUL character instead of a newline. |
-q, --quiet | Do not print a warning when a path is invalid. The exit status still reports the failure. |
realpath and readlink #
readlink with -f/-e/-m does the same canonicalising job. The difference is focus: readlink is primarily for reading a single link's target, with canonicalising as an extra; realpath is built for canonicalising and carries the richer option set — relative output, the symlink-handling modes, the strip-only mode. Use realpath when canonicalising is the actual goal.
Exit status #
| Code | Meaning |
|---|---|
0 | Every path was resolved and printed. |
1 | A path could not be resolved under the chosen options. |
basename
Peios / Using Peios / Peiosutils / Listing and paths
basename takes a path and prints just its final component — the file name, with all the leading directories removed.
basename name [suffix]
basename [options] name...
$ basename /home/jack/projects/notes.txt
notes.txt
It is a pure text operation: basename never looks at the file system. It works on the string you give it, so the path need not exist.
Removing a suffix #
Give a second argument and basename also strips that suffix from the end of the name — handy for turning a file name into a bare stem:
$ basename /home/jack/projects/notes.txt .txt
notes
The suffix is only removed if it is actually there, and never if it would leave an empty string.
Options #
basename takes one name by default. The options let it process several at once.
| Option | Effect |
|---|---|
-a, --multiple | Treat every argument as a name to process, instead of treating the second argument as a suffix. Required to pass more than one name. |
-s, --suffix=SUFFIX | Remove SUFFIX from each name. Implies -a, so it applies to every name given. |
-z, --zero | End each output line with a NUL character instead of a newline. |
$ basename -s .txt notes.txt readme.txt changelog.txt
notes
readme
changelog
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A usage error — a missing or extra operand. |
dirname
Peios / Using Peios / Peiosutils / Listing and paths
dirname takes a path and prints everything except its final component — the directory the name lives in. It is the counterpart of basename.
dirname [options] name...
$ dirname /home/jack/projects/notes.txt
/home/jack/projects
Like basename, it is a pure text operation — dirname never touches the file system, so the path need not exist.
How it handles edge cases #
- If the name has no
/in it at all, there is no directory part, sodirnameprints.— the current directory. - Trailing slashes on the name are ignored before the final component is removed.
$ dirname notes.txt
.
$ dirname /home/jack/projects/
/home
Multiple names #
dirname accepts any number of names and prints the directory part of each on its own line:
$ dirname /etc/hosts /var/log/messages report.txt
/etc
/var/log
.
Options #
| Option | Effect |
|---|---|
-z, --zero | End each output line with a NUL character instead of a newline. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A usage error — no name was given. |
pathchk
Peios / Using Peios / Peiosutils / Listing and paths
pathchk checks whether a path name is valid and usable. It tells you, before you try to create a file, whether the name would be rejected — because it is too long, contains an unusable component, or could not be reached.
pathchk [options] name...
$ pathchk /home/jack/projects/notes.txt
$ echo $?
0
pathchk prints nothing when a name is fine; it prints a diagnostic and fails when a name is not. It is meant for scripts that build up path names and want to fail early, with a clear message, instead of discovering the problem halfway through an operation.
Like the other name commands in this topic, pathchk does not require the file to exist — it is checking the name, not looking for the file.
What the default check covers #
With no options, for each name pathchk checks:
- that the name is not empty;
- that no part of the name exceeds the length limits of the file systems the path would actually touch;
- that the leading directories of the name can be searched.
Stricter checks #
The options replace "valid on this system" with "valid across a wide range of systems" — useful when a name has to work somewhere other than where you are checking it.
| Option | Adds |
|---|---|
-p | Check against a fixed, conservative set of length limits instead of the local file system's, and flag characters that are not broadly portable. |
-P | Reject an empty name, and reject any component that begins with -. |
--portability | Apply both -p and -P — the strictest check. |
A name with a leading - on a component is worth catching: many commands would read it as an option rather than a file name. -P flags exactly that class of trouble.
Exit status #
| Code | Meaning |
|---|---|
0 | Every name passed every requested check. |
1 | At least one name failed. A diagnostic naming the problem was printed. |
dircolors
Peios / Using Peios / Peiosutils / Listing and paths
When ls colours its output, it reads the colours from an environment variable named LS_COLORS. dircolors is the command that produces the value of that variable.
dircolors [options] [file]
dircolors does not set the variable itself — a command cannot change its parent shell's environment. Instead it prints shell commands that, when run by the shell, set LS_COLORS. The usual way to use it is to have the shell evaluate that output:
eval "$(dircolors)"
Put that line in a shell startup file and every ls --color afterwards picks up the colours. With no arguments, dircolors uses a built-in default colour scheme.
Choosing the shell syntax #
The commands dircolors prints have to match the shell that will run them. By default it guesses the syntax from the SHELL environment variable; these options state it outright.
| Option | Output syntax |
|---|---|
-b, --sh, --bourne-shell | Bourne-shell-family syntax. |
-c, --csh, --c-shell | C-shell-family syntax. |
If no shell option is given and SHELL is not set, dircolors cannot guess and reports an error.
Customising the colours #
To change the colours, you start from the default scheme, edit it, and feed it back.
| Option | Effect |
|---|---|
-p, --print-database | Print the built-in colour database in its editable source form, instead of shell commands. |
--print-ls-colors | Print the colours fully escaped, one per line, for inspection. |
The workflow is:
dircolors -p > ~/.dircolors # save the default scheme to a file
# ...edit ~/.dircolors to taste...
eval "$(dircolors ~/.dircolors)" # load the edited scheme
When dircolors is given a file argument, it reads the colour definitions from that file instead of using the built-in database. The file maps file types and name extensions to colours; dircolors -p shows the format, with each line commented.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A usage error, or a colour-definition file that could not be read or parsed. |
Viewing and joining text
Peios / Using Peios / Peiosutils / Viewing and joining text
Much of the work at a command line is reading text — looking at a file, checking the start or end of one, pulling a column out, stitching two files together. This topic covers the commands for that: viewing files, showing parts of them, and joining or splitting text by line and by column.
This page names every command in the topic. The companion topic, Transforming text, covers the commands that reshape text — sorting, de-duplicating, substituting characters.
The commands #
Viewing a whole file
| Command | Purpose |
|---|---|
cat | Print one or more files straight through — and join several into one. |
tac | Print a file with its lines in reverse — last line first. |
more | Show a file one screen at a time, pausing between screens. |
od | Dump a file's raw bytes in octal, hex, decimal, or character form. |
Viewing part of a file
| Command | Purpose |
|---|---|
head | Print the first lines (or bytes) of a file. |
tail | Print the last lines of a file — and, with -f, keep printing as the file grows. |
nl | Print a file with its lines numbered. |
Cutting and combining
| Command | Purpose |
|---|---|
cut | Pull selected columns — byte ranges or delimited fields — out of each line. |
paste | Join files side by side, line for line. |
join | Join two files on a shared field, like a database join. |
comm | Compare two sorted files and report which lines they share. |
Splitting a file into files
| Command | Purpose |
|---|---|
split | Break a file into equal-sized pieces. |
csplit | Break a file into pieces at lines that match a pattern. |
A note on input #
Almost every command here follows the same convention for where its text comes from. Name one or more files and it reads those; name none and it reads from standard input, so it can sit in the middle of a pipeline. Where a command accepts files, the name - stands for standard input, so you can mix files and piped input in one invocation.
Where to start #
To simply print a file, or join a few together, read cat — the most-used command in the topic.
cat
Peios / Using Peios / Peiosutils / Viewing and joining text
cat prints the contents of files. Its name is short for "concatenate" — given several files, it prints them one after another, as a single stream.
cat [options] [file...]
$ cat notes.txt
$ cat part1.txt part2.txt part3.txt > whole.txt
With no file — or with the name - — cat reads standard input, which is what makes it useful in a pipeline or for capturing typed input into a file.
Plain use #
Most of the time cat is run with no options at all: it copies its input to its output, byte for byte, unchanged. The options exist for the times you want to see something about the text that is normally invisible, or to adjust spacing and numbering.
Numbering lines #
| Option | Effect |
|---|---|
-n, --number | Number every output line. |
-b, --number-nonblank | Number only the non-empty lines. Overrides -n. |
Making invisible characters visible #
These options reveal characters that normally print as nothing, or as whitespace — useful when a file is not behaving and you suspect a stray tab or control character.
| Option | Effect |
|---|---|
-E, --show-ends | Print a $ at the end of each line, so trailing spaces become visible. |
-T, --show-tabs | Print tab characters as ^I instead of as whitespace. |
-v, --show-nonprinting | Print control and other non-printing characters using ^ and M- notation (leaving newline and tab as they are). |
-e | Shorthand for -vE. |
-t | Shorthand for -vT. |
-A, --show-all | Shorthand for -vET — show ends, tabs, and all non-printing characters at once. |
Adjusting spacing #
| Option | Effect |
|---|---|
-s, --squeeze-blank | Collapse runs of blank lines into a single blank line. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was printed. |
1 | A file could not be read. |
tac
Peios / Using Peios / Peiosutils / Viewing and joining text
tac prints a file with its lines in reverse — the last line first, the first line last. The name is cat spelled backwards, which is exactly what it does.
tac [options] [file...]
$ tac events.log
Reversing a log file so the newest entries come first is the everyday use. With no file, tac reads standard input.
Reversing on something other than lines #
tac does not have to split on newlines. You can tell it to treat some other string as the separator, and it will reverse the order of the pieces between those separators.
| Option | Effect |
|---|---|
-s, --separator=STRING | Use STRING as the separator between records, instead of a newline. |
-r, --regex | Treat the separator as a regular expression rather than a literal string. |
-b, --before | Expect the separator before each record rather than after it. This matters for how the separator is reattached when the records are reordered. |
$ tac -s ', ' -r names.csv
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was printed. |
1 | A file could not be read, or the separator regular expression was invalid. |
head
Peios / Using Peios / Peiosutils / Viewing and joining text
head prints the beginning of a file — by default, the first 10 lines.
head [options] [file...]
$ head config.toml
It is the quick way to glance at the top of a file without printing the whole thing — a header, the first few records, the start of a log. With no file, head reads standard input.
Choosing how much #
| Option | Effect |
|---|---|
-n, --lines=NUM | Print the first NUM lines instead of 10. |
-c, --bytes=NUM | Print the first NUM bytes instead of counting lines. |
NUM accepts a unit suffix (K, M, …) when counting bytes.
Counting from the end #
If NUM is given a leading -, the meaning flips: head prints everything except the last NUM.
$ head -n -5 report.txt # all but the final 5 lines
Headers for multiple files #
When given more than one file, head prints a header line before each one so you can tell them apart. Two options override that:
| Option | Effect |
|---|---|
-q, --quiet | Never print the file-name headers. |
-v, --verbose | Always print the header, even for a single file. |
Other options #
| Option | Effect |
|---|---|
-z, --zero-terminated | Treat the NUL character as the line delimiter instead of newline. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was printed. |
1 | A file could not be read. |
tail
Peios / Using Peios / Peiosutils / Viewing and joining text
tail prints the end of a file — by default, the last 10 lines.
tail [options] [file...]
$ tail server.log
It is the counterpart of head: the quick way to see the most recent lines of a file. With no file, tail reads standard input.
Choosing how much #
| Option | Effect |
|---|---|
-n, --lines=NUM | Print the last NUM lines instead of 10. |
-c, --bytes=NUM | Print the last NUM bytes instead of counting lines. |
If NUM is given a leading +, the meaning flips: tail prints everything from line NUM onward.
$ tail -n +20 report.txt # from line 20 to the end
Following a growing file #
tail's most useful trick is -f. Instead of printing the end and exiting, it stays running and prints new lines as they are appended — the standard way to watch a log file live.
| Option | Effect |
|---|---|
-f, --follow | Keep the file open and print new data as it is added. |
-F | Follow the file by name, and keep retrying if it is missing. Equivalent to --follow=name --retry. This survives log rotation — when the file is replaced, -F picks up the new one. |
--retry | Keep trying to open a file that is not yet accessible. |
--pid=PID | While following, stop once process PID exits. |
-s, --sleep-interval=N | Wait N seconds between checks of the file. |
Press Ctrl-C to stop a tail -f.
Headers for multiple files #
As with head, when given more than one file tail prints a header before each.
| Option | Effect |
|---|---|
-q, --quiet | Never print the file-name headers. |
-v, --verbose | Always print the header, even for a single file. |
Other options #
| Option | Effect |
|---|---|
-z, --zero-terminated | Treat the NUL character as the line delimiter instead of newline. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read. |
more
Peios / Using Peios / Peiosutils / Viewing and joining text
more displays a file one screenful at a time. Where cat prints a whole file at once — and a long file scrolls straight past — more shows the first screen, then waits for you before showing the next.
more [options] file...
$ more long-report.txt
Moving through the file #
While more is paused, it is waiting for a keypress:
| Key | Action |
|---|---|
| Space | Show the next screenful. |
| Return | Show the next line. |
q | Quit. |
/ | Search forward for a string. |
h | Show the built-in help. |
more moves forward through a file. It is the simple, always-available pager — enough for reading something through once.
Options #
| Option | Effect |
|---|---|
-n, --lines=NUM | Show NUM lines per screenful instead of filling the terminal. --number=NUM is the same. |
-F, --from-line=NUM | Begin displaying at line NUM. |
-P, --pattern=STRING | Search for STRING and begin displaying at the first match. |
-e, --exit-on-eof | Exit automatically at the end of the file, rather than waiting. |
-s, --squeeze | Collapse runs of blank lines into one. |
-u, --plain | Suppress underlining in the displayed text. |
-p, --print-over | Clear the screen and print the next page, instead of scrolling. |
-c, --clean-print | Redraw each page in place, cleaning line ends, instead of scrolling. |
-d, --silent | When an unrecognised key is pressed, show a short hint instead of ringing the terminal bell. |
-l, --logical | Do not pause when a line contains a form-feed character. |
-f, --no-pause | Count logical lines rather than screen lines when deciding where to pause. |
Exit status #
| Code | Meaning |
|---|---|
0 | The file was displayed. |
1 | A file could not be opened. |
nl
Peios / Using Peios / Peiosutils / Viewing and joining text
nl prints a file with its lines numbered. cat -n also numbers lines; nl is the command for when you need control over the numbering — which lines count, how the number looks, where it restarts.
nl [options] [file...]
$ nl chapter.txt
1 Once the system is installed,
2 the first boot brings up
3 the configuration service.
Which lines get a number #
By default nl numbers only the non-empty lines. -b sets the rule:
-b STYLE | Numbers… |
|---|---|
-b a | every line. |
-b t | only non-empty lines. This is the default. |
-b n | no lines. |
-b pBRE | only lines that match the basic regular expression BRE. |
| Option | Effect |
|---|---|
-b, --body-numbering=STYLE | The numbering rule, as above. |
-l, --join-blank-lines=N | Count a run of N blank lines as one numbered line. |
How the number looks #
| Option | Effect |
|---|---|
-n, --number-format=FORMAT | ln = left-justified; rn = right-justified; rz = right-justified with leading zeros. |
-w, --number-width=N | Use N columns for the number. |
-s, --number-separator=STRING | Put STRING between the number and the line text. |
-v, --starting-line-number=N | Start counting from N. |
-i, --line-increment=N | Increase the number by N at each counted line. |
Logical pages #
nl can treat one file as a sequence of logical pages, each with a header, a body, and a footer, and number the three parts by different rules. Pages are separated by special delimiter lines in the input.
| Option | Effect |
|---|---|
-h, --header-numbering=STYLE | Numbering style for header sections. |
-f, --footer-numbering=STYLE | Numbering style for footer sections. |
-d, --section-delimiter=CC | The characters that mark a section boundary in the input. |
-p, --no-renumber | Do not reset the line number at the start of each logical page. |
The styles for -h and -f are the same a / t / n / pBRE set as -b. For an ordinary file with no delimiter lines, the whole file is one body and only -b matters.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or an option value was invalid. |
cut
Peios / Using Peios / Peiosutils / Viewing and joining text
cut extracts columns from text. For each line of input it prints only the parts you select, dropping the rest.
cut option... [file...]
$ cut -d , -f 1,3 people.csv # the 1st and 3rd comma-separated field
$ cut -c 1-8 log.txt # the first 8 characters of each line
Every run of cut needs two decisions: what counts as a column, and which columns to keep.
What counts as a column #
Choose exactly one mode:
| Option | A column is… |
|---|---|
-b, --bytes | a single byte. |
-c, --characters | a single character. |
-f, --fields | a field — a stretch of text between delimiters. |
-b and -c cut by position. -f cuts by field, which is what you want for tabular data — a CSV, a table, the columns of a report.
Which columns to keep #
The mode option takes a sequence: numbers and inclusive ranges, separated by commas.
| Sequence | Selects |
|---|---|
3 | column 3 only. |
2,5,9 | columns 2, 5, and 9. |
5-7 | columns 5 through 7. |
3- | column 3 to the end of the line. |
-4 | the start of the line through column 4. |
1,4-6,9 | any mixture of the above. |
| Option | Effect |
|---|---|
--complement | Invert the selection — keep every column except those named. |
Field mode: the delimiter #
In field mode, cut needs to know what separates the fields.
| Option | Effect |
|---|---|
-d, --delimiter=CHAR | The character that separates fields. The default is a tab. |
-w | Separate fields on runs of whitespace (spaces and tabs) instead of a single character. Cannot be combined with -d. |
-s, --only-delimited | Print only lines that actually contain the delimiter; drop lines that have no fields to cut. |
--output-delimiter=STRING | Put STRING between the kept fields in the output, instead of repeating the input delimiter — handy for converting one delimited format to another. |
Other options #
| Option | Effect |
|---|---|
-z, --zero-terminated | Treat the NUL character as the line delimiter, so a "line" may itself contain newlines. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A mode was missing or given twice, an option was misused, or a file could not be read. |
paste
Peios / Using Peios / Peiosutils / Viewing and joining text
paste joins files side by side. It takes the first line of each file and prints them on one line, then the second line of each, and so on — merging files into columns.
paste [options] [file...]
$ paste names.txt ages.txt
Ada 36
Grace 41
Linus 29
By default the merged pieces are separated by a tab. paste is the column-wise counterpart of cat, which joins files end to end.
Pasting one file at a time #
| Option | Effect |
|---|---|
-s, --serial | Paste each file's lines onto a single line, one file at a time, instead of merging files in parallel. A file's lines become a row. |
$ paste -s -d , names.txt
Ada,Grace,Linus
Choosing the separator #
| Option | Effect |
|---|---|
-d, --delimiters=LIST | Use the characters in LIST as separators instead of a tab. When LIST has more than one character, paste cycles through them. |
-z, --zero-terminated | Treat the NUL character as the line delimiter instead of newline. |
paste and join #
paste merges by position — line 1 with line 1, line 2 with line 2, blind to content. When you want to merge by a matching value — pairing rows that share a key — that is join.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or the delimiter list was malformed. |
join
Peios / Using Peios / Peiosutils / Viewing and joining text
join merges two files on a shared field. For every pair of lines — one from each file — that have the same value in their join field, it prints a combined line. It is the command-line form of a database join.
join [options] file1 file2
$ join employees.txt salaries.txt
If employees.txt has 101 Ada and salaries.txt has 101 80000, join pairs them on the shared 101 and prints 101 Ada 80000.
The input must be sorted #
join works by stepping through both files together, so both files must be sorted on the join field. If they are not, join will miss matches. Sort them first with sort, on the same field join will use.
join checks the ordering as it goes and reports a file that is out of order; --nocheck-order turns that check off, and --check-order forces it on.
Choosing the join field #
By default join joins on the first field of each file, and fields are separated by whitespace.
| Option | Effect |
|---|---|
-1 FIELD | Join on FIELD of file 1. |
-2 FIELD | Join on FIELD of file 2. |
-j FIELD | Join on FIELD of both files — shorthand for -1 FIELD -2 FIELD. |
-t CHAR | Use CHAR as the field separator for input and output. |
Unmatched lines #
By default join prints only the lines that paired. A line with no match on the other side is dropped. These options bring unmatched lines back:
| Option | Effect |
|---|---|
-a FILENUM | Also print unpaired lines from file FILENUM (1 or 2). |
-v FILENUM | Print only the unpaired lines from file FILENUM, and suppress the joined output. |
-e EMPTY | Fill in any missing field with the string EMPTY. |
Shaping the output #
| Option | Effect |
|---|---|
-o FORMAT | Build each output line from the field list FORMAT, rather than the default layout. |
-i, --ignore-case | Ignore letter case when comparing join fields. |
--header | Treat the first line of each file as column headers — print them, paired, without trying to match them. |
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, was not sorted, or an option was invalid. |
comm
Peios / Using Peios / Peiosutils / Viewing and joining text
comm compares two sorted files and tells you which lines are unique to each and which are common to both.
comm [options] file1 file2
By default it prints three columns:
$ comm list-a.txt list-b.txt
apple
banana
cherry
date
- Column 1 — lines only in
file1. - Column 2 — lines only in
file2. - Column 3 — lines in both.
Each column is indented past the one before, so a line's column position tells you where it was found.
The input must be sorted #
comm steps through both files together, so both must be sorted. On unsorted input the comparison is meaningless. Sort the files first with sort.
comm checks the ordering and reports a file that is out of order. --check-order forces the check; --nocheck-order disables it.
Showing only the columns you want #
Each column can be switched off. The remaining columns close up to fill the gap.
| Option | Effect |
|---|---|
-1 | Suppress column 1 — hide lines unique to file1. |
-2 | Suppress column 2 — hide lines unique to file2. |
-3 | Suppress column 3 — hide lines common to both. |
These combine to answer specific questions. comm -12 shows only the common lines — the intersection of the two files. comm -23 shows lines in file1 that are not in file2 — the difference.
Other options #
| Option | Effect |
|---|---|
--output-delimiter=STR | Separate the columns with STR instead of spaces. |
--total | Print a final summary line with the count in each column. |
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or was not sorted. |
split
Peios / Using Peios / Peiosutils / Viewing and joining text
split breaks one file into several smaller files of roughly equal size.
split [options] [input [prefix]]
$ split -l 1000 big.log
$ ls
xaa xab xac xad
By default split writes 1000 lines per piece, names the pieces xaa, xab, xac, … — a prefix of x followed by a two-letter suffix — and reads its input from a named file or from standard input.
To put the file back together, concatenate the pieces in order: cat xaa xab xac … > original.
Each piece is a newly created file, and a new file needs a security descriptor; each piece inherits one from the directory it is created in.
How big each piece is #
Choose one of these to set the piece size:
| Option | Each piece holds… |
|---|---|
-l, --lines=N | N lines. This is the default behaviour, with N = 1000. |
-b, --bytes=SIZE | SIZE bytes. |
-C, --line-bytes=SIZE | up to SIZE bytes, but only whole lines — never a line split across two pieces. |
-n, --number=CHUNKS | the input divided into a fixed number of pieces (see below). |
A SIZE accepts a unit suffix: K, M, G, … as powers of 1024, or KB, MB, … as powers of 1000.
Fixed number of chunks (-n) #
-n divides the input into a set number of pieces rather than fixing each piece's size. CHUNKS may be:
| Form | Effect |
|---|---|
N | Split into N pieces by size. |
l/N | Split into N pieces without splitting any line across pieces. |
r/N | Like l/N, but distribute lines round-robin across the pieces. |
K/N | Write only the Kth of N pieces, to standard output. |
Naming the pieces #
| Option | Effect |
|---|---|
prefix (operand) | The leading part of each output name. Default x. |
-a, --suffix-length=N | Use N characters for the suffix. Default 2. |
-d, --numeric-suffixes[=START] | Use numeric suffixes (00, 01, …) instead of letters, optionally from START. |
-x, --hex-suffixes[=START] | Use hexadecimal suffixes. |
--additional-suffix=SUFFIX | Append a fixed SUFFIX to every output name — for giving the pieces an extension. |
Other options #
| Option | Effect |
|---|---|
-e, --elide-empty-files | With -n, do not write out pieces that would be empty. |
-t, --separator=SEP | Use SEP as the line separator instead of newline. |
--verbose | Print a line as each output file is opened. |
Exit status #
| Code | Meaning |
|---|---|
0 | The file was split. |
1 | A failure — the input could not be read, or the suffixes were exhausted. |
csplit
Peios / Using Peios / Peiosutils / Viewing and joining text
csplit — "context split" — breaks a file into pieces at points you choose: a line number, or a line that matches a pattern. Where split cuts a file into equal sizes, csplit cuts it at meaningful boundaries.
csplit [options] file pattern...
$ csplit report.txt '/^Chapter/' '{*}'
That splits report.txt into one piece per chapter, cutting at every line beginning with Chapter. The pieces are written to xx00, xx01, xx02, … and csplit prints the byte size of each.
Each piece is a newly created file, and a new file needs a security descriptor; each piece inherits one from the directory it is created in.
Patterns #
Each pattern argument marks a cut point. csplit copies everything up to that point into the next output file, then continues.
| Pattern | Cut where… |
|---|---|
N (a number) | line N is reached — the piece is lines up to N-1. |
/REGEX/ | a line matches REGEX; that line starts the next piece. |
%REGEX% | a line matches REGEX — but skip the text up to it instead of writing it to a piece. |
/REGEX/+N or /REGEX/-N | an offset of N lines from the matching line. |
{N} | repeat the previous pattern N more times. |
{*} | repeat the previous pattern as many times as the file allows. |
A pattern that finds no match is an error, and by default csplit removes the pieces it had already written. -k keeps them.
Naming the pieces #
| Option | Effect |
|---|---|
-f, --prefix=PREFIX | Use PREFIX instead of xx at the start of each output name. |
-n, --digits=N | Use N digits in the numeric part of the name instead of 2. |
-b, --suffix-format=FORMAT | Use a custom printf-style FORMAT for the numeric suffix. |
Other options #
| Option | Effect |
|---|---|
-k, --keep-files | Do not delete the output files if csplit fails partway. |
-z, --elide-empty-files | Do not write out pieces that would be empty. |
--suppress-matched | Drop the lines that the patterns matched, rather than keeping them in a piece. |
-s, --quiet | Do not print the byte size of each piece. |
Exit status #
| Code | Meaning |
|---|---|
0 | The file was split. |
1 | A pattern did not match, a line number was out of range, or the input could not be read. |
od
Peios / Using Peios / Peiosutils / Viewing and joining text
od — short for "octal dump" — prints the raw bytes of a file. Where cat shows you a file as text, od shows you the actual bytes underneath: each one rendered as a number, a character, or a floating-point value, in whatever base you ask for. It is the tool for looking at binary files, checking exactly which bytes a text file contains, or finding a stray control character that isn't visible on screen.
od [options] [file...]
$ od hello.txt
0000000 062510 066154 020157 067527 066162 020144 000012
0000015
With no file — or with the name - — od reads standard input, so it can sit at the end of a pipeline. Given several files, it reads them one after another as a single continuous stream, and the byte offsets run straight through from one file into the next.
How the output is laid out #
Every line of output has two parts:
- An offset on the left: the position, counted in bytes from the start of the input, of the first byte on that line. By default it is printed in octal (see
-Ato change the base). - One or more format columns on the right: the bytes of that line rendered in the format(s) you chose.
With no format option, od prints the input as octal, two bytes at a time — the traditional default. The very last line of output is a lone offset marking the total length of the input.
If you ask for more than one format at once, each format is printed on its own line, stacked under a single shared offset. Only the first line of the group carries the offset; the rest are indented to line up beneath it.
Choosing what the bytes look like #
There are two ways to say how bytes should be rendered: a set of single-letter named shortcuts for the common cases, and the general -t / --format option for full control. You can give several at once, and they are applied in the order they appear on the command line.
Named format shortcuts #
Each of these selects one output format. Combine them freely (od -cx prints characters and hex together).
| Option | Renders each unit as |
|---|---|
-a | Named characters, ignoring the high-order bit (control characters shown by name, e.g. nul, sp, del). |
-b | Octal, one byte at a time. |
-c | Printable ASCII / UTF-8 characters, with backslash escapes (\n, \t, …) for the rest. |
-d | Unsigned decimal, 2-byte units. |
-D | Unsigned decimal, 4-byte units. |
-o | Octal, 2-byte units. |
-O | Octal, 4-byte units. |
-s | Signed decimal, 2-byte units. |
-i | Signed decimal, 4-byte units. |
-l | Signed decimal, 8-byte units. |
-I, -L | Signed decimal, 8-byte units. |
-x, -h | Hexadecimal, 2-byte units. |
-X, -H | Hexadecimal, 4-byte units. |
-f | Floating point, single precision (32-bit). |
-e, -F | Floating point, double precision (64-bit). |
Type specifications (-t, --format) #
-t takes a type specification and gives you every combination the named shortcuts cover and more. Repeat -t (or list several specs in one argument) to print multiple formats.
A type specification is a type letter, an optional size, and an optional z suffix:
| Type letter | Meaning |
|---|---|
a | Printable 7-bit ASCII, named (like -a). |
c | UTF-8 characters, with octal escapes for undefined bytes (like -c). |
d | Signed decimal. |
u | Unsigned decimal. |
o | Octal. |
x | Hexadecimal. |
f | Floating point. |
For the numeric types (d, u, o, x, f) a size says how many bytes make up one unit. It can be a number, or a letter:
| Size | Applies to | Bytes per unit |
|---|---|---|
1 / C | integer types | 1 |
2 / S | integer types | 2 |
4 / I | integer types | 4 |
8 / L | integer types | 8 |
4 / F | floating point | 4 (single precision) |
8 / D | floating point | 8 (double precision) |
16 / L | floating point | 16 (extended / long double) |
2 / H | floating point | 2 (IEEE half precision) |
2 / B | floating point | 2 (bfloat16) |
If you leave the size off, integer types default to 4 bytes and floating point defaults to 8 bytes. For the character types a and c, a size is not allowed.
Add a z at the end of a spec to append an ASCII dump — the bytes of that line shown as text, . for anything non-printable — to the right of the numbers. For example, od -t x1z gives the familiar hex-plus-text layout:
$ od -A x -t x1z file.bin
000000 48 65 6c 6c 6f 2c 20 77 6f 72 6c 64 21 0a >Hello, world!.<
Examples: od -t d2 is signed decimal in 2-byte units; od -t x1 is hex byte-by-byte; od -t fD is double-precision floats; od -t c is characters. od -t x1 -t c prints hex and characters as two stacked lines.
Byte order (--endian) #
For multi-byte numeric formats, --endian sets the byte order used to assemble each unit. Without it, od uses the machine's native byte order. Use --endian=big or --endian=little to force one regardless of the host.
The offset column #
| Option | Effect |
|---|---|
-A RADIX, --address-radix=RADIX | Print offsets in the given base. RADIX is one of o (octal, the default), d (decimal), x (hexadecimal), or n (none — omit the offset column entirely). |
Only the first character of the value is examined, so od -A n and od -Anone mean the same thing.
Which bytes to read #
By default od dumps the whole input. Two options narrow that to a window:
| Option | Effect |
|---|---|
-j BYTES, --skip-bytes=BYTES | Skip BYTES bytes of input before starting to dump. When several files are given, the skip runs across the concatenated stream. |
-N BYTES, --read-bytes=BYTES | Dump at most BYTES bytes, then stop. |
In both, BYTES is decimal by default, octal if it starts with 0, or hexadecimal if it starts with 0x. A suffix scales it: b = ×512, KB/K = ×1000 / ×1024, MB/M = ×1000² / ×1024², GB/G = ×1000³ / ×1024³.
Collapsing repeated lines #
When several output lines in a row would be identical, od prints the first one, then a single line containing just * to stand for the run, and resumes at the next line that differs. This keeps a dump of a large block of identical bytes (a file full of zeros, say) short.
| Option | Effect |
|---|---|
-v, --output-duplicates | Turn the collapsing off — print every line, including repeats, with no * markers. |
Output width #
| Option | Effect |
|---|---|
-w[BYTES], --width[=BYTES] | Print BYTES input bytes per output line. The default is 16; giving -w with no value uses 32. |
The width must be a whole multiple of the size of the largest format unit in play; if it isn't, od warns and rounds it to a usable value.
Finding printable strings #
| Option | Effect |
|---|---|
-S[BYTES], --strings[=BYTES] | Instead of dumping bytes, scan the input and print only runs of at least BYTES printable characters, one per line, each preceded by its offset. BYTES defaults to 3 when omitted. |
This turns od into a quick way to pull the human-readable text out of a binary file. The offset in front of each string honours -A (and is dropped entirely under -A n). -j and -N still apply, so you can restrict the scan to a window.
The traditional offset form #
od also accepts the historical calling convention, where an offset (and, in --traditional mode, a label) is given as a bare operand rather than an option:
od [named-format-options] [file] [[+]offset[.][b]]
od --traditional [options] [file] [[+]offset[.][b] [[+]label[.][b]]]
The offset says where in the input to begin, exactly like -j. It is read as octal by default, hexadecimal if prefixed with 0x, or decimal if it carries a trailing .; a trailing b multiplies it by 512. The optional leading + is allowed everywhere and is required when there is no filename (so od +20 reads standard input starting at octal offset 20).
The label (only in --traditional mode) is a second such number: the dump still begins at offset, but the offset column is displayed as if it started at label — useful for making a partial dump's offsets match the original file.
od only reads an operand as an offset in the plain form when none of -A, -j, -N, -t, -v, or -w are present. So if a real filename happens to look like a number, add a harmless option such as -j0 to force it to be treated as a filename.
A note on reading files #
od has no access rules of its own — it simply opens a file and reads its bytes. That read is subject to the same check as every read on Peios: whether you may open the file for reading is decided by the file's security descriptor (see Security descriptors). od never reveals bytes you could not already read another way; it only presents the bytes you can read in a different form.
Exit status #
| Code | Meaning |
|---|---|
0 | The input was dumped successfully. |
1 | A file could not be read, or an option, format, or offset was invalid. |
Transforming text
Peios / Using Peios / Peiosutils / Transforming text
This topic covers the commands that reshape text: putting lines in order, dropping duplicates, swapping characters, reflowing paragraphs to a width, counting what is there. Its companion, Viewing and joining text, covers printing and combining files; this topic is about changing the text itself.
The commands #
Ordering lines
| Command | Purpose |
|---|---|
sort | Sort lines — alphabetically, numerically, and many other ways. |
shuf | The opposite of sorting: put lines into a random order. |
tsort | Topological sort — order items so that each comes after the things it depends on. |
Filtering lines
| Command | Purpose |
|---|---|
uniq | Collapse or report adjacent repeated lines. |
Substituting characters
| Command | Purpose |
|---|---|
tr | Translate, squeeze, or delete individual characters. |
Whitespace
Reflowing and paginating
| Command | Purpose |
|---|---|
fmt | Reflow paragraphs to a target width. |
fold | Hard-wrap long lines at a fixed width. |
pr | Paginate and columnate text for printing. |
Indexing and counting
| Command | Purpose |
|---|---|
ptx | Produce a permuted index of the words in a file. |
wc | Count the lines, words, and bytes in a file. |
Two commands that need sorted input #
A theme worth knowing before you start: uniq only collapses adjacent duplicate lines, so duplicates scattered through a file are not caught unless the file is sorted first. sort and uniq are almost always used together — and sort -u does both jobs in one step.
Where to start #
sort is the workhorse of the topic and the one with the most depth — start there.
sort
Peios / Using Peios / Peiosutils / Transforming text
sort reads lines and writes them out in order.
sort [options] [file...]
$ sort names.txt
With no file, sort reads standard input. Given several files, it sorts all of their lines together as one stream. By default it compares lines as plain text, character by character.
How lines are compared #
The default text comparison is often not the order you want — 10 sorts before 9, and case matters. These options change the comparison:
| Option | Compares lines as… |
|---|---|
-n, --numeric-sort | numbers. 9 sorts before 10. |
-g, --general-numeric-sort | numbers, including scientific notation. Slower than -n; use it only when -n is not enough. |
-h, --human-numeric-sort | human-readable sizes — 2K before 1M before 3G. |
-M, --month-sort | month names — JAN before FEB. |
-V, --version-sort | version numbers — 1.2.10 after 1.2.9. |
-R, --random-sort | a random order (a shuffle that groups equal lines together). |
And these adjust what is compared:
| Option | Effect |
|---|---|
-f, --ignore-case | Treat lower and upper case as the same. |
-d, --dictionary-order | Consider only letters, digits, and blanks. |
-i, --ignore-nonprinting | Ignore non-printing characters. |
-b, --ignore-leading-blanks | Ignore blanks at the start of a line (or field). |
Sort order options #
| Option | Effect |
|---|---|
-r, --reverse | Reverse the result. |
-u, --unique | Output only the first line of each run of equal lines — sort and de-duplicate in one step. |
-s, --stable | Keep equal lines in their original relative order. |
Sorting by a field #
By default sort compares whole lines. -k sorts on a key — a chosen part of each line — which is what you need for tabular data.
A line is split into fields (by default at whitespace). A key is written FIELD[.CHAR][OPTIONS][,FIELD[.CHAR]] — a start field, an optional start character within it, and an optional end. The single-letter comparison options above can be appended to a key to apply only to it.
$ sort -k 2 -n scores.txt # sort on the 2nd field, numerically
$ sort -t , -k 3 people.csv # sort a CSV on the 3rd field
| Option | Effect |
|---|---|
-k, --key=KEYDEF | Sort on the key KEYDEF. May be given more than once; keys are tried in order. |
-t, --field-separator=SEP | Use SEP to split fields, instead of the whitespace default. |
Checking and merging #
| Option | Effect |
|---|---|
-c, --check | Do not sort — just check whether the input is already sorted, and report the first line that is out of order. |
-C, --check=silent | Check, but report only through the exit status. |
-m, --merge | Treat the inputs as already-sorted files and merge them, which is faster than a full sort. |
Output and resources #
| Option | Effect |
|---|---|
-o, --output=FILE | Write to FILE instead of standard output. FILE may be one of the inputs. |
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
--parallel=N | Use N threads. |
-S, --buffer-size=SIZE | Set the size of the in-memory sort buffer. |
-T, --temporary-directory=DIR | Put temporary files in DIR rather than the default location. |
--files0-from=F | Take the list of input files from F, NUL-separated. |
--debug | Underline the part of each line that was actually used as the sort key — invaluable when a -k key is not behaving. |
Exit status #
| Code | Meaning |
|---|---|
0 | The lines were sorted — or, with -c/-C, the input was already sorted. |
1 | With -c/-C, the input was not sorted. |
2 | An error — a file could not be read, or an option was invalid. |
uniq
Peios / Using Peios / Peiosutils / Transforming text
uniq deals with repeated lines: it can collapse a run of identical lines into one, count them, or print only the ones that repeated.
uniq [options] [input [output]]
$ uniq events.txt
input and output may be given as operands; with neither, uniq reads standard input and writes standard output.
uniq only sees adjacent duplicates #
This is the one thing to know about uniq: it only compares each line with the one before it. It collapses adjacent repeats. Duplicate lines scattered through a file, with other lines between them, are not caught.
So uniq is almost always paired with sort, which brings every copy of a line together first:
$ sort events.txt | uniq
If collapsing duplicates is all you need, sort -u does both jobs in one command. Use uniq itself when you want what sort -u cannot do — counting repeats, or printing only the duplicated lines.
What to output #
By default uniq prints every line, with each run of adjacent duplicates reduced to a single copy. These options change that:
| Option | Effect |
|---|---|
-c, --count | Prefix each line with the number of times it occurred. |
-d, --repeated | Print only lines that were repeated — one copy of each. |
-D | Print all copies of every repeated line, not just one. |
-u, --unique | Print only lines that were not repeated. |
--group[=WHICH] | Print every line, with runs separated by a blank line. WHICH is separate, prepend, append, or both. |
What counts as "the same" #
| Option | Effect |
|---|---|
-i, --ignore-case | Treat lines differing only in letter case as the same. |
-f, --skip-fields=N | Ignore the first N fields when comparing. |
-s, --skip-chars=N | Ignore the first N characters when comparing. |
-w, --check-chars=N | Compare at most N characters of each line. |
Other options #
| Option | Effect |
|---|---|
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read or written. |
tr
Peios / Using Peios / Peiosutils / Transforming text
tr — "translate" — works on text one character at a time. It can replace characters with other characters, remove characters, or collapse runs of a character into one.
tr [options] set1 [set2]
tr reads standard input and writes standard output — it has no file arguments, so it always sits in a pipeline.
$ tr 'a-z' 'A-Z' < notes.txt # lower-case to upper-case
$ tr -d ' ' < spaced.txt # delete every space
$ tr -s ' ' < padded.txt # collapse runs of spaces to one
The three jobs #
What tr does depends on the options and how many sets you give it.
- Translate — give two sets. Each character in
set1is replaced by the character at the same position inset2.tr 'abc' 'xyz'turns everyaintox,bintoy,cintoz. - Delete (
-d) — give one set. Every character inset1is removed. - Squeeze (
-s) — each run of a repeated character that appears in the relevant set is collapsed to a single occurrence.
-d and -s can be combined: delete one set of characters, then squeeze another.
Writing a set #
A set is a string of characters, with a few shorthands:
| Notation | Means |
|---|---|
a-z | A range — every character from a to z. |
[:alpha:], [:digit:], [:space:], … | A named character class. |
[=c=] | An equivalence class — every character that sorts the same as c. |
[c*n] | The character c repeated n times — for padding a set to a length. |
\n, \t, \\, \NNN | Escapes — newline, tab, backslash, an octal byte. |
Options #
| Option | Effect |
|---|---|
-d, --delete | Delete the characters in set1 instead of translating. |
-s, --squeeze-repeats | Collapse each run of a repeated character listed in the last set into one. |
-c, -C, --complement | Operate on the complement of set1 — every character it does not list. |
-t, --truncate-set1 | Before translating, shorten set1 to the length of set2, rather than reusing set2's last character to cover the surplus. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A usage error — the wrong number of sets, or a malformed set. |
expand
Peios / Using Peios / Peiosutils / Transforming text
expand converts tab characters into spaces, so that text lines up the same way no matter what tab width something is viewed with.
expand [options] [file...]
$ expand source.txt > source-spaces.txt
A tab is not a fixed number of spaces — it jumps to the next tab stop, and where those stops are is a display setting. expand removes the ambiguity: it replaces each tab with exactly the number of spaces needed to reach the same column, baking the layout in. With no file, it reads standard input.
unexpand is the reverse operation.
Options #
| Option | Effect |
|---|---|
-t, --tabs=N | Set tab stops every N columns instead of the default 8. |
-t, --tabs=LIST | Given a comma-separated list, place tab stops at those explicit column positions. |
-i, --initial | Convert only the tabs that come before the first non-blank character on each line — leave tabs within the text alone. Useful for re-indenting code without disturbing aligned tabs inside it. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or a tab specification was invalid. |
unexpand
Peios / Using Peios / Peiosutils / Transforming text
unexpand is the reverse of expand: it converts runs of spaces back into tab characters.
unexpand [options] [file...]
$ unexpand spaced.txt > tabbed.txt
Where a run of spaces reaches a tab stop, unexpand replaces it with a tab. By default it only converts the leading spaces on each line — the indentation — and leaves spacing within the text alone. With no file, it reads standard input.
Options #
| Option | Effect |
|---|---|
-t, --tabs=N | Set tab stops every N columns instead of the default 8. A comma-separated list places stops at explicit column positions. Using -t also enables -a. |
-a, --all | Convert all runs of spaces that reach a tab stop, not only the leading indentation. |
--first-only | Convert only the leading run of blanks on each line. Overrides -a. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or a tab specification was invalid. |
fmt
Peios / Using Peios / Peiosutils / Transforming text
fmt reflows paragraphs: it rejoins the lines of a paragraph and re-breaks them so each comes out close to a target width. Words move freely between lines to make the result fit.
fmt [options] [file...]
$ fmt -w 72 draft.txt
fmt works on paragraphs — runs of non-blank lines separated by blank lines — and treats word boundaries as places it may break. The result reads naturally. This is what distinguishes it from fold, which simply cuts every line at a fixed column without regard to words.
With no file, fmt reads standard input.
Setting the width #
| Option | Effect |
|---|---|
-w, --width=WIDTH | Fill lines up to at most WIDTH columns. The default is 75. |
-g, --goal=WIDTH | Aim for WIDTH columns — the width fmt targets, while -w is the hard maximum. Defaults to about 93% of the maximum. |
Controlling the reflow #
| Option | Effect |
|---|---|
-s, --split-only | Only split lines that are too long; never join short lines together. |
-u, --uniform-spacing | Put exactly one space between words and two between sentences. |
-c, --crown-margin | Preserve the indentation of a paragraph's first two lines; indent the rest to match the second. |
-t, --tagged-paragraph | Like -c, but the first line must be indented differently from the second, or the two are treated as separate paragraphs. |
-q, --quick | Break lines faster, accepting a more ragged right edge. |
Selecting which lines to format #
| Option | Effect |
|---|---|
-p, --prefix=PREFIX | Reformat only lines that begin with PREFIX; reattach PREFIX afterwards. Useful for reflowing comment blocks in source code. |
-m, --preserve-headers | Detect and preserve mail-style header lines rather than reflowing them. |
--tab-width=N | Treat a tab as N columns when measuring line length. Tabs are kept in the output; this affects measurement only. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or an option value was invalid. |
fold
Peios / Using Peios / Peiosutils / Transforming text
fold breaks long lines so that none exceeds a fixed width. Every line longer than the limit is cut into pieces.
fold [options] [file...]
$ fold -w 80 wide.txt
By default fold wraps at 80 columns, and it cuts at exactly that column — even in the middle of a word. That hard cut is the difference between fold and fmt: fmt reflows paragraphs and breaks between words; fold just enforces a width, mechanically. Use fold when you need a guarantee that no line is wider than N; use fmt when you want the result to read well.
With no file, fold reads standard input.
Options #
| Option | Effect |
|---|---|
-w, --width=WIDTH | Wrap at WIDTH columns instead of 80. |
-s, --spaces | Break at a space within the width where one exists, rather than mid-word — a gentler wrap. |
-b, --bytes | Count bytes rather than display columns. Control characters such as tab and newline then count as ordinary bytes. |
-c, --characters | Count character positions rather than display columns. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or the width was invalid. |
pr
Peios / Using Peios / Peiosutils / Transforming text
pr prepares text for printing on paper. It breaks a file into pages, puts a header and a trailer on each, and can arrange the text into columns.
pr [options] [file...]
$ pr report.txt
Run plainly, pr divides the input into 66-line pages, each beginning with a five-line header — the date, the file name, and a page number — and ending with a trailing margin. With no file, it reads standard input.
Columns #
pr can lay text out in several columns per page.
| Option | Effect |
|---|---|
-COLUMN | Produce COLUMN columns per page (e.g. -3 for three). Text fills down the first column, then the next. |
-a, --across | Fill the columns across — line 1 in column 1, line 2 in column 2, and so on — rather than down. |
-m, --merge | Print several files side by side, one per column. |
-w, --width=WIDTH | Set the page width, for multi-column output. |
The header and the page #
| Option | Effect |
|---|---|
-h, --header=TEXT | Use TEXT in the header instead of the file name. |
-t, --omit-header | Print neither the header nor the trailer. |
-T, --omit-pagination | Drop the header, the trailer, and all pagination — just the text. |
-l, --length=LINES | Set the page length to LINES instead of 66. |
-o, --indent=N | Indent every line by N spaces. |
-D, --date-format=FORMAT | Format the header's date with FORMAT. |
-F, -f, --form-feed | Separate pages with a form-feed character rather than blank lines. |
Selecting and numbering #
| Option | Effect |
|---|---|
--pages=FIRST[:LAST] | Print only pages FIRST through LAST. |
-n, --number-lines[=SEP[WIDTH]] | Number every line. |
-N, --first-line-number=N | Begin line numbering at N. |
-d, --double-space | Double-space the output. |
Other options #
| Option | Effect |
|---|---|
-s, --separator-char[=CHAR] | Separate columns with a single CHAR (default tab) rather than padding with spaces. |
-S, --sep-string[=STRING] | Separate columns with STRING. |
-e, --expand-tabs[=CHAR[WIDTH]] | Expand input tabs to spaces. |
-J, --join-lines | Merge full lines, turning off width truncation. |
-r, --no-file-warnings | Do not warn when a file cannot be opened. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or an option was invalid. |
ptx
Peios / Using Peios / Peiosutils / Transforming text
ptx produces a permuted index — a concordance. For each significant word in the input, it prints a line showing that word together with the text around it, so the same passage appears once per keyword it contains.
ptx [options] [input...]
$ ptx manual.txt
A permuted index is the kind of index found at the back of a reference book: every keyword, in alphabetical order, each shown in its context. ptx builds one from plain text. With no file, it reads standard input.
Choosing the keywords #
| Option | Effect |
|---|---|
-W, --word-regexp=REGEXP | Treat anything matching REGEXP as a keyword. |
-b, --break-file=FILE | Take the set of word-break characters from FILE. |
-i, --ignore-file=FILE | Read a list of words to exclude from FILE. |
-o, --only-file=FILE | Index only the words listed in FILE. |
-f, --ignore-case | Fold case together when sorting. |
Output format #
| Option | Effect |
|---|---|
-w, --width=N | Set the output width, in columns. |
-g, --gap-size=N | Set the gap, in columns, between the output fields. |
-A, --auto-reference | Generate a reference (file name and line number) for each entry automatically. |
-r, --references | Treat the first field of each input line as a reference for that line. |
-R, --right-side-refs | Put references on the right, and exclude them from the width count. |
-F, --flag-truncation=STRING | Use STRING to mark where a line was truncated. |
-O, --format=roff | Emit the index as roff typesetting directives. |
-T, --format=tex | Emit the index as TeX typesetting directives. |
-M, --macro-name=NAME | Use NAME as the macro name in roff/TeX output. |
-G, --traditional | Behave like the older System V ptx, without the extended features. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or an option value was invalid. |
tsort
Peios / Using Peios / Peiosutils / Transforming text
tsort performs a topological sort. Given a set of "this must come before that" rules, it produces an order in which everything appears after the things it depends on.
tsort [file]
$ tsort dependencies.txt
With no file, tsort reads standard input.
The input #
tsort reads its input as a flat sequence of whitespace-separated tokens, taken in pairs. Each pair A B means "A must come before B" — an edge in a dependency graph.
compiler linker
linker installer
compiler installer
That input says the linker depends on the compiler, the installer on the linker, and the installer on the compiler. tsort reads the pairs and prints the items in a valid order:
compiler
linker
installer
A token paired with itself (A A) simply introduces A with no dependency — a way to list an item that nothing else mentions.
Cycles #
A topological order only exists if the dependencies form no cycle. If A must come before B and B must come before A, there is no valid order. tsort detects this, reports the loop it found, and exits with a failure status — it still prints an order, but the cycle means that order cannot satisfy every rule.
Where it is used #
tsort answers "what order do I do these in?" — building components in dependency order, scheduling tasks, sequencing anything where some steps must precede others. It is the dependency-aware counterpart to sort, which orders by value rather than by dependency.
Exit status #
| Code | Meaning |
|---|---|
0 | A valid order was produced. |
1 | The input contained a cycle, had an odd number of tokens, or could not be read. |
wc
Peios / Using Peios / Peiosutils / Transforming text
wc — "word count" — counts what is in a file: its lines, its words, and its bytes.
wc [options] [file...]
$ wc report.txt
214 1832 12044 report.txt
By default wc prints three numbers per file — lines, words, bytes — followed by the file name. Given several files, it adds a total line. With no file, it reads standard input.
Choosing what to count #
Pass any of these and wc prints only the counts you ask for, in the fixed order lines, words, characters/bytes, longest-line:
| Option | Counts |
|---|---|
-l, --lines | The number of lines. |
-w, --words | The number of words — runs of non-whitespace. |
-c, --bytes | The number of bytes. |
-m, --chars | The number of characters. This differs from -c for multi-byte text, where one character is several bytes. |
-L, --max-line-length | The length of the longest line. |
$ wc -l report.txt # just the line count
214 report.txt
Other options #
| Option | Effect |
|---|---|
--files0-from=F | Take the list of files to count from F, as NUL-terminated names. - reads the list from standard input. |
--total=WHEN | Control the total line: auto (the default — shown for more than one file), always, only (just the total, no per-file lines), or never. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every file was counted. |
1 | A file could not be read. |
shuf
Peios / Using Peios / Peiosutils / Transforming text
shuf shuffles: it outputs its input lines in a random order. Every possible ordering is equally likely.
shuf [options] [file]
shuf -e [options] arg...
shuf -i lo-hi [options]
$ shuf playlist.txt
shuf is the opposite of sort — instead of imposing an order, it removes one. With no file, it reads standard input.
Where the lines come from #
shuf has three ways of getting its input, one per usage form:
| Option | Input is… |
|---|---|
| (a file, or stdin) | the lines of the file. |
-e, --echo | the command-line arguments, each treated as one line. |
-i, --input-range=LO-HI | the integers from LO to HI, each treated as one line. |
$ shuf -e red green blue # shuffle three given words
$ shuf -i 1-100 # the numbers 1..100 in random order
Shaping the output #
| Option | Effect |
|---|---|
-n, --head-count=COUNT | Output at most COUNT lines — a random sample rather than the whole shuffle. |
-r, --repeat | Allow lines to be repeated, so output can be longer than the input. Pair with -n to set how many. |
-o, --output=FILE | Write to FILE instead of standard output. |
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
shuf -n 1 is a common idiom — pick one random line.
Reproducible shuffles #
A shuffle is random by default. To get the same shuffle every time — for a repeatable test, say — fix the randomness:
| Option | Effect |
|---|---|
--random-seed=STRING | Seed the shuffle with STRING. The same seed gives the same shuffle. |
--random-source=FILE | Take the random bytes from FILE. The same file gives the same shuffle. Cannot be combined with --random-seed. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A failure — the input could not be read, or -r was used with no lines to repeat. |
Output and evaluation
Peios / Using Peios / Peiosutils / Output and evaluation
This topic gathers the small, sharp commands that scripts are built from: the ones that print something, that read or set the environment, that evaluate an expression or a condition, and that simply succeed or fail.
The commands #
Producing text
| Command | Purpose |
|---|---|
echo | Print its arguments as a line of text. |
printf | Print text from a format string, with precise control over layout. |
yes | Print a line over and over, without stopping. |
seq | Print a sequence of numbers. |
tee | Copy standard input to standard output and to each named file. |
The environment
| Command | Purpose |
|---|---|
env | Run a command with a modified environment — or print the current one. |
printenv | Print environment variables. |
Numbers
| Command | Purpose |
|---|---|
numfmt | Convert numbers to and from human-readable forms (1.5G, 1000000). |
factor | Print the prime factors of a number. |
Evaluating
| Command | Purpose |
|---|---|
expr | Evaluate an arithmetic, string, or comparison expression. |
test | Evaluate a condition — a file check or a comparison — as a true/false result. |
Exit status
| Command | Purpose |
|---|---|
true and false | Do nothing, and succeed — or do nothing, and fail. |
A note on exit status #
Several commands here exist to be used for their exit status rather than their output. Every command, when it finishes, leaves behind a number: 0 means success, anything else means a kind of failure. A shell's if, &&, and || all act on that number.
test is the clearest case — it prints nothing and exists only to set an exit status from a condition. true and false are the most minimal: fixed success and fixed failure. Where this topic's pages give an exit-status table, that number is often the whole point of the command.
Where to start #
For everyday printing, echo; for anything that needs columns, padding, or precise formatting, printf.
echo
Peios / Using Peios / Peiosutils / Output and evaluation
echo prints its arguments, separated by single spaces, followed by a newline.
echo [options] [string...]
$ echo Hello, Peios
Hello, Peios
It is the simplest way to put a line of text on standard output — printing a message, or feeding a fixed string into a pipe.
Options #
| Option | Effect |
|---|---|
-n | Do not print the trailing newline. |
-e | Interpret backslash escape sequences in the arguments (see below). |
-E | Do not interpret escape sequences. This is the default. |
Escape sequences #
With -e, echo recognises these backslash sequences and prints the character they stand for:
| Sequence | Character |
|---|---|
\\ | A literal backslash. |
\n | Newline. |
\t | Horizontal tab. |
\r | Carriage return. |
\b | Backspace. |
\f | Form feed. |
\v | Vertical tab. |
\a | Alert (the terminal bell). |
\e | Escape. |
\0NNN | The byte with octal value NNN. |
\xHH | The byte with hexadecimal value HH. |
\c | Stop — produce no further output. |
echo and printf #
echo is fixed: arguments, spaces, a newline. The moment you need control over the output — columns, padding, a number formatted a particular way, output with no spaces between pieces — use printf instead. printf is also more predictable across environments, since echo's handling of escapes and -n varies.
A note on shells #
Many command shells provide their own built-in echo, and the shell's version is what runs when you type echo at a prompt. Built-in versions vary — especially in how they treat -n, -e, and escape sequences — so their behaviour may differ from what is described here. To be certain you are running this command rather than the shell built-in, invoke it by its full path.
Exit status #
| Code | Meaning |
|---|---|
0 | The output was written. |
1 | The output could not be written. |
printf
Peios / Using Peios / Peiosutils / Output and evaluation
printf prints text built from a format string. The format string is printed literally, except that escape sequences become characters and substitution fields are replaced by the arguments that follow.
printf format [argument...]
$ printf 'the letter %X comes before %X\n' 10 11
the letter A comes before B
Where echo prints arguments as-is, printf gives you control: padding, column widths, number bases, fixed decimal places. It is the command for output that has to line up or be exact.
printf writes only what the format string says — there is no automatic trailing newline. Include \n yourself where you want one.
Escape sequences #
The format string interprets these backslash sequences:
| Sequence | Character |
|---|---|
\\ | Backslash. |
\n \t \r | Newline, tab, carriage return. |
\b \f \v \a | Backspace, form feed, vertical tab, alert. |
\e | Escape. |
\NNN | The byte with octal value NNN. |
\xHH | The byte with hexadecimal value HH. |
\uHHHH, \UHHHHHHHH | A Unicode character by hexadecimal code point. |
%% | A literal %. |
Substitution fields #
A field begins with % and is replaced by the next argument, formatted accordingly.
| Field | Formats the argument as… |
|---|---|
%s | a string. |
%b | a string, with backslash escapes in it interpreted. |
%q | a string, quoted so it is safe to reuse as shell input. |
%c | a single character. |
%d, %i | a signed integer. |
%u | an unsigned integer. |
%x, %X | an unsigned integer in hexadecimal (lower- or upper-case). |
%o | an unsigned integer in octal. |
%f, %F | a decimal floating-point number. |
%e, %E | a number in scientific notation. |
%g, %G | whichever of decimal or scientific notation is shorter. |
Width and precision #
Between the % and the field letter you may put two numbers — %[width][.precision]field:
- width — the minimum number of columns. Output narrower than this is padded with leading spaces; a negative width pads on the right instead.
- precision — after a
.; its meaning depends on the field. For a string it is a maximum length; for an integer, a minimum digit count (zero-padded); for a float, the number of decimal places.
$ printf '%-10s %5.2f\n' apples 3.1
apples 3.10
Reusing the format string #
If more arguments are supplied than the format string has fields, printf repeats the format string until the arguments run out. If there are too few, the leftover fields default to an empty string (or 0 for a number).
$ printf '%s: %d\n' alpha 1 beta 2 gamma 3
alpha: 1
beta: 2
gamma: 3
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A missing operand, a bad format string, or a write failure. |
yes
Peios / Using Peios / Peiosutils / Output and evaluation
yes prints a line over and over, without stopping.
yes [string...]
$ yes
y
y
y
...
With no argument it prints y forever. Given arguments, it prints them — joined by spaces — as the repeated line instead.
$ yes retry
retry
retry
...
yes never stops on its own. It ends when something downstream closes the pipe, or when you interrupt it with Ctrl-C.
What it is for #
yes exists to answer prompts. An older interactive command that asks "are you sure? [y/n]" again and again can be fed a stream of confirmations:
$ yes | slow-interactive-command
Most modern commands have a proper option for this — rm -f, and similar — and that is the better way when it exists. yes is the fallback for a command that offers no such option. It is also a quick way to generate an endless stream of identical lines for a test.
Exit status #
| Code | Meaning |
|---|---|
0 | The output was closed normally. |
1 | A write error occurred. |
seq
Peios / Using Peios / Peiosutils / Output and evaluation
seq prints a sequence of numbers, one per line.
seq [options] last
seq [options] first last
seq [options] first increment last
$ seq 3
1
2
3
$ seq 2 2 10
2
4
6
8
10
The three forms control where the sequence starts and how it steps:
seq last— count from 1 up tolast.seq first last— count fromfirstup tolast.seq first increment last— count fromfirsttolast, stepping byincrement.
The increment may be negative, to count down, and the numbers may be fractional. seq is most often used to drive a loop a fixed number of times.
Options #
| Option | Effect |
|---|---|
-s, --separator=STRING | Separate the numbers with STRING instead of a newline. |
-t, --terminator=STRING | End the output with STRING instead of a newline. |
-w, --equal-width | Pad the numbers with leading zeros so they all have the same width. |
-f, --format=FORMAT | Format each number with a printf-style floating-point FORMAT. |
$ seq -s , 1 5
1,2,3,4,5
$ seq -w 8 10
08
09
10
-f takes a single floating-point conversion — see printf for the format syntax — and cannot be combined with -w.
Exit status #
| Code | Meaning |
|---|---|
0 | The sequence was printed. |
1 | An argument was not a valid number, the increment was zero, or no argument was given. |
env
Peios / Using Peios / Peiosutils / Output and evaluation
env runs a command with a changed environment — the set of NAME=VALUE variables a process inherits. Run with no command, it prints the current environment instead.
env [options] [NAME=VALUE]... [command [arg]...]
$ env LANG=C TZ=UTC my-program
$ env
PATH=/bin
HOME=/home/jack
...
How it works #
You give env a list of NAME=VALUE assignments, then a command. env applies the assignments to its own environment and then runs the command, which inherits the result. The assignments affect only that one run — your shell's environment is untouched.
This is the clean way to run something with a particular variable set, without changing it for everything else, and the standard way to run a command with one variable temporarily different.
With no command, env simply prints the environment it would have used — which, with no options, is the current one.
Building the environment #
| Option | Effect |
|---|---|
-i, --ignore-environment | Start from an empty environment, not the inherited one — so the command sees only what you assign. A bare - argument means the same. |
-u, --unset=NAME | Remove NAME from the environment. |
--file=FILE | Read NAME=VALUE lines from a .env-style FILE and apply them. |
The order is: start (inherited, or empty with -i), apply --file, apply --unset, apply the NAME=VALUE arguments.
Running the command #
| Option | Effect |
|---|---|
-C, --chdir=DIR | Change to DIR before running the command. |
--argv0=NAME | Run the command but present NAME as its zeroth argument. |
-S, --split-string=S | Split S into separate arguments. This exists for script interpreter lines, where everything after the interpreter arrives as one string. |
Signal handling #
env can adjust how the command treats signals before launching it:
| Option | Effect |
|---|---|
--ignore-signal[=SIG] | Set the named signals to be ignored. |
--default-signal[=SIG] | Reset the named signals to their default handling. |
--block-signal[=SIG] | Block delivery of the named signals while the command runs. |
--list-signal-handling | List the signal-handling changes the other options requested. |
Other options #
| Option | Effect |
|---|---|
-0, --null | When printing the environment, end each line with a NUL character instead of a newline. Not valid together with a command. |
-v, --debug | Print each processing step env performs. |
Exit status #
When env runs a command, it exits with that command's status. The exception is a failure in env itself:
| Code | Meaning |
|---|---|
| (command's own) | The command ran; this is its exit status. |
125 | env itself failed — for example, a bad option. |
126 | The command was found but could not be run. |
127 | The command was not found. |
printenv
Peios / Using Peios / Peiosutils / Output and evaluation
printenv prints environment variables.
printenv [options] [variable...]
Name one or more variables and printenv prints the value of each. Name none and it prints the whole environment, one NAME=VALUE pair per line.
$ printenv HOME
/home/jack
$ printenv
PATH=/bin
HOME=/home/jack
...
Options #
| Option | Effect |
|---|---|
-0, --null | End each output line with a NUL character instead of a newline — so values that themselves contain newlines can still be told apart. |
printenv and env #
Both can print the environment. printenv is the one built for reading it — it can pick out a single variable by name. env prints the environment too, but its real job is running a command with a modified one. Use printenv to look something up; use env to change something for a command.
Exit status #
| Code | Meaning |
|---|---|
0 | Every named variable was found and printed. |
1 | A named variable was not set. |
2 | A usage error. |
numfmt
Peios / Using Peios / Peiosutils / Output and evaluation
numfmt converts numbers between plain digits and human-readable forms — turning 1500000 into 1.5M, or 1.5M back into 1500000.
numfmt [options] [number...]
$ numfmt --to=si 1500000
1.5M
$ numfmt --from=si 1.5M
1500000
numfmt takes numbers as arguments, or — with none — reads them from standard input, which lets it rescale a column of numbers flowing through a pipe.
The direction of conversion #
| Option | Effect |
|---|---|
--from=UNIT | Parse input numbers that carry a UNIT suffix, scaling them up to plain digits. |
--to=UNIT | Scale output numbers down and add a UNIT suffix. |
A UNIT is one of:
| Unit | Suffixes mean |
|---|---|
none | No suffixes — a suffix is an error. The default. |
si | Powers of 1000: 1K = 1000, 1M = 1000000. |
iec | Powers of 1024: 1K = 1024, 1M = 1048576. |
iec-i | Powers of 1024 with a two-letter suffix: 1Ki = 1024, 1Mi = 1048576. |
auto | Accept any of the above on input, reading Ki/Mi as powers of 1024 and bare K/M as powers of 1000. |
Working on fields #
By default numfmt converts the whole input line. To convert numbers sitting in a column of wider text, name the field:
| Option | Effect |
|---|---|
--field=FIELDS | Convert only the numbers in these fields. FIELDS uses the same range syntax as cut — 3, 2-5, 2-. |
-d, --delimiter=X | Use X to separate fields instead of whitespace. |
--header[=N] | Pass the first N lines through unconverted, as a header. |
Shaping the output #
| Option | Effect |
|---|---|
--format=FORMAT | Format the number with a printf-style floating-point FORMAT, controlling width, padding, and precision. |
--padding=N | Pad the output to N columns — positive right-aligns, negative left-aligns. |
--grouping | Group digits according to the locale (for example, 1,000,000). |
--round=METHOD | Choose the rounding method used when scaling. |
--suffix=SUFFIX | Append SUFFIX to each result, and accept it on input. |
--from-unit=N / --to-unit=N | Set the unit size the input or output is counted in. |
Other options #
| Option | Effect |
|---|---|
--invalid=MODE | Choose what to do with input that is not a valid number: abort, fail, warn, or ignore. |
-z, --zero-terminated | Treat the NUL character as the line delimiter. |
--debug | Print warnings about questionable input. |
Exit status #
| Code | Meaning |
|---|---|
0 | Every number was converted. |
2 | An input was not a valid number, or an option was misused. |
expr
Peios / Using Peios / Peiosutils / Output and evaluation
expr evaluates a single expression and prints its value.
expr expression
$ expr 6 + 7
13
$ expr length "Peios"
5
Each part of the expression — every number, operator, and keyword — is a separate argument. That is why the spaces matter: expr 6+7 is one argument and not an expression, while expr 6 + 7 is three.
Arithmetic #
| Operator | Result |
|---|---|
ARG1 + ARG2 | Sum. |
ARG1 - ARG2 | Difference. |
ARG1 * ARG2 | Product. |
ARG1 / ARG2 | Integer quotient. |
ARG1 % ARG2 | Remainder. |
Comparison #
A comparison yields 1 for true and 0 for false. It is arithmetic when both sides are numbers, and lexical otherwise.
| Operator | True when… |
|---|---|
ARG1 = ARG2 | the two are equal. |
ARG1 != ARG2 | they are unequal. |
ARG1 < ARG2 | ARG1 is less. |
ARG1 <= ARG2 | ARG1 is less or equal. |
ARG1 > ARG2 | ARG1 is greater. |
ARG1 >= ARG2 | ARG1 is greater or equal. |
Logic #
| Operator | Result |
|---|---|
ARG1 | ARG2 | ARG1 if it is neither null nor 0; otherwise ARG2. |
ARG1 & ARG2 | ARG1 if neither argument is null or 0; otherwise 0. |
String operations #
| Form | Result |
|---|---|
length STRING | The number of characters in STRING. |
substr STRING POS LENGTH | LENGTH characters of STRING, counting POS from 1. |
index STRING CHARS | The position of the first of any of CHARS in STRING, or 0. |
STRING : REGEXP | Match REGEXP anchored at the start of STRING. Returns the matched length, or the text captured by \(…\). |
match STRING REGEXP | The same as STRING : REGEXP. |
Many of these operators — *, <, |, ( — are also meaningful to a shell, so quote or escape them so they reach expr intact.
Exit status #
expr's exit status reports on the value it computed:
| Code | Meaning |
|---|---|
0 | The result was neither null nor 0. |
1 | The result was null or 0. |
2 | The expression was syntactically invalid. |
3 | An error occurred during evaluation — such as division by zero. |
factor
Peios / Using Peios / Peiosutils / Output and evaluation
factor prints the prime factors of a number.
factor [options] [number...]
$ factor 360
360: 2 2 2 3 3 5
Each line of output is the original number, a colon, and its prime factors in increasing order, repeated as often as they divide in. Give several numbers and factor factors each; give none and it reads numbers from standard input.
Options #
| Option | Effect |
|---|---|
-h, --exponents | Write a repeated factor in exponent form — p^e — instead of repeating it. |
$ factor -h 360
360: 2^3 3^2 5
Exit status #
| Code | Meaning |
|---|---|
0 | Every number was factored. |
1 | An input was not a valid positive integer. |
test
Peios / Using Peios / Peiosutils / Output and evaluation
test evaluates a single condition and reports whether it is true or false. It prints nothing — the answer is its exit status: 0 for true, 1 for false. That makes it the command a shell's if, while, &&, and || are built on.
test expression
[ expression ]
The two forms are the same command. [ is test under another name; when invoked as [, it requires a closing ] as its last argument. [ -f notes.txt ] and test -f notes.txt are identical.
$ test -f notes.txt && echo "the file exists"
File tests #
Existence and type #
| Test | True when the file… |
|---|---|
-e FILE | exists. |
-f FILE | exists and is a regular file. |
-d FILE | exists and is a directory. |
-L FILE, -h FILE | exists and is a symbolic link. |
-p FILE | exists and is a named pipe (FIFO). |
-S FILE | exists and is a socket. |
-b FILE | exists and is a block device. |
-c FILE | exists and is a character device. |
-s FILE | exists and is not empty. |
Access #
These three tests ask whether you may actually use the file:
| Test | True when… |
|---|---|
-r FILE | you may read the file. |
-w FILE | you may write the file. |
-x FILE | you may execute the file, or search the directory. |
On Peios these are real access checks. test asks the kernel whether your token is granted the right in question — a live check against the file's security descriptor, the same decision any actual read, write, or execute would face. The answer is therefore honest: test -w FILE is true exactly when a write would be permitted.
-x on a regular file means "you are permitted to execute it" — which is a different question from whether the file is an executable. A file can be runnable code and still fail -x for you, and the two are decided separately; see Access decisions.
Comparing two files #
| Test | True when… |
|---|---|
FILE1 -nt FILE2 | FILE1 is newer than FILE2. |
FILE1 -ot FILE2 | FILE1 is older than FILE2. |
FILE1 -ef FILE2 | both name the same file (same device and inode). |
Other file tests #
| Test | True when the file… |
|---|---|
-t FD | file descriptor FD is open on a terminal. |
-N FILE | has been modified since it was last read. |
-O FILE, -G FILE | carries an owner-id / group-id field matching the caller's. |
-g FILE, -u FILE, -k FILE | has its set-group-id, set-user-id, or sticky inode bit set. |
The last two rows probe decorative inode fields. The Peios access model does not consult them — they are stored on the inode and reported for completeness, but they carry no authority. For a true picture of what may be done to a file, use the access tests -r, -w, -x above, not -O, -g, or -u.
String tests #
| Test | True when… |
|---|---|
-n STRING | STRING is not empty. A bare STRING means the same. |
-z STRING | STRING is empty. |
STRING1 = STRING2 | the strings are equal. |
STRING1 != STRING2 | the strings are unequal. |
STRING1 < STRING2 | STRING1 sorts before STRING2. |
STRING1 > STRING2 | STRING1 sorts after STRING2. |
Integer comparisons #
| Test | True when… |
|---|---|
A -eq B | A equals B. |
A -ne B | A does not equal B. |
A -lt B | A is less than B. |
A -le B | A is less than or equal to B. |
A -gt B | A is greater than B. |
A -ge B | A is greater than or equal to B. |
Combining conditions #
| Form | Result |
|---|---|
! EXPRESSION | The negation. |
( EXPRESSION ) | Grouping — escape the parentheses so the shell does not take them. |
EXPR1 -a EXPR2 | True when both are true. |
EXPR1 -o EXPR2 | True when either is true. |
-a and -o are genuinely ambiguous to parse and are best avoided. Combine separate test calls with the shell's own && and || instead:
test -f notes.txt && test -r notes.txt
A note on shells #
Many command shells provide their own built-in test and [, and the shell's version is what runs when you type test at a prompt or in a script. A built-in may not behave as described here — in particular, the access tests -r, -w, and -x are the real access checks described above only when this command runs. To be certain you are running this command rather than the shell built-in, invoke it by its full path.
Exit status #
| Code | Meaning |
|---|---|
0 | The expression was true. |
1 | The expression was false. |
2 | The expression was malformed. |
true and false
Peios / Using Peios / Peiosutils / Output and evaluation
true and false do nothing at all. Their only product is an exit status: true always succeeds, false always fails.
true
false
$ true ; echo $?
0
$ false ; echo $?
1
true exits 0. false exits 1. They take no arguments that matter, produce no output, and have no effect on anything.
What they are for #
A command that is only an exit status is useful as a fixed value in places that expect a command:
- An always-true or always-false loop.
while trueis the standard way to write a loop that runs until something inside it breaks out. - A deliberate placeholder. Setting a configurable command to
truemakes that step do nothing and "succeed";falsemakes it a step that always fails. - A known result in a test. When a script or a condition needs a guaranteed pass or fail to check against,
trueandfalseprovide it.
They are the simplest commands there are — and the overview's note on exit status is the whole idea behind them.
Exit status #
| Code | Meaning |
|---|---|
0 | Returned by true, always. |
1 | Returned by false, always. |
tee
Peios / Using Peios / Peiosutils / Output and evaluation
tee reads standard input and writes it, unchanged, to standard output and to each file you name. It is the T-junction of a pipeline: the data keeps flowing down the pipe while a copy is also captured on disk.
tee [options] [file...]
$ echo saved | tee note.txt
saved
$ cat note.txt
saved
The name is the shape of the letter T — one stream in, the same stream out two ways. That makes tee the tool for the moment you want to both see (or keep piping) some data and keep it at the same time.
Writing to several places at once #
Every byte that arrives on standard input is written to standard output and to each named file. Name more than one file and each one receives a full, identical copy:
$ producer | tee a.log b.log c.log | consumer
Here producer's output reaches consumer down the pipe exactly as if tee were not there, and a.log, b.log, and c.log each end up holding the same complete copy of it. With no files named, tee copies input straight to standard output and nothing else — a plain pass-through.
tee does not buffer between reads: it writes each chunk out as it is read, so a file being written by tee fills up as the data flows, rather than all at once when the input ends.
Overwrite or append #
By default, each named file is truncated — opened fresh, so any existing contents are discarded before the new data is written. A file that does not exist is created.
With -a (--append), tee appends to each named file instead: existing contents are kept and the new data is added to the end. A file that does not exist is still created.
$ echo first | tee log.txt # log.txt now holds "first"
$ echo second | tee -a log.txt # log.txt now holds "first" then "second"
Creating a file inherits a security descriptor #
When tee creates a new output file — a named file that did not already exist — that file needs a security descriptor, and it inherits one from the directory it is created in. This is the same creation-inherits-a-descriptor rule that governs every file-creating tool on Peios; see Files and directories for the shared model.
With -a, when the named file already exists, tee opens that existing file to append and creates nothing, so no new descriptor is involved. Whether opening a file to write succeeds — whether creating a new one or writing an existing one — is decided by the normal access check against the relevant directory or file. tee reports a file it cannot open and, by default, carries on with the outputs it can write.
Files named - #
tee gives no special meaning to -. A file argument of - refers to a file literally named - in the current directory; it is opened, created, and written exactly like any other named file. It is not a stand-in for standard output — standard output is always written and needs no naming.
Ignoring interrupts #
With -i (--ignore-interrupts), tee ignores the interrupt signal (the one a terminal sends on Ctrl-C). This lets tee keep copying to its files even as an interrupt tears down the rest of a pipeline, so the capture is not cut short.
Behaviour on write errors #
By default, if a write to one output fails, tee reports it and stops writing to that output, but keeps writing to the others; a broken-pipe error on standard output is treated quietly. Two options change this policy.
-p sets write-error behaviour so that errors on a pipe (a downstream reader that has gone away) are ignored while other write errors are still reported.
--output-error[=MODE] sets the policy explicitly. Given on its own, with no =MODE, it selects warn-nopipe. The modes are:
| Mode | Behaviour |
|---|---|
warn | Warn on a write error to any output, including a broken pipe, and continue with the remaining outputs. |
warn-nopipe | Warn on a write error that is not a pipe error, and continue. Broken-pipe errors are ignored. This is the mode selected by a bare --output-error. |
exit | Exit on a write error to any output, including a broken pipe. |
exit-nopipe | Exit on a write error that is not a pipe error. Broken-pipe errors are ignored. |
Options #
| Option | Effect |
|---|---|
-a, --append | Append to each named file rather than truncating it. |
-i, --ignore-interrupts | Ignore the interrupt signal. |
-p | Ignore errors from writing to a broken pipe, still reporting other write errors. |
--output-error[=MODE] | Set the write-error policy: warn, warn-nopipe, exit, or exit-nopipe. A bare --output-error means warn-nopipe. |
Exit status #
| Code | Meaning |
|---|---|
0 | The input was copied to standard output and every named file without error. |
1 | A file could not be opened, a write failed under a policy that reports or exits on it, or the input could not be read. |
Hashing and encoding
Peios / Using Peios / Peiosutils / Hashing and encoding
This topic covers two related jobs. Hashing reduces a file to a short fixed-size value — a checksum — that can be used to tell whether the file has changed. Encoding rewrites binary data as plain text so it can travel safely through channels that only handle text.
The commands #
Checksums and hashes
| Command | Purpose |
|---|---|
| checksum commands | md5sum, sha1sum, sha256sum, and the rest — one command per hash algorithm. |
cksum | The general checksum tool — any of the algorithms, selected by an option. |
sum | A small, legacy block-checksum. |
Encoding
| Command | Purpose |
|---|---|
base32 and base64 | Encode binary data as text using the base32 or base64 alphabet, and decode it back. |
basenc | The general encoder — base64, base32, base16, and several more, selected by an option. |
What these commands are not #
It is worth being clear up front, because both jobs are easy to mistake for something they are not.
A hash is not encryption. Hashing is one-way: a checksum tells you whether data has changed, but the original cannot be recovered from it. It protects against corruption and detects tampering — it does not keep anything secret.
Encoding is not encryption either. base64 rewrites data into a text-safe form, but anyone can decode it straight back — it is a change of representation, not of secrecy. Encoding something does not protect it.
Neither hashing nor encoding hides data. They are about integrity and transport, not confidentiality.
Where to start #
To verify a file you downloaded against a published checksum, read the checksum commands. To turn binary data into something safe to paste into text, read base32 and base64.
Checksum commands
Peios / Using Peios / Peiosutils / Hashing and encoding
This page covers a family of commands that all work the same way and differ only in which hash algorithm they use:
| Command | Algorithm | Digest size |
|---|---|---|
md5sum | MD5 | 128-bit |
sha1sum | SHA-1 | 160-bit |
sha224sum | SHA-224 | 224-bit |
sha256sum | SHA-256 | 256-bit |
sha384sum | SHA-384 | 384-bit |
sha512sum | SHA-512 | 512-bit |
b2sum | BLAKE2b | up to 512-bit |
sha256sum [options] [file...]
Everything below is written with sha256sum, but applies to every command in the table.
Computing a checksum #
Run plainly, the command prints the hash of each file, followed by the file name:
$ sha256sum installer.iso
9f86d0818884...b1a5 installer.iso
With no file, it reads standard input. The hash is a fingerprint of the file's contents: change a single byte and the hash changes completely.
Verifying with a checksum #
The everyday use is verification — confirming a file is exactly what it should be, usually a download.
First, the file's publisher computes a checksum and publishes it, often in a file:
$ sha256sum installer.iso > installer.iso.sha256
Then anyone with the file and that checksum file can verify:
$ sha256sum -c installer.iso.sha256
installer.iso: OK
-c reads each hash filename line, recomputes the hash, and reports OK or FAILED. A FAILED means the file is not the one the checksum was made from — corrupted in transit, or altered.
| Option | Effect |
|---|---|
-c, --check | Read checksums from the given files and verify them. |
--ignore-missing | In check mode, do not fail over files that are listed but absent. |
--quiet | In check mode, print nothing for files that pass — only failures. |
--status | In check mode, print nothing at all; report only through the exit status. |
-w, --warn | Warn about improperly formatted lines in the checksum file. |
--strict | In check mode, fail if any checksum line is malformed. |
Output format #
| Option | Effect |
|---|---|
--tag | Produce a tagged line — SHA256 (file) = hash — that records which algorithm was used. |
--untagged | Produce the plain hash file line. This is the default. |
-z, --zero | End each output line with a NUL character instead of a newline. |
-b, --binary | Note the file as read in binary mode. |
-t, --text | Note the file as read in text mode. |
b2sum additionally accepts -l, --length=BITS to produce a shorter BLAKE2b digest.
A note on choosing an algorithm #
These commands detect change — but not all of them resist a deliberate attempt to forge a match. MD5 and SHA-1 can be defeated by an attacker who wants two different files to share a checksum. They are still fine for catching accidental corruption, but for verifying that a file has not been tampered with, use a SHA-2 command (sha256sum and up) or b2sum.
Exit status #
| Code | Meaning |
|---|---|
0 | The checksums were computed, or — in check mode — every file verified. |
1 | In check mode, a file failed verification. |
2 | An error — a file could not be read, or a checksum file was malformed. |
cksum
Peios / Using Peios / Peiosutils / Hashing and encoding
cksum is the general checksum command. Where each of the checksum commands is fixed to one algorithm, cksum does any of them — the algorithm is an option.
cksum [options] [file...]
$ cksum installer.iso
3915528286 372736000 installer.iso
$ cksum -a sha256 installer.iso
9f86d0818884...b1a5 installer.iso
With no -a, cksum computes a CRC checksum and prints the CRC value, the file's byte count, and the name. With -a, it behaves like the corresponding dedicated command.
Choosing the algorithm #
| Option | Effect |
|---|---|
-a, --algorithm=NAME | Use the named algorithm. |
NAME may be:
| Name | Algorithm |
|---|---|
crc | The default CRC. |
crc32b | A CRC-32 variant. |
md5 | MD5 — as md5sum. |
sha1 | SHA-1 — as sha1sum. |
sha224, sha256, sha384, sha512 | The SHA-2 family — as the matching sha*sum. |
sha3 | SHA-3. |
blake2b | BLAKE2b — as b2sum. |
sm3 | The SM3 hash. |
sysv, bsd | The legacy block checksums — as sum. |
crc, crc32b, sha3, and sm3 are available only through cksum — there is no dedicated command for them.
Verifying #
cksum checks files the same way the dedicated commands do:
| Option | Effect |
|---|---|
-c, --check | Read checksums and verify the files against them. |
--ignore-missing | Do not fail over listed files that are absent. |
--quiet | Print nothing for files that pass. |
--status | Print nothing; report only through the exit status. |
-w, --warn | Warn about malformed checksum lines. |
--strict | Fail on a malformed checksum line. |
Output format #
| Option | Effect |
|---|---|
--tag | Produce a tagged ALGORITHM (file) = hash line. The default for most algorithms. |
--untagged | Produce a plain hash file line. |
--raw | Output the raw binary digest, with nothing else. |
--base64 | Print the digest in base64 rather than hexadecimal. |
-l, --length=BITS | For an algorithm that supports it, produce a shorter digest. |
-z, --zero | End each output line with a NUL character. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success — or, in check mode, every file verified. |
1 | In check mode, a file failed verification. |
2 | An error — a file could not be read, or an option was invalid. |
sum
Peios / Using Peios / Peiosutils / Hashing and encoding
sum computes a small, old-style checksum of a file and reports it along with the file's size in blocks.
sum [options] [file...]
$ sum archive.tar
12345 84 archive.tar
The output is the checksum, the block count, and the file name. With no file, sum reads standard input.
Which algorithm #
sum predates the modern hashes and offers two historical algorithms:
| Option | Algorithm |
|---|---|
-r | The BSD checksum, counted in 1 KiB blocks. This is the default. |
-s, --sysv | The System V checksum, counted in 512-byte blocks. |
When to use it #
sum exists for compatibility — for reading checksums produced by old tools, and for scripts that still expect its output. Its checksum is short and weak: it catches some accidental corruption, but it is easily fooled and gives no protection against tampering.
For anything new, do not use sum. Use a checksum command such as sha256sum, or cksum — both of which cksum can still produce, via cksum -a bsd and cksum -a sysv, if you need the legacy values.
Exit status #
| Code | Meaning |
|---|---|
0 | The checksum was computed. |
1 | A file could not be read. |
base32 and base64
Peios / Using Peios / Peiosutils / Hashing and encoding
base32 and base64 encode binary data as plain text — and decode it back. They are the same command with one difference: the alphabet they use.
base32 [options] [file]
base64 [options] [file]
$ echo Peios | base64
UGVpb3MK
$ echo UGVpb3MK | base64 -d
Peios
What encoding is for #
Some channels only carry text safely — they mangle or reject arbitrary bytes. Encoding rewrites binary data using a small, safe set of characters, so it can pass through unharmed; decoding reverses it exactly. base64 uses 64 characters and is the more compact; base32 uses 32 and is more robust where case might not survive. With no file, both read standard input.
Encoding is not encryption — anyone can decode the result. See the overview.
Options #
By default these commands encode. The options below apply identically to both.
| Option | Effect |
|---|---|
-d, --decode | Decode instead of encode. |
-i, --ignore-garbage | When decoding, skip any characters that are not part of the alphabet, rather than failing on them. |
-w, --wrap=COLS | When encoding, wrap the output to lines of COLS characters. The default is 76; -w 0 disables wrapping and produces one unbroken line. |
When decoding, newlines in the input are always tolerated; -i is for other stray characters.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | A file could not be read, or — when decoding — the input was not valid for the alphabet. |
basenc
Peios / Using Peios / Peiosutils / Hashing and encoding
basenc is the general encoding command. Where base32 and base64 each do one encoding, basenc does many — the encoding is chosen by an option.
basenc encoding [options] [file]
$ echo Peios | basenc --base16
5065696F730A
Every run of basenc must name an encoding. With no file, it reads standard input.
The encodings #
| Option | Encoding |
|---|---|
--base64 | Standard base64 — the same as the base64 command. |
--base64url | Base64 with a file- and URL-safe alphabet. |
--base32 | Standard base32 — the same as the base32 command. |
--base32hex | Base32 with the extended-hex alphabet. |
--base16 | Hexadecimal. |
--base2msbf | A bit string, most-significant bit first. |
--base2lsbf | A bit string, least-significant bit first. |
--z85 | A compact, ASCII85-style encoding. When encoding, the input length must be a multiple of 4; when decoding, a multiple of 5. |
--base58 | Base58 — an alphabet chosen so its characters are not visually confusable. |
Options #
By default basenc encodes. These options apply to whichever encoding you chose:
| Option | Effect |
|---|---|
-d, --decode | Decode instead of encode. |
-i, --ignore-garbage | When decoding, skip characters that are not part of the alphabet. |
-w, --wrap=COLS | When encoding, wrap output to lines of COLS characters. -w 0 disables wrapping. |
basenc and the dedicated commands #
For plain base64 or base32, base64 and base32 are the shorter way to ask. Use basenc when you need an encoding the dedicated commands do not offer — hex, URL-safe base64, the bit-string forms, z85, or base58.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | No encoding was named, a file could not be read, or the input was not valid for the chosen encoding. |
System and processes
Peios / Using Peios / Peiosutils / System and processes
This topic gathers the commands that deal with the running system rather than with files: reading the time, asking what hardware and system you are on, launching another command under some constraint, signalling a process, working with the terminal.
The commands #
Time
| Command | Purpose |
|---|---|
date | Print — or set — the system date and time. |
sleep | Pause for a given length of time. |
uptime | Show how long the system has been running. |
Running a command under a constraint
| Command | Purpose |
|---|---|
timeout | Run a command, and kill it if it runs too long. |
nohup | Run a command so it survives the terminal closing. |
nice | Run a command at an adjusted scheduling priority. |
stdbuf | Run a command with altered stream buffering. |
chroot | Run a command with a different directory as its root. |
Signalling processes
| Command | Purpose |
|---|---|
kill | Send a signal to a process. |
System identity and capacity
| Command | Purpose |
|---|---|
uname | Print system information — the OS, the kernel, the machine. |
arch | Print the machine's hardware architecture. |
hostname | Display — or set — the system's host name. |
hostid | Print the host's numeric identifier. |
nproc | Print how many processor cores are available. |
Who you are
| Command | Purpose |
|---|---|
whoami | Print the name of the user a process is running as. |
id | Print the user and group identity of a process, or of a named user. |
groups | Print the groups a user belongs to. |
logname | Print the login name of the user a process is running as. |
These report the projection of a token onto POSIX user and group numbers, which is what Linux programs see. The numbers grant nothing on their own — id explains what they can and cannot say, and token shows the identity underneath them.
Storage and terminal
| Command | Purpose |
|---|---|
sync | Flush cached writes out to persistent storage. |
tty | Print the name of the terminal on standard input. |
stty | Display or change terminal settings. |
A note on changing the system #
Most commands here only report. A few can change the running system — date can set the clock, hostname can set the host name. Changing system-wide state is a privileged operation: it succeeds only for a caller whose token holds the right to do it, and is refused otherwise. The pages for those commands say so where it applies, and Privileges is the full picture.
Where to start #
uname answers "what am I running on?". kill is the one to read carefully — sending the wrong signal to the wrong process is a quick way to lose work.
date
Peios / Using Peios / Peiosutils / System and processes
date prints the current date and time. With the right option, it can also set the system clock, display some other moment, or format a date however you need.
date [options] [+format]
$ date
Sun 17 May 2026 14:32:08 BST
$ date +%Y-%m-%d
2026-05-17
Formatting the output #
A +format argument controls the output. The format string is printed literally, except for % sequences, each of which is replaced by a piece of the date. The common ones:
| Sequence | Is replaced by |
|---|---|
%Y %m %d | Year, month, day. |
%H %M %S | Hour, minute, second. |
%F | The full date — same as %Y-%m-%d. |
%T | The time — same as %H:%M:%S. |
%A %a | Weekday name, full and abbreviated. |
%B %b | Month name, full and abbreviated. |
%j | Day of the year. |
%s | Seconds since 1970-01-01 UTC. |
%z %Z | Numeric and named time zone. |
%% | A literal %. |
The full set is long; date --help lists every sequence with an example.
Displaying a different moment #
| Option | Effect |
|---|---|
-d, --date=STRING | Show the time described by STRING — "yesterday", "next Friday", "@1615432800" — instead of now. |
-r, --reference=FILE | Show FILE's last modification time. |
-f, --file=DATEFILE | Like -d, but once for each line of DATEFILE. |
-u, --universal | Work in Coordinated Universal Time (UTC) rather than the local zone. |
Standard formats #
| Option | Output |
|---|---|
-I, --iso-8601[=FMT] | ISO 8601 — FMT is date, hours, minutes, seconds, or ns. |
-R, --rfc-email | The format used in email headers. |
--rfc-3339=FMT | RFC 3339 — FMT is date, seconds, or ns. |
Setting the clock #
| Option | Effect |
|---|---|
-s, --set=STRING | Set the system clock to the time described by STRING. |
Setting the clock changes state for the whole system, so it is a privileged operation — it succeeds only for a caller whose token carries the right to set the time, and is refused otherwise. See Privileges.
Exit status #
| Code | Meaning |
|---|---|
0 | The date was printed or set. |
1 | An invalid date or format, or the clock could not be set. |
sleep
Peios / Using Peios / Peiosutils / System and processes
sleep does nothing for a given length of time, then exits.
sleep number[suffix]...
$ sleep 5 # pause for five seconds
$ sleep 1.5h # pause for an hour and a half
It is used to space things out — a pause between the steps of a script, a delay before a retry.
Specifying the duration #
number may be a whole number or a fraction. A suffix gives the unit:
| Suffix | Unit |
|---|---|
s | Seconds. This is the default if no suffix is given. |
m | Minutes. |
h | Hours. |
d | Days. |
Given more than one argument, sleep waits for the sum of them — sleep 1m 30s pauses for ninety seconds.
Exit status #
| Code | Meaning |
|---|---|
0 | The full time elapsed. |
1 | A bad argument, or sleep was interrupted before the time was up. |
timeout
Peios / Using Peios / Peiosutils / System and processes
timeout runs a command with a time limit. If the command finishes in time, nothing special happens. If it is still running when the limit is reached, timeout kills it.
timeout [options] duration command...
$ timeout 30s slow-fetch https://example.com
It is the guard against a command that might hang — a network fetch, a script that could loop forever.
The duration #
duration is a number with an optional unit suffix: s for seconds (the default), m for minutes, h for hours, d for days. A duration of 0 disables the limit.
How the command is stopped #
When the limit is reached, timeout sends the command a TERM signal — a request to shut down cleanly. A well-behaved command stops there. A command that ignores TERM keeps running, which is what -k is for.
| Option | Effect |
|---|---|
-s, --signal=SIGNAL | Send SIGNAL instead of TERM when the limit is reached. |
-k, --kill-after=DURATION | If the command is still running DURATION after the first signal, send it a KILL signal — which cannot be ignored. |
--preserve-status | Exit with the command's own status even when it timed out. |
--foreground | Allow the command to keep reading from the terminal; do not time out the command's own children. |
-v, --verbose | Report to standard error whenever a signal is sent. |
Exit status #
timeout passes through the command's exit status when the command finishes on its own. Otherwise:
| Code | Meaning |
|---|---|
| (command's own) | The command finished within the limit. |
124 | The command timed out. |
125 | timeout itself failed. |
126 | The command was found but could not be run. |
127 | The command was not found. |
137 | The command was ended by a KILL signal. |
nohup
Peios / Using Peios / Peiosutils / System and processes
nohup runs a command so that it survives the terminal closing. Normally, when a terminal session ends, the system sends a hangup signal to the commands running under it, and they stop. A command started with nohup ignores that signal and keeps running.
nohup [options] command [arg]...
$ nohup long-build &
It is how you start something that needs to outlast your session — a long build, a background job — without it being killed the moment you disconnect.
Where the output goes #
A command run with nohup is meant to outlive the terminal, so nohup cannot leave its streams attached to one. It redirects any stream that is still connected to a terminal:
- Standard input, if it is a terminal, is replaced with an empty source — the command reads nothing.
- Standard output, if it is a terminal, is appended to a file named
nohup.outin the current directory. If that file cannot be opened there,nohupfalls back tonohup.outin your home directory. - Standard error, if it is a terminal, is sent to wherever standard output now goes.
Streams that you have already redirected yourself — to a file, or a pipe — are left as they are.
How nohup.out is secured #
When nohup has to create nohup.out, that file is a new file and needs a security descriptor. nohup does not let it inherit one from the directory. It creates nohup.out with a deliberate, locked descriptor: full access for the owner, and no one else.
The reasoning is that nohup.out captures whatever the command writes, which the person running it has not chosen to share. A file that appears as a side effect should not be more open than its creator intended — so nohup gives it the safe default of owner-only, rather than whatever the surrounding directory would have handed down.
| Option | Effect |
|---|---|
--sddl=SDDL | Create nohup.out with the security descriptor given as an SDDL string, instead of the owner-only default. |
--sddl only matters when nohup actually creates the file. If nohup.out already exists, nohup appends to it and leaves its existing security alone.
Exit status #
When nohup runs a command, it exits with that command's status once it finishes. The exception is a failure in nohup itself:
| Code | Meaning |
|---|---|
| (command's own) | The command ran; this is its exit status. |
125 | nohup could not set things up — it could not detach from the terminal, or could not open nohup.out anywhere. The command was not run. |
nice
Peios / Using Peios / Peiosutils / System and processes
nice runs a command at an adjusted scheduling priority — making it more, or less, willing to give the processor up to other work.
nice [options] [command [arg]...]
$ nice -n 15 big-batch-job
With no command, nice prints the current niceness.
Niceness #
A process has a niceness — a number from -20 to 19:
- A high niceness (toward 19) means the process is "nicer" to others — it yields the processor readily. Good for background work that should not get in the way.
- A low niceness (toward -20) means the process is favoured — it gets the processor more often.
By default nice adds 10 to the niceness, making the command run in the background more gracefully.
| Option | Effect |
|---|---|
-n, --adjustment=N | Add N to the niceness instead of 10. N may be negative. |
Raising a command's niceness — running it lower priority — is always allowed. Lowering the niceness, to claim a higher priority than normal, is a privileged request and may be refused; when it is, nice warns and runs the command at the priority it was permitted.
Exit status #
When nice runs a command, it exits with that command's status. The exception is a failure in nice itself:
| Code | Meaning |
|---|---|
| (command's own) | The command ran; this is its exit status. |
0 | No command was given; the niceness was printed. |
125 | nice itself failed — for example, a bad adjustment value. |
126 | The command was found but could not be run. |
127 | The command was not found. |
kill
Peios / Using Peios / Peiosutils / System and processes
kill sends a signal to a process. Despite the name, a signal is not always fatal — it is a message, and some signals ask a process to do something other than stop.
kill [options] pid...
$ kill 4821 # ask process 4821 to terminate
$ kill -KILL 4821 # force it to stop
A pid is a process identifier — the number a running process is known by.
Signals #
With no option, kill sends TERM — a polite request to shut down, which a well-behaved process honours by cleaning up and exiting. The signals you will use most:
| Signal | Meaning |
|---|---|
TERM | Terminate. The default — a request to stop cleanly. |
KILL | Stop immediately. Cannot be caught or ignored, so it cannot be refused — but the process gets no chance to clean up. The last resort. |
HUP | Hang up. Conventionally tells a long-running service to reload its configuration. |
INT | Interrupt — the signal a terminal sends on Ctrl-C. |
STOP / CONT | Suspend the process / resume a suspended one. |
| Option | Effect |
|---|---|
-s, --signal=SIGNAL | Send SIGNAL instead of TERM. |
-SIGNAL | A shorthand — -KILL or -9 both send KILL. |
-l, --list | List the signal names. |
-L, --table | List the signals as a table of numbers and names. |
Use KILL only when TERM has failed. A process killed outright cannot save its work or release what it holds.
Exit status #
| Code | Meaning |
|---|---|
0 | Every signal was sent. |
1 | A signal could not be sent — a process that does not exist, or one you are not permitted to signal. |
uname
Peios / Using Peios / Peiosutils / System and processes
uname prints information about the system you are running on.
uname [options]
$ uname
Peios
$ uname -a
Peios host-01 1.4.0 #1 SMP 2026-05-10 x86_64 Peios
With no option, uname prints the operating-system name — Peios.
What each option prints #
| Option | Prints |
|---|---|
-s | The kernel name. |
-o | The operating-system name — Peios. |
-n | The node name — the name this system is known by on the network. |
-r | The kernel release. |
-v | The kernel version. |
-m | The machine's hardware name (its architecture). |
-p | The processor type. |
-i | The hardware platform. |
-a | Everything — equivalent to -mnrsvo. |
Give several options and uname prints those fields, in a fixed order, on one line.
The operating system is Peios #
uname -o, and the operating-system field of uname -a, report Peios. This is the field a script should check when it wants to confirm what system it is on.
For the machine architecture alone, arch is the shorter command — it prints exactly what uname -m prints.
Exit status #
| Code | Meaning |
|---|---|
0 | The requested information was printed. |
1 | The system information could not be read. |
arch
Peios / Using Peios / Peiosutils / System and processes
arch prints the machine's hardware architecture — the kind of processor the system runs on.
arch
$ arch
x86_64
That is the whole command. It takes no options and prints a single name.
arch prints exactly what uname -m prints. It exists as a short, obvious name for the one question "what architecture is this?" — convenient in a script that picks an architecture-specific path.
Exit status #
| Code | Meaning |
|---|---|
0 | The architecture was printed. |
1 | The architecture could not be determined. |
hostname
Peios / Using Peios / Peiosutils / System and processes
hostname displays the system's host name — the name the system is known by.
hostname [options] [name]
$ hostname
host-01.example.com
What is displayed #
By default hostname prints the fully qualified name. These options narrow or change what is shown:
| Option | Prints |
|---|---|
-f, --fqdn | The fully qualified domain name — the full host.domain form. This is the default. |
-s, --short | The short host name only — the part before the first dot. |
-d, --domain | The DNS domain part only. |
-i, --ip-address | The network address (or addresses) the host name resolves to. |
Setting the host name #
Given a name argument, hostname sets the system's host name to it.
$ hostname host-02
Changing the host name changes state for the whole system, so it is a privileged operation — it succeeds only for a caller whose token carries the right to do it, and is refused otherwise. See Privileges.
Exit status #
| Code | Meaning |
|---|---|
0 | The host name was displayed or set. |
1 | A failure — the name could not be read, set, or resolved. |
hostid
Peios / Using Peios / Peiosutils / System and processes
hostid prints the host's numeric identifier, in hexadecimal.
hostid
$ hostid
007f0100
The host id is a number associated with the system. It takes no options and prints one value.
Where hostname gives the system's name — meant to be read by people — hostid gives a numeric value, used by software that wants a machine identifier in a fixed numeric form.
Exit status #
| Code | Meaning |
|---|---|
0 | The host id was printed. |
nproc
Peios / Using Peios / Peiosutils / System and processes
nproc prints the number of processor cores available.
nproc [options]
$ nproc
8
By default it prints the number of cores available to the current process — which can be fewer than the machine has, if the process has been restricted to a subset. It is most often used to decide how many parallel jobs to run.
Options #
| Option | Effect |
|---|---|
--all | Print the number of cores the whole system has, ignoring any restriction on the current process. |
--ignore=N | Subtract N from the count — for leaving some cores free rather than using every one. |
The environment variables OMP_NUM_THREADS and OMP_THREAD_LIMIT, if set, also bound the number nproc reports.
Exit status #
| Code | Meaning |
|---|---|
0 | The count was printed. |
1 | An option value was invalid. |
uptime
Peios / Using Peios / Peiosutils / System and processes
uptime shows how long the system has been running, along with a few related figures.
uptime [options]
$ uptime
14:32:08 up 6 days, 3:21, load average: 0.18, 0.24, 0.21
The default line packs in three things:
- the current time;
- how long the system has been up;
- the load average — a rough measure of how busy the system has been over the last 1, 5, and 15 minutes.
If you know uptime from other systems you may be expecting a count of logged-in users between the uptime and the load average. Peios does not print one. That count comes from a login database (utmp) that Peios does not keep — logon sessions are a KACS concept, and the tooling to report them is not built yet. Rather than print a figure that would always read 0 users, the field is left out until it can be answered honestly.
Options #
| Option | Effect |
|---|---|
-p, --pretty | Show just the uptime, in a readable phrase — up 6 days, 3 hours, 21 minutes. |
-s, --since | Show the date and time the system started, rather than how long ago that was. |
Exit status #
| Code | Meaning |
|---|---|
0 | The uptime was printed. |
1 | The boot time could not be read. |
sync
Peios / Using Peios / Peiosutils / System and processes
sync flushes cached writes to persistent storage.
sync [options] [file...]
$ sync
When a program writes to a file, the data does not always reach the physical device immediately — for speed, the system holds recent writes in memory and writes them out a little later. sync forces that pending data out now, so that what is on the device matches what programs have written.
It is the command to run before doing something that should not lose recent writes — before powering off by hand, or before removing storage.
Options #
With no argument, sync flushes everything. The options narrow it:
| Option | Effect |
|---|---|
file... | Flush only the given files, rather than everything. |
-d, --data | For the given files, flush only the file data, not metadata that does not need it. |
-f, --file-system | Flush the entire file system that contains each given file. |
Exit status #
| Code | Meaning |
|---|---|
0 | The flush completed. |
1 | A named file could not be reached. |
tty
Peios / Using Peios / Peiosutils / System and processes
tty prints the name of the terminal connected to standard input.
tty [options]
$ tty
/dev/pts/3
If standard input is not a terminal — because it has been redirected from a file or a pipe — tty prints not a tty instead.
That second behaviour is the useful one: a script can run tty to find out whether it is being run interactively or as part of a pipeline, and behave accordingly.
Options #
| Option | Effect |
|---|---|
-s, --silent | Print nothing; report the answer only through the exit status. |
Exit status #
| Code | Meaning |
|---|---|
0 | Standard input is a terminal. |
1 | Standard input is not a terminal. |
2 | A usage error. |
stty
Peios / Using Peios / Peiosutils / System and processes
stty displays and changes the settings of a terminal — how it handles input, output, and special keys.
stty [options] [setting...]
$ stty
speed 38400 baud; line = 0;
-brkint -imaxbel iutf8
Run with no arguments, stty prints the settings that differ from the usual defaults. Given setting arguments, it changes them.
Viewing the settings #
| Option | Effect |
|---|---|
-a, --all | Print every setting, in a readable layout. |
-g, --save | Print every setting in a single compact line — a form that can be fed straight back to stty to restore them. |
The -g form is how you save and restore a terminal's state:
$ saved=$(stty -g) # capture the current state
$ stty -echo # ...change something...
$ stty "$saved" # restore exactly what was saved
Changing the settings #
A setting argument turns something on or off, or assigns a value. A few common ones:
| Setting | Effect |
|---|---|
echo / -echo | Show / hide typed characters — -echo is how a password prompt hides input. |
rows N / cols N | Tell the terminal its size. |
| A number | Set the connection speed (baud rate). |
stty has a large catalog of settings; stty -a shows them all with their current values.
Choosing the terminal #
| Option | Effect |
|---|---|
-F, --file=DEVICE | Operate on DEVICE instead of the terminal on standard input. |
Exit status #
| Code | Meaning |
|---|---|
0 | The settings were printed or changed. |
1 | A setting was invalid, or the terminal could not be accessed. |
stdbuf
Peios / Using Peios / Peiosutils / System and processes
stdbuf runs a command with altered buffering on its standard streams.
stdbuf [options] command...
$ slow-producer | stdbuf -oL grep error
What buffering is, and why change it #
A program usually does not write each piece of output the instant it is produced — it collects output in a buffer and writes it in batches, which is faster. The cost is delay: when a program's output feeds a pipe, that batching can hold lines back for a long time, which is unhelpful when you are watching a pipeline live.
stdbuf lets you override that batching for a command, so its output appears sooner.
Setting the buffering #
| Option | Stream |
|---|---|
-i, --input=MODE | Standard input. |
-o, --output=MODE | Standard output. |
-e, --error=MODE | Standard error. |
MODE is one of:
| MODE | Buffering |
|---|---|
0 | Unbuffered — every write goes out immediately. |
L | Line-buffered — output is flushed at the end of each line. Not valid for input. |
| a size | Fully buffered with a buffer of that many bytes. Accepts suffixes — K, M, and so on. |
-oL — line-buffered output — is the common case: it makes a command in a pipeline emit each line as it is finished.
A limitation #
stdbuf adjusts the default buffering. A command that manages its own stream buffering will override what stdbuf sets, and some commands do not use buffered streams at all — for those, stdbuf has no effect.
Exit status #
When stdbuf runs a command, it exits with that command's status. The exception is a failure in stdbuf itself:
| Code | Meaning |
|---|---|
| (command's own) | The command ran; this is its exit status. |
(a stdbuf-level error code) | The command could not be started. |
chroot
Peios / Using Peios / Peiosutils / System and processes
chroot runs a command with a different directory as its root — its /. Inside that command, the chosen directory is the top of the file system, and nothing above it can be named or reached.
chroot newroot [command [arg]...]
$ chroot /mnt/system /bin/sh
With a command, chroot changes the root to newroot and runs the command there. With no command, it starts an interactive shell.
What it does #
A process normally sees the whole file system, rooted at the real /. After chroot, the named directory becomes that process's /: a path like /etc/config inside the command resolves to newroot/etc/config, and there is no path that reaches outside newroot at all.
It is used to work inside another installed system — repairing or configuring a system image by entering it — and to run a command confined to a known subtree.
For the command to be usable, newroot must already contain what the command needs: the command's own executable, and whatever libraries and files it depends on, all present under newroot.
Options #
| Option | Effect |
|---|---|
--skip-chdir | Do not change the working directory to / after changing the root. Permitted only when newroot is the current /. |
A privileged operation #
Changing a process's root directory is a privileged operation. chroot succeeds only for a caller whose token carries the right to do it, and is refused otherwise — the ability to redraw a process's view of the file system is not something every principal holds. See Privileges.
Exit status #
chroot exits with the command's own status once it finishes. Otherwise:
| Code | Meaning |
|---|---|
125 | chroot itself failed — for example, newroot is not a directory. |
126 | The command was found but could not be run. |
127 | The command was not found. |
whoami
Peios / Using Peios / Peiosutils / System and processes
whoami prints the name of the user the calling process is running as.
whoami
$ whoami
jack
It takes no arguments. It is the same answer as id -un, which is all it has ever been.
Where the name comes from #
The name is resolved from the process's effective user ID, which is a projection of the token's user SID. The lookup goes to the authority — there is no /etc/passwd to read — and comes back as the principal's canonical name. Resolving names is the full picture.
The name is the principal's; the number it was looked up by is not what grants anything. If you want the identity access is actually decided against, id -Z prints the SID.
Exit status #
| Code | Meaning |
|---|---|
0 | The name was printed. |
1 | The user ID could not be resolved to a name. |
A failure here usually means the authority could not be reached rather than that the account is missing — identity does not resolve before the authority is running, which is the correct answer rather than a gap.
id
Peios / Using Peios / Peiosutils / System and processes
id prints who a process is: its user, its primary group, the groups it belongs to, and its security context.
id [options] [USER]...
$ id
uid=5001000(jack) gid=5000513(Users) groups=5000513(Users),100(Everyone),101(Authenticated Users),1544(Administrators) context=S-1-5-21-1004336348-1177238915-682003330-1000:High
With no argument it reports the calling process. Given one or more users, it reports on those instead.
The numbers are projections #
This is the thing to understand before reading id's output on Peios.
The uid= and gid= numbers are projections of the token's SIDs. They are real, stable, and exactly what Linux programs see and what filesystems store — but nothing is decided by them. Access is decided against the SID the number was projected from, by the access check. A number here grants nothing.
That distinction is not academic, because the projection cannot carry everything a token holds:
- Groups whose SIDs are deliberately unnumbered —
Interactive,Network,Batch,Service— cannot appear at all. They are what make a rule like "network logons cap at Low" expressible, and they have no gid by design. - A group's attributes have nowhere to go. A deny-only group, which can block access through a deny entry but grant nothing through an allow entry, appears in
groups=looking exactly like an ordinary membership. - The integrity level and the logon session have no number either.
So two tokens can project to identical numbers and mean different things. The clearest case is a filtered token: it carries the same user SID and the same group numbers as the token it came from, and differs only in attributes the projection cannot express.
That is why the default line ends with a context= field.
The context field #
context= is the authoritative half: the user SID access is actually decided against, and the integrity level.
context=S-1-5-21-1004336348-1177238915-682003330-1000:High
The integrity level is part of it precisely because the SID alone does not separate an elevated token from its filtered counterpart — both carry the same user SID, and the level is what differs.
If you know id from another Unix, this is the field that carries the SELinux context there. Peios has no SELinux; its security context is the token, so that is what the field holds.
context= is omitted when a user is named rather than the calling process — a named user has no token here to read — and when POSIXLY_CORRECT is set, so a caller that asked for the POSIX shape still gets it.
For the whole token rather than this summary, use token.
Options #
| Option | Effect |
|---|---|
-u, --user | Print only the effective user ID. |
-g, --group | Print only the effective group ID. |
-G, --groups | Print only the group IDs, space-separated. |
-n, --name | With -u, -g or -G, print names instead of numbers. |
-r, --real | With -u, -g or -G, print the real ID rather than the effective one. |
-z, --zero | Separate entries with NUL rather than whitespace. Not allowed in the default format. |
-Z, --context | Print only the security context. |
-p | A readable, multi-line form. |
-P | Print as a password-file entry. |
-a | Ignored; accepted for compatibility. |
-A | Not applicable on Peios — see below. |
-n and -r need one of -u, -g or -G; they do not change the default format.
-r means something different here #
On other Unixes the real/effective split is the setuid boundary. On Peios a setuid bit does not change a token, so that boundary does not exist. What the split tracks instead is impersonation: the real IDs come from the process's own token, the effective ones from whatever token is in force.
id never impersonates, so in practice it reports the same either way and the euid=/egid= fields never appear. That is not a fault — there is genuinely nothing differing to report.
-A is not applicable #
-A reports BSD audit session properties. Peios audits through its own event system and keeps no BSM session, so -A says so rather than silently succeeding at nothing.
id -G and id -G <yourself> differ #
Asking about yourself and asking about your name are two different questions here, and they give different answers:
id -Greads your process's credential — the projection of your token's group set, which includes the groups the authority added when the token was minted:Everyone,Authenticated Users, and anyAdministrators-style aliases.id -G jacklooks the name up through the principal store, which returns recorded memberships only. The stapled groups are not recorded anywhere, because membership in them is a rule rather than a stored fact — so they do not come back.
On other systems these two differ only in the order they print. Here the second list is genuinely shorter, and neither is wrong.
Exit status #
| Code | Meaning |
|---|---|
0 | The identity was printed. |
1 | A user could not be found, an option combination was rejected, or the context could not be read. |
groups
Peios / Using Peios / Peiosutils / System and processes
groups prints the groups a user belongs to.
groups [USERNAME]...
$ groups
Users Everyone Authenticated Users Administrators
$ groups jack
jack : Users Administrators
With no argument it reports the calling process. Given names, it reports on those.
Those two answers are both correct #
The example above is not a mistake, and it is the thing worth knowing about this command on Peios.
groups reads the calling process's credential — the projection of its token's group set. That set includes the groups the authority stapled on when the token was minted: Everyone, Authenticated Users, and the like.
groups jack looks the name up through the principal store, which returns recorded memberships — the ones something actually wrote down.
The stapled groups are not recorded anywhere, because membership in them is a rule, not a stored fact. Everyone is in Everyone; nothing needs to record it, and nothing can enumerate it. So they appear in the first answer and not the second.
On other Unixes these two forms differ only in the order they print.
What cannot appear #
Both forms are lossy in the same way, and unavoidably so — a group can only be listed if it has a group ID, and some deliberately do not:
Interactive,Network,BatchandServiceare unnumbered on purpose. They describe how you signed in rather than who you are, which is what lets a rule like "network logons cap at Low" be written at all. You are inInteractiveat the console and not over the network, so there is no static answer to record.- A group's attributes are not representable. A deny-only group — one that can block access through a deny entry but grant nothing through an allow entry — is printed exactly like an ordinary membership.
token groups is the lossless view, with every SID and its attributes.
Exit status #
| Code | Meaning |
|---|---|
0 | The groups were printed. |
1 | A named user could not be found, or the group list could not be read. |
A group ID with no name is printed as the bare number and sets the exit status, rather than stopping the listing.
logname
Peios / Using Peios / Peiosutils / System and processes
logname prints the login name the calling process runs under.
logname
$ logname
jack
It takes no arguments.
Login name, not current name #
logname and whoami look like the same command and answer slightly different questions.
whoami reports the effective identity — whoever the process is acting as right now. logname reports the login identity: the principal the process's own token is for, regardless of any token it has since taken on. When a process is impersonating a client, whoami follows the impersonation and logname does not.
Most of the time nothing is impersonating and the two agree.
If you know logname from another Unix, note that it answers this from the token rather than from a login-record file. There is no utmp on Peios — nothing writes one — so the traditional source does not exist. The token is the record of who a process is, and it is a better one: it cannot drift from the identity access is actually checked against, because it is that identity.
Exit status #
| Code | Meaning |
|---|---|
0 | The login name was printed. |
1 | No login name could be determined. |
token
Peios / Using Peios / Peiosutils / System and processes
token is the command-line tool for working with tokens directly. It reads a token's contents, adjusts it, produces derived tokens, and drives impersonation — the low-level operations this topic describes, exposed at a shell.
token subcommand [target] [arguments]
$ token # one-line summary of your own token
$ token show --all # every field of your own token
$ token privs --pid 4821 # the privileges on process 4821's token
token is a direct, debug-level tool. Day to day you do not inspect tokens by hand — the system does. token is for diagnosing an access problem, for understanding what identity a process is really running under, and for building and testing identity setups. Run with no subcommand, it prints a one-line summary of your own token.
Choosing which token #
Almost every subcommand operates on a token, and these flags choose which one. With none, the target is your own.
| Flag | Target |
|---|---|
--self | Your own token. The default. |
--real | Your primary token specifically, rather than the effective one — relevant when your thread is impersonating. |
--pid PID | The primary token of process PID. |
--tid TID | The impersonation token of thread TID (used with --pid). |
--peer SOCK_FD | The peer's captured token on a connected socket — see Peer tokens. |
Reading another process's token is itself access-controlled: it succeeds only with the right authority over that process.
Inspecting a token #
show #
token show prints a token's contents. It is the default — bare token is token show --short.
| Flag | Effect |
|---|---|
--short | A one-line summary. |
--all | Every query class — the fullest dump. |
Field accessors #
Each of these prints one part of a token, for when you want just that piece:
| Subcommand | Prints |
|---|---|
user | The user SID — who the token is. |
owner | The default owner SID. |
group | The primary group SID. |
groups | The group list. |
privs | The privileges, with their enabled state. |
caps | The capabilities. |
claims | The user and device claims. |
integrity | The integrity level. |
logon | The logon type and logon SID. |
source | What minted the token. |
origin | The originating session for a derived token. |
stats | Token statistics — IDs, timestamps, the modification counter. |
default-dacl | The token's default DACL. |
query #
token query CLASS performs a raw read of a single named token-info class and prints the result as JSON — the lowest-level inspection route, for tooling.
Changing a token #
adjust #
token adjust mutates a token in place:
| Form | Changes |
|---|---|
adjust privs NAME=STATE … | Enable, disable, or remove privileges. STATE is enabled, disabled, or removed. |
adjust groups IDX=STATE … | Enable or disable groups by their list index. |
adjust default --dacl SDDL | Replace the token's default DACL. Also --owner-idx / --group-idx. |
adjust session ID | Replace the token's session id. |
restrict #
token restrict produces a restricted token — a more limited variant of a token.
| Flag | Effect |
|---|---|
--drop-privs MASK|NAMES | Privileges to drop. |
--deny IDX,… | Group indices to mark deny-only. |
--restrict SID,… | The restricting SIDs to apply. |
duplicate #
token duplicate (alias dup) copies a token, optionally changing its --type (primary or impersonation), its impersonation --level, or its --access mask.
link and linked #
token link joins two tokens as an elevation pair — a full token and its filtered counterpart — given their file descriptors and a session id. token linked shows a token's elevation-linked counterpart, if it has one. See Elevation.
Impersonation #
| Subcommand | Effect |
|---|---|
impersonate | Begin impersonating the target token on the calling thread. With a trailing -- command …, run that command under the impersonating token. |
revert | Drop any active impersonation on the calling thread. |
See Impersonation for the model these drive.
Creating tokens #
| Subcommand | Effect |
|---|---|
create SPEC | Create a token from a binary token-spec (SPEC is a file, or - for standard input). |
install SPEC | Create a token from a spec and install it as the caller's primary token. |
Creating and installing tokens is a privileged operation, reserved for the components that legitimately mint identity.
Output options #
| Flag | Effect |
|---|---|
--raw | Render SIDs in raw S-1-… form only. |
--label | Render SIDs as their labels where known, falling back to raw. |
--json | Emit JSON instead of human-readable output. |
token and the inspection surfaces #
token is the convenient front-end. Underneath, it reads the same kernel surfaces and rules described in Inspecting tokens — that page covers the query mechanism, the access rules for reading another process's token, and what cannot be inspected.
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded. |
1 | A usage error. |
| non-zero | The operation failed — no such target, an access denial, or a bad spec. |
logonse
Peios / Using Peios / Peiosutils / System and processes
logonse is the command-line tool for logon sessions — the kernel's records of authentication events that this topic describes. It lists the active sessions, shows which processes belong to one, creates and destroys sessions, and (as a related low-level job) sets a process's mitigation flags.
logonse subcommand [arguments]
$ logonse list
$ logonse show 4711
logonse is a low-level administrative and debugging tool. It requires a subcommand: list, show, create, destroy, or psb.
Listing sessions #
logonse list #
Enumerates the active logon sessions, and the process IDs in each.
$ logonse list
session 0 pids: [1, 2, 14, 22]
session 4711 pids: [820, 844, 901]
logonse show #
Shows the process IDs that belong to one session.
$ logonse show 4711
A caveat on list and show #
There is no syscall that enumerates logon sessions, and logonse does not read the kernel's sessions file (whose SD restricts it to Administrators and SYSTEM). logonse list and logonse show work by walking the running processes and reading each one's token to find which session it belongs to. That has two consequences worth knowing:
- It is best-effort. A session that has no running process — held alive only by a token file descriptor somewhere — will not appear, because there is no process to find it through.
- It is a snapshot under change. Processes start and exit while the walk runs, so the result is a close approximation of the moment, not a locked one.
For routine "who is signed in" use this is fine. For an authoritative listing, the kernel's own sessions surface — described in Inspecting sessions — is the source of record.
Creating and destroying sessions #
logonse create #
Creates a new logon session for a user, described by a logon type, an authentication-package name, and the user's SID.
$ logonse create --logon-type interactive --auth-package Negotiate --user-sid S-1-5-21-...-1001
| Flag | Meaning |
|---|---|
--logon-type TYPE | The kind of logon: interactive, network, batch, service, network-cleartext, or new-credentials. |
--auth-package STR | The name of the authentication package that vouched for the logon. |
--user-sid SID | The user the session belongs to, as an S-1-… SID or an SDDL alias such as BA. |
On success logonse prints the new session's id. Creating a session is a privileged operation — minting authentication records is reserved for the components that legitimately do so.
logonse destroy #
Destroys a session — but only an empty one, with no tokens still referencing it.
$ logonse destroy 4711
A session with live tokens cannot be destroyed this way; its tokens must go first. See Session lifecycle.
Setting process mitigation flags #
logonse psb #
logonse psb sets the mitigation flags in a process's Process Security Block.
$ logonse psb --pid 4821 --mitigations 0x1c0
| Flag | Meaning |
|---|---|
--pid PID | The process to act on. |
--mitigations MASK | The mitigation bitmask to apply, in hexadecimal or decimal. |
This subcommand is about process hardening rather than logon sessions — it lives in logonse because both deal with low-level per-process kernel state. For what the mitigation flags mean and how they behave, see Process mitigations.
Output options #
| Flag | Effect |
|---|---|
--json | Emit JSON instead of human-readable output. Accepted by every subcommand. |
Exit status #
| Code | Meaning |
|---|---|
0 | The operation succeeded. |
1 | A usage error, or the operation failed. |
mount
Peios / Using Peios / Peiosutils / Disks and mounts
mount attaches a filesystem to the Peios mount tree. With no operands it instead lists what is currently mounted. It is a faithful reworking of the util-linux mount(8) surface for Peios, with two structural differences: Peios has no /etc/fstab and no /etc/mtab, so every mount is described entirely on the command line and live mount state is read from the kernel; and a mount can apply a KACS mount policy to the new filesystem at attach time (see Mount policies).
mount [-t TYPE] [-o OPTIONS] SOURCE TARGET
mount [-o remount,OPTIONS] TARGET
mount --bind|--rbind|--move SOURCE TARGET
mount --make-shared|--make-private|... TARGET
mount [-l] [-t TYPE]
Internally mount uses the fd-based mount API (fsopen / fsconfig / fsmount / move_mount, plus open_tree and mount_setattr), never the classic single-shot mount(2). This matters in one place you can see: when a mount fails, the kernel's fs_context message log is drained and printed as the reason, even without -v.
Operands and argument shapes #
Because there is no fstab to consult, operand resolution is strict:
- No operands, no verb — list mode (see below).
- Two operands —
SOURCE TARGET. - One operand — an error. In util-linux a lone operand is resolved through fstab; Peios has none, so a single operand cannot be turned into a
SOURCE TARGETpair. Supply both, or use--source/--target. - A lone target is valid only for
-o remountand for a standalone propagation change (--make-*), which act on an existing mount point.
--source SRC and --target DIR name the operands explicitly and may be combined with a single positional. --target-prefix DIR prepends DIR/ to the target after it is chosen. Options may be interspersed with operands (mount SRC -o ro TGT), matching util-linux.
Paths, -o key=value values, labels and UUIDs are handled as opaque byte strings, never assumed to be UTF-8; an embedded NUL is a usage error.
Canonicalisation #
By default source and target paths are canonicalised (made absolute, symlinks resolved). -c / --no-canonicalize disables that; X-mount.nocanonicalize[=source|target] is the -o form and can disable it for just one side. The final path handed to the kernel is protected against a TOCTOU symlink swap on its last component.
Operation modes (verbs) #
mount performs one of several operations. The structural verbs — bind, rbind, move, beneath and remount — are mutually exclusive with one another. A propagation change is not a structural verb: it may stand alone on a target, or trail another verb or a new mount in the same command (applied afterwards as one or more mount_setattr steps), and several may be combined.
| Verb | How to request it | What it does |
|---|---|---|
| New mount | mount [-t T] SRC TGT | Attach a fresh instance of the filesystem at SRC onto TGT. |
| Bind | -B / --bind, or -o bind | Make an existing subtree visible at a second location. |
| Recursive bind | -R / --rbind, or -o rbind | Bind a subtree together with every mount underneath it. |
| Move | -M / --move, or -o move | Relocate an existing mount to a new mount point. |
| Move beneath | --beneath SRC TGT | Attach SRC beneath the mount currently at TGT (the kernel enforces several constraints; violations surface as exit 32). |
| Remount | -o remount[,...] TGT | Change the options of an existing mount (see Remounting). |
| Propagation | --make-* / --make-r*, or the -o tokens below | Change how mount/unmount events propagate across a subtree. |
| List | mount / mount -l | Print the current mounts. |
Propagation flags #
Each of these sets the propagation type of the mount at the target. The --make-r* (and r-prefixed -o) forms apply recursively to the whole subtree; the recursive form is all-or-nothing (it either applies to the entire subtree or to none of it).
| Flag | -o token | Recursive flag | Recursive -o token | Meaning |
|---|---|---|---|---|
--make-shared | shared | --make-rshared | rshared | Events propagate to and from peer mounts. |
--make-slave | slave | --make-rslave | rslave | Events propagate in from the master but not back out. |
--make-private | private | --make-rprivate | rprivate | No propagation either way. |
--make-unbindable | unbindable | --make-runbindable | runbindable | Private, and cannot be bind-mounted. |
A freshly created mount, bind or move is private by default unless its destination's parent is shared, in which case it joins that peer group — the standard kernel rule.
Source specification #
| Form | Resolution |
|---|---|
Device path (/dev/sda1) | Used directly. |
-L LABEL / LABEL=, -U UUID / UUID=, PARTLABEL=, PARTUUID= | Resolved to a device via libblkid. No match is a mount failure (exit 32). |
| A directory or regular file | Used directly (a bind source or target; a file may be a bind target). |
A pseudo source (tmpfs, proc, sysfs, none, …) | Passed through; -t is required because it cannot be probed. |
| An image file | Attached through a loop device (see Loop devices). |
Filesystem type #
-t TYPE names the type explicitly. -t auto, or omitting -t entirely, asks libblkid to safely probe the source; an ambiguous or multiply-signed device is refused (exit 32) rather than guessed at. X-mount.auto-fstypes=LIST constrains the probe candidates. Type lists and no<type> negation are not accepted in the mounting path (they remain meaningful only as a listing filter).
The -o option language #
-o takes a comma-separated list of key or key=value tokens and is repeatable (all occurrences are joined). A key="..." double-quoted value protects embedded commas; the key=value split is on the first =. Every token is sorted into one of the following categories.
Per-mount attributes #
Applied to the mount itself. Each accepts its negation; ro/rw additionally accept a =vfs / =fs / =recursive scope qualifier (bare is =vfs, non-recursive; =fs targets the superblock read-only flag; =recursive applies to the whole subtree).
| Option | Effect |
|---|---|
ro / rw | Read-only / read-write. |
suid / nosuid | Honour / ignore set-user-ID bits. |
dev / nodev | Allow / disallow device nodes. |
exec / noexec | Allow / disallow execution. |
atime / noatime | Update / never update access times. |
relatime / norelatime | Relative-atime updates on or off. |
strictatime / nostrictatime | Strict-atime updates on or off. |
diratime / nodiratime | Directory access-time updates on or off. |
nosymfollow | Do not follow symlinks on this mount. There is no positive symfollow token — the attribute is cleared only via a remount mask. |
The atime tokens share a single mode field; the last one specified wins.
Superblock flags #
| Option | Effect |
|---|---|
sync / async | Synchronous / asynchronous writes. |
dirsync | Synchronous directory updates. |
lazytime / nolazytime | Lazy on-disk timestamp updates on or off. |
iversion / noiversion | Inode version counting on or off. |
silent / loud | Suppress or emit certain kernel messages. |
mand / nomand | Obsolete (removed from the kernel). Accepted and ignored with a note under -v. |
Filesystem-specific parameters #
Any token not recognised above is forwarded verbatim to the filesystem: key=value as a string parameter, a bare key as a flag. There is no built-in allow-list and no length limit; a rejection by the filesystem is reported with the offending key and the kernel's message.
For example, StrataFS takes its precedence-ordered directory stack through
strata=:
See StrataFS for the stack flags, merge
and write-routing model, and the stratafs inspection command.
Userspace-only tokens #
These never reach the filesystem. They include the meta-verbs remount, bind, rbind, move; the loop controls loop / loop=/dev/loopN, offset=, sizelimit= (numeric values accept K/M/G/T and KiB/MiB/… suffixes); the propagation tokens listed above; and defaults, which expands to rw,suid,dev,exec,async (later tokens override it).
Functional X-mount.* options #
| Option | Effect |
|---|---|
X-mount.mkdir[=mode] | Create the target directory if missing (default mode 0755; the alias of -m). |
X-mount.subdir=DIR | Attach subdirectory DIR of a freshly mounted filesystem at the target. Effective only for a new-instance mount; silently ignored (noted under -v) for bind/move/remount/propagation. |
X-mount.noloop | Suppress the implicit loop device for a regular-file source. |
X-mount.auto-fstypes=LIST | Constrain the -t auto probe to these types. |
X-mount.nocanonicalize[=source|target] | The -o form of -c; with =source or =target it disables canonicalisation for just that path. |
X-mount.idmap and X-mount.owner / group / mode are not supported and are rejected with a usage error: Peios ownership is SID/security-descriptor based, so the fix is to add a SID/ACE to the descriptor rather than remap or chown.
KACS mount policy #
This is the genuinely Peios-specific part of mount. A mount policy is a per-superblock setting that governs how FACS treats the filesystem; the full model is described in Mount policies and its detail pages. mount can set that policy at attach time:
| Option | Policy class |
|---|---|
-o policy=deny-missing | facs_deny_missing — a file with no SD is unreachable. |
-o policy=synth-ephemeral | facs_synthesize_ephemeral — a missing SD is synthesised in memory only. |
-o policy=synth-persist | facs_synthesize_persistent — a missing SD is synthesised and written back. |
--synth-sddl SDDL | Provide the mount-level SD template used during synthesis (only valid with a synth-* policy). |
policy=unmanaged is not user-settable; only the kernel sets the unmanaged class, for its own pseudo-filesystems. policy= is valid only on a new mount of a real filesystem — combining it with bind/move/remount/propagation, or with list mode, is a usage error. See Policy classes for what each class does and SD storage by filesystem for how the SD is physically stored.
The policy is applied to the detached filesystem before it is attached: if setting it fails, nothing is ever published with an unintended policy (no rollback needed). Because setting a mount policy is a SeTcbPrivilege-gated operation (see Managing mounts), a caller without that privilege gets a clean EPERM (exit 1) and no mount. --synth-sddl is validated client-side first — it must be well-formed SDDL and must include an owner.
Peios applies no coarse uid==0 check anywhere: the mount applet is not installed set-user-ID, and every privileged action is authorised per-operation by KACS.
Remounting #
-o remount changes the options of an existing mount without detaching it. Peios does not perform the util-linux "read the old flags and re-supply them" dance — the fd-based API applies deltas rather than resetting, so each remount touches only what you name. Per-mount attributes (ro/rw, nosuid, atime, …) are changed via mount_setattr; superblock flags, filesystem parameters and ro=fs/rw=fs go through the superblock reconfigure path. =recursive remounts the whole subtree, all-or-nothing.
A bind mount shares its source's superblock, so any superblock-level option on a bind remount (a Category-B flag, a filesystem parameter, or ro=fs/rw=fs) is refused as a usage error rather than silently mutating the shared superblock for every mount of that filesystem. Only per-mount VFS attributes are valid on a bind remount.
Loop devices #
A regular-file source is backed by a loop device automatically when the type is unspecified or the filesystem is recognised by libblkid; X-mount.noloop suppresses this. -o loop forces auto-allocation of a free device, loop=/dev/loopN names one, and offset= / sizelimit= select a region of the file (they imply loop and are an error against a real block device). A backing file already attached at the same offset and size is reused rather than doubly attached, to avoid corruption. Loops created by mount are auto-cleared by the kernel on unmount, so they do not leak; umount -d force-clears the rest (see umount).
Other flags #
| Flag | Effect |
|---|---|
-t, --types TYPE | Filesystem type, or auto. |
-o, --options LIST | Mount options (above); repeatable. |
-r, --ro, --read-only | Mount read-only (-o ro). |
-w, --rw, --read-write | Mount read-write and forbid the automatic read-only fallback on a write-protected device (-o rw). |
--source SRC / --target DIR | Name the operands explicitly. |
--target-prefix DIR | Prepend DIR/ to the target. |
-B, --bind / -R, --rbind / -M, --move / --beneath | The structural verbs. |
--make-*, --make-r* | Propagation changes. |
--exclusive | Force a unique superblock instance (no reuse). Meaningful only for multi-instance filesystems (e.g. tmpfs) or read-only block mounts; on an already-mounted writable block device it fails with EBUSY (exit 32). |
-m, --mkdir[=MODE] | Create the target directory if missing (default mode 0755). |
-L, --label LABEL / -U, --uuid UUID | Select the source by filesystem label / UUID. |
-c, --no-canonicalize | Do not canonicalise paths. |
-f, --fake | Dry run: parse, resolve and plan everything but skip the mount syscalls and the policy step. |
-v, --verbose | Narrate resolved values and each syscall; drain the kernel fs_context log. Repeatable but -vv is the same as -v. |
-l, --show-labels | In list mode, append each filesystem's label. |
--onlyonce | Skip the mount if it is already present (a driver-aware check against live mount state, not a naive string match). |
-N, --namespace NS | Operate inside mount namespace NS (a PID, an ns file path, or a named namespace). Source resolution happens in the caller's namespace; the mount lands in the target namespace. |
-i, --internal-only | Do not invoke a mount.<type> helper. |
-n, --no-mtab | Accepted and ignored (Peios has no mtab). |
--synth-sddl SDDL | KACS synth-policy template SD (above). |
-h, --help / -V, --version | Standard. |
No external mount helpers ship in this version, so a network or FUSE type fails with "no helper for type" (exit 1) — a clean deferral, not a crash.
List mode #
With no operands, mount prints one line per mount, read from /proc/self/mountinfo:
SOURCE on TARGET type FSTYPE (OPTIONS)
mount -t TYPE with no operands filters the list by filesystem type instead of mounting; here type lists (-t ext4,xfs) and no<type> negation (-t nosysfs) are honoured. -l appends [LABEL] to each line when a label is known.
Details of the rendering: the option field is the positional VFS-then-superblock merge with a coalesced ro/rw, not sorted; mountinfo octal escapes are decoded; control characters in the mount point become ?; and the source is shown as the kernel recorded it, with /dev/loopN resolved to its backing file and /dev/dm-N to /dev/mapper/<name>. The listing shows only mounts the caller's token may observe; a partial or empty view is correct, not an error, and the paths of filtered-out parents are never reconstructed.
For a device-oriented view of what is available to mount, use lsblk.
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Incorrect invocation or a permission denial — bad flags, mutually-exclusive verbs, a lone operand, an invalid policy= or --synth-sddl, an authorisation failure (EPERM/EACCES), an embedded NUL, or "no helper for type". |
2 | System error (out of memory, no free loop device, cannot fork). |
4 | Internal error (an invariant failure). |
8 | Interrupted (SIGINT), after signal-safe cleanup. |
32 | Mount failure — a syscall in the flow failed, a label/UUID had no match, a probe was undetermined, or a --beneath/move/--exclusive kernel refusal. |
126 | An external mount.<type> helper was found but failed to execute (moot until a helper ships). |
Code 16 (mtab) is never produced. Code 64 (some succeeded, some failed) only arises when several source arguments are given and at least one succeeds while another fails.
umount
Peios / Using Peios / Peiosutils / Disks and mounts
umount detaches a filesystem from the Peios mount tree. It is the counterpart of mount and, like it, reads live mount state from the kernel (/proc/self/mountinfo) rather than any /etc/mtab — Peios has none. Each operand is resolved against that live state and unmounted with umount2(2).
umount [-lfRA] [-dr] TARGET|SOURCE...
Operands and how they are resolved #
Each operand is either a mount point or a source:
- If the (canonicalised) operand is itself a mount point in the current mount table, it is unmounted directly. This is always unambiguous.
- Otherwise the operand is treated as a source and matched against the sources in the mount table. If it is mounted in exactly one place, that place is unmounted. If it is mounted in several places,
umountrefuses with an error naming the candidates — resolve it by naming a specific mount point, or use-Ato unmount all of them.
If an operand matches nothing, it is "not mounted": normally an error (exit 32), but -g makes that a success and -q suppresses the message.
Several operands may be given in one invocation, and options may be interspersed with them (umount /a -f /b). Paths are handled as opaque bytes; an embedded NUL is a usage error. By default each operand is canonicalised; -c disables that and additionally selects UMOUNT_NOFOLLOW so the kernel does not follow a symlink in the final component.
Recursive and all-targets unmounts #
Two flags expand a single operand into multiple unmounts. Within one operand the expansion stops on the first failure.
-R/--recursiveunmounts the target and everything mounted underneath it, including any over-mount stack at a single point, ordered deepest-first.-A/--all-targetsunmounts every mount point of the given source in the current mount namespace. This is the live-mountinfo "unmount everywhere" complement to source resolution; it is not an fstab feature.-A -Rtogether compose: for each mount point of the source, recurse underneath it.
-R and -r are mutually exclusive (combining them is a usage error, exit 1).
Flags #
| Flag | Effect |
|---|---|
-l, --lazy | Detach the filesystem now and clean up references later (MNT_DETACH). |
-f, --force | Force the unmount (MNT_FORCE), e.g. for an unreachable server. |
-R, --recursive | Unmount the target and everything under it (see above). |
-A, --all-targets | Unmount every mount point of the given source (see above). |
-d, --detach-loop | After unmounting, free the backing loop device. Best-effort and verified: it clears the device only if it still backs the mount just removed, so a recycled loop number is never clobbered. Usually redundant, since mount-created loops auto-clear on unmount. |
-r, --read-only | If the unmount fails, remount the filesystem read-only instead (via mount_setattr). Mutually exclusive with -R. |
-g, --graceful | Exit 0 when the target is absent or not mounted (rather than failing). Applied unconditionally. |
-q, --quiet | Suppress "not mounted" messages. |
-c, --no-canonicalize | Do not canonicalise paths; selects UMOUNT_NOFOLLOW. Applied unconditionally. |
-v, --verbose | Say what is being unmounted. Repeatable. |
-N, --namespace NS | Enter mount namespace NS (a PID, an ns file path, or a named namespace) before reading its mount table and unmounting. All work happens inside that namespace. |
-n, --no-mtab | Accepted and ignored (no mtab on Peios). |
-i, --internal-only | Do not invoke a umount.<type> helper (none ship). |
--fake | Dry run: resolve operands and report, but skip the unmount syscalls. |
-h, --help / -V, --version | Standard. |
The umount2 flag mapping is direct: -l → MNT_DETACH, -f → MNT_FORCE, -c (or a symlinked final component) → UMOUNT_NOFOLLOW. MNT_EXPIRE is deliberately not exposed, matching util-linux.
On privilege #
As with mount, there is no coarse uid==0 check: the applet is not set-user-ID and every unmount is authorised per-operation by KACS. Where util-linux silently suppresses an option for non-root users (-c, and -g's effectiveness), Peios applies it unconditionally.
Exit status #
| Code | Meaning |
|---|---|
0 | Success (or a -g no-op on an absent target). |
1 | Incorrect invocation or a permission denial — including -R with -r, a missing operand, an ambiguous source, an embedded NUL, or an authorisation failure. |
2 | System error (e.g. cannot read the mount table). |
8 | Interrupted (SIGINT). |
32 | Unmount failed, or the target is not mounted (unless -g). |
64 | Several source/target arguments were given and at least one succeeded while another failed. |
126 | An external umount.<type> helper was found but failed to execute (moot until a helper ships). |
A single -R / -A / -A -R invocation stops on the first failure and yields 32; the aggregate 64 only appears across multiple top-level arguments. To see what is currently mounted before unmounting, use mount with no arguments or lsblk.
lsblk
Peios / Using Peios / Peiosutils / Disks and mounts
lsblk lists the block devices on the system and their relationships — disks, their partitions, and the device-mapper and loop devices layered on top. It is the device-oriented companion to mount: where mount with no arguments shows what is mounted, lsblk shows what is available to mount and what filesystem sits on each device.
lsblk [options] [DEVICE...]
By default it prints a tree, one row per device with children indented beneath their parent. Given one or more DEVICE operands (a name like sda, a full path like /dev/sda, or anything resolving to a device node), it re-roots the output at those devices.
Where the data comes from #
Each column is sourced from one of four places, and any that cannot be read degrades to an empty cell rather than aborting the run:
- sysfs (
/sys/block) — the device tree, sizes, and the topology/hardware columns. Peios leaves/sys/blockunpatched, so this is the standard no-udev path. - libblkid — filesystem identity:
FSTYPE,FSVER,UUID,LABEL, and the partition-table columns. libblkid is opened at runtime. - The device node's security descriptor —
OWNERandMODE, read the same wayls -lreads them. - The
/dev/disk/by-*symlink farm —ID-LINK. Populated by the device manager once it has run; there is deliberately no/run/udev/dataparser, so the column reads the links themselves and is empty in the initramfs, where no device manager runs.
Output modes #
The mode selects how the rows are formatted; it does not change which columns are shown.
| Flag | Mode |
|---|---|
| (default) | Indented tree. |
-l, --list | Flat list — the same columns, no tree glyphs. |
-J, --json | JSON. Flag columns render as bare booleans and MOUNTPOINTS as an array; children nest under a children key. |
-P, --pairs | KEY="value" pairs, one device per line. |
-r, --raw | Raw, space-separated. Values that could contain a space or control character are hex-escaped (\xNN) so fields stay parseable. |
-T, --tree[=COLUMN] | Force tree output even alongside -l, optionally attaching the tree glyphs to COLUMN instead of NAME. |
Columns #
With no column flag, lsblk prints the default set: NAME, MAJ:MIN, RM, SIZE, RO, TYPE, MOUNTPOINTS. Three flags swap in a preset, and -o names an explicit list (which wins over all of them):
| Flag | Column set |
|---|---|
-o, --output LIST | Exactly the comma-separated columns named (case-insensitive; an unknown name is a usage error). |
-O, --output-all | Every available column. |
-f, --fs | Filesystem view: NAME, FSTYPE, FSVER, LABEL, UUID, MOUNTPOINTS. |
-m, --perms | Permissions view: NAME, SIZE, OWNER, MODE. |
The available columns are NAME, KNAME, PATH, MAJ:MIN, FSTYPE, FSVER, LABEL, UUID, PTUUID, PTTYPE, PARTTYPE, PARTLABEL, PARTUUID, MOUNTPOINT, MOUNTPOINTS, SIZE, RO, RM, HOTPLUG, TYPE, OWNER, MODE, MODEL, VENDOR, REV, SERIAL, TRAN, HCTL, ALIGNMENT, MIN-IO, OPT-IO, PHY-SEC, LOG-SEC, STATE, ROTA, SCHED, PKNAME, and ID-LINK.
The OWNER and MODE columns #
OWNER and MODE describe the device node, not the filesystem on it, and they mirror ls -l exactly — there is no GROUP column and no POSIX permission bits, because access to a device node is governed by its security descriptor, not by a mode. OWNER is the owner SID (S-1-…); MODE is a three-character [type][x][+]: the device-type character (b for a block device, c for a character device), an x slot (never set for a block device), and a + when the DACL is inheritance-protected. When the SD cannot be read — for instance on a non-Peios host with no KACS syscalls — OWNER shows ? and MODE degrades honestly.
Filtering, sorting and shaping #
| Flag | Effect |
|---|---|
-a, --all | Include empty (zero-size) devices, which are hidden by default. |
-d, --nodeps | Do not print a device's holders or slaves (drop the children). |
-I, --include LIST | Show only devices with these major numbers (comma-separated). |
-e, --exclude LIST | Exclude devices by major number. The default is 1 (RAM disks); an explicit -e replaces that default, and -a clears exclusions entirely. |
-s, --inverse | Print dependencies in inverse order (holders above the devices they depend on). |
-M, --merge | Collapse a subtree shared by several parents (a multipath device) to a single occurrence. |
-E, --dedup COLUMN | Drop rows whose COLUMN value duplicates an earlier one. |
-x, --sort COLUMN | Sort siblings by COLUMN (numeric columns sort by value, not text). |
Formatting #
| Flag | Effect |
|---|---|
-b, --bytes | Print SIZE as an exact byte count instead of a human-readable value. |
-p, --paths | Print full /dev paths in the NAME column. |
-n, --noheadings | Omit the header row. |
-i, --ascii | Draw the tree with ASCII characters instead of box-drawing glyphs. |
-y, --shell | Render column keys shell-safe (MAJ:MIN becomes MAJ_MIN). |
-w, --width NUM | Truncate each table row to NUM columns wide. |
--sysroot DIR | Read sysfs, the mount table and /dev from DIR instead of / (chiefly for testing against a fixture). |
-h, --help / -V, --version | Standard. |
Exit status #
| Code | Meaning |
|---|---|
0 | Success. |
1 | Incorrect invocation (an unknown column, a malformed major-number or width value) or a failed run (for example, /sys/block is unreadable). |
lsblk uses this single non-zero code, matching util-linux. A per-device read failure is not fatal: it degrades the affected columns to empty or ? and the run continues. A broken output pipe (piping into head, for instance) is treated as success.
mkirf
Peios / Using Peios / Peiosutils / Boot images
mkirf compiles an initramfs source tree into an initramfs image. The source tree is an ordinary directory — on a running Peios system that directory is /boot/initramfs/ — whose contents map 1:1 onto / inside the initramfs. The image is a single deterministic, gzip-compressed newc cpio archive that the bootloader loads into memory alongside the kernel.
mkirf [--watch] [--debounce SECS] [--exclude GLOB]... <src-dir> <out-file>
This page is the command reference. For what the initramfs stage is — prelude, the /boot/initramfs/ layout, and the handoff to the real root — see The initramfs stage; for how hooks declare their order, see Boot hooks. mkirf reads <src-dir> and writes <out-file>; it never writes back into the source tree, so the directory you inspect is always exactly what packages and you have put there.
What a build does #
Given a source tree, one build runs a fixed sequence:
-
Validate the layout.
<src-dir>must be a directory, must carry an executableinit(the initramfs PID 1 — a symlink is fine as long as it resolves within the tree), and must not already contain a hook sequence —hooks.seqorsystem/prelude/hooks.seq.<n>— whichmkirfgenerates. A missing or non-executableinit, or a stray sequence file, stops the build. -
Walk the tree. Every object beneath
<src-dir>becomes a cpio entry — regular files (with their executable bit preserved), directories, symlinks (target stored verbatim), character/block device nodes, and FIFOs. Sockets, and any other unsupported type, are rejected. Entries are sorted inLC_ALL=Cbyte order, which places every directory before its descendants — the order the kernel's unpacker requires. -
Resolve the hook order. Hooks are the regular files directly under
<src-dir>/usr/libexec/prelude/hooks.d/(packaged) or<src-dir>/lcl/libexec/prelude/hooks.d/(operator-placed), with the operator's copy winning when both hold the same file name. The legacy<src-dir>/hooks/is still read, and each hook found there is reported with the directory it should move to.mkirfparses each hook's# /// hookmetadata block, topologically sorts the resulting capability DAG, and bakes the execution order into generated manifests injected into the image at/system/prelude/hooks.seq.<n>. A missinghooks/directory simply means no hooks — the manifests are still written, empty, because prelude treats a missing sequence as a broken image rather than as "nothing to run".The version is in the file name, and
mkirfwrites every version it can express faithfully — currentlyhooks.seq.1(the flat resolved order) andhooks.seq.2(that order plus each hook's declarations).mkirfships inpeiosutilsand prelude ships in its own package, so an image can pair a newer writer with an older reader; naming the versions separately lets one image satisfy both, which a single file carrying an in-band version cannot. A version is dropped only when a future format carries something it can no longer project honestly.They live under
/system— the tier for derived content, alongsidesystem/retc— rather than/usr, which is the vendor tier no package ships these into, or/var/state, which is for mutable state rather than immutable build output. See Validation and errors below. -
Write atomically. The archive is written to a sibling temp file and
renamed into place, so a failed run never leaves a half-written image behind. On successmkirfprints the path, entry count, and byte size to stderr.
Early (pre-decompression) segments #
A reserved <src-dir>/++/ directory, if present, holds early initramfs segments — content the kernel consumes before it decompresses the main archive, namely CPU microcode and ACPI table overrides. Each immediate child of ++/ is one segment whose own contents map onto the cpio root (++/microcode/kernel/x86/microcode/GenuineIntel.bin becomes kernel/x86/microcode/GenuineIntel.bin); the segment directory name is a human label and never appears in the archive. Every ++/ entry must itself be a directory. The segments are merged into one uncompressed cpio prepended ahead of the gzip-compressed main archive. With no ++/ directory the output is exactly a single gzip member. mkirf stays format-agnostic here: it knows "early segments", never "microcode".
The deterministic-build guarantee #
The same source tree always compiles to the same image, byte for byte. This is what makes "did anything actually change?" a meaningful question and underpins later work such as signed boot artifacts. Determinism comes from normalising everything that would otherwise vary between builds:
- Ownership, inode numbers, and timestamps are all emitted as zero.
- Permission bits are normalised. The source tree is authoritative for file type and, for regular files, executability — nothing else. Read/write permission bits are not Peios's access mechanism, so they are flattened to a constant: directories
0755, symlinks0777, FIFOs and device nodes0644, regular files0755if executable and0644otherwise. - The gzip member carries mtime 0 and no embedded filename (the
gzip -nequivalent), at compression level 9. - The early region is byte-stable, with file payloads padded to a 16-byte boundary by widening the preceding entry's name field with NUL bytes.
Validation and errors #
mkirf will not produce an image from a source tree that cannot boot. A misconfiguration is caught when the image is built — on a running system where the message is easy to read — rather than as a mystery failure at the next boot. The checks:
- Layout.
<src-dir>is not a directory; noinit, orinitis not a regular file, or is not executable; a pre-existing hook sequence; a++entry that is not a directory; two++segments defining the same file. (Exit 1.) - Hooks. A malformed
# /// hookmetadata block (unclosed, a non-comment line inside it, a duplicate or unknown key, a badly-formed array), a dependency cycle, arequiresthat nothing supplies, or a capability that is both provided and contributed to (a capability is either a set of alternatives or a set of contributors, never both). Anafternaming a capability nothing supplies is not an error — that is what distinguishes it fromrequires. (Exit 1.) A hook with no metadata block at all is not an error — it is the escape hatch: it is scheduled last, in name order, andmkirfprints a warning so a forgotten block is visible. - I/O. Any filesystem read, write, or compression failure. (Exit 1.)
- Usage. A semantic invocation error clap cannot express — currently,
--watchwith<out-file>inside<src-dir>(see below). (Exit 2.)
Watch mode #
With --watch, mkirf stays resident: it builds once up front (so the watch never begins from a stale archive), then watches <src-dir> recursively and rebuilds after every change, debounced. This is what lets /boot/initramfs/ behave like an ordinary part of the filesystem — edit a hook and the initramfs is current again — rather than a build artifact an administrator has to remember to regenerate.
Watch mode is a foreground loop that runs until killed; supervising and restarting it is a service manager's job, not mkirf's. A rebuild that fails (say, a hook edited into a dependency cycle) is logged but does not stop the watch, so fixing the offending file recovers on the next change.
<out-file> must not live inside <src-dir>: each rebuild would write the image back into the watched tree and retrigger the watch endlessly. mkirf refuses this invocation up front (exit 2). The --debounce window (default 5 seconds) is the settle time — mkirf waits for the tree to be quiet for that long before rebuilding, so a burst of edits produces one rebuild rather than many.
Options #
| Option | Effect |
|---|---|
--watch | Stay resident and rebuild on every change to <src-dir>, after an initial build. Runs until killed. |
--debounce SECS | With --watch, the settle time before a rebuild. Default 5. A non-numeric value is a usage error. |
--exclude GLOB | Exclude paths (relative to <src-dir>) matching GLOB from the image. Repeatable. * and ? stay within a path segment; ** crosses separators. A matched directory is pruned with its whole subtree. A malformed glob is a usage error. |
The two positionals are both required:
| Positional | Meaning |
|---|---|
<src-dir> | The source tree; its contents map onto / in the initramfs. |
<out-file> | The output cpio.gz archive. |
Exit status #
| Code | Meaning |
|---|---|
0 | The image was built (or --help/--version was printed). |
1 | An operational failure — an invalid layout, an unresolvable hook set, or an I/O/compression error. |
2 | A usage error — a bad option, a missing positional, or --watch with <out-file> inside <src-dir>. |
See also #
- The initramfs stage — what
mkirf's output is for. - Boot hooks — the
# /// hookmetadata block and how order is resolved. - mkuki — wraps
mkirf's image, together with a kernel and command line, into a bootable UEFI unified kernel image.
mkuki
Peios / Using Peios / Peiosutils / Boot images
mkuki builds a UEFI unified kernel image (UKI): it takes a PE/COFF EFI stub and appends the kernel, the initramfs, and the kernel command line to it as named PE sections, producing a single EFI binary that UEFI firmware boots directly. Where mkirf produces the initramfs image, mkuki is the step that wraps that image — together with a kernel and a command line — into one bootable artifact. The two are the two halves of Peios' Dynamic Boot system.
mkuki --kernel PATH --initramfs PATH (--cmdline TEXT | --cmdline-file PATH) --out PATH
[--stub PATH] [--watch] [--debounce SECS]
mkuki --stub-info
The UKI section layout #
A UKI is an ordinary PE/COFF EFI executable — the stub — with three extra sections appended, which the stub reads at boot to find its payloads:
| Section | Content | Source |
|---|---|---|
.cmdline | The kernel command line, NUL-terminated. | --cmdline or --cmdline-file |
.linux | The kernel image. | --kernel |
.initrd | The initramfs cpio image. | --initramfs |
mkuki appends them in exactly that order. Each new section is marked as initialised, read-only data. The tool works at the PE level directly: it parses the stub's DOS/PE headers and section table, computes correctly-aligned virtual addresses and file offsets for the new sections, appends their data, updates the section count and the SizeOfImage/SizeOfHeaders fields, and — if the stub's header has no spare room for three more section entries — grows the header region and relocates the existing sections' file pointers to make space. A stub that is not a valid MZ/PE image, or whose optional header is malformed, is rejected.
The command line #
The command line is taken either literally from --cmdline or read from the file named by --cmdline-file (exactly one is required). Either way, mkuki trims trailing newlines and carriage returns and appends a single NUL terminator. A command line containing an embedded NUL byte is rejected.
The stub #
--stub names the EFI stub to append to. With no --stub, mkuki uses a stub bundled into the binary (the systemd EFI stub). mkuki --stub-info prints that bundled stub's provenance and exits, so you can see exactly what the default is without building anything.
Output and atomicity #
mkuki creates any missing parent directories of --out, writes the assembled image to a sibling temp file, and renames it into place, so an interrupted or failed build never leaves a half-written UKI behind. On success it prints the output path and byte size to stderr. Each of the three payloads must be non-empty; an empty .cmdline, .linux, or .initrd fails the build.
Watch mode #
With --watch, mkuki stays resident: it builds once up front, then rebuilds the UKI whenever an input changes — so the boot image tracks a new kernel, a freshly-repacked initramfs (mkirf's half of Dynamic Boot), or an edited command-line file with no manual step. Like mkirf's watch mode, it is a foreground loop that runs until killed; supervising it is a service manager's job, and a rebuild that fails (for example, a kernel caught mid-copy) is logged rather than fatal, so fixing the input recovers on the next change.
The watched inputs are --kernel, --initramfs, and — only if it is a --cmdline-file, since a literal --cmdline is static — the command-line file. mkuki watches each input's parent directory (non-recursively), not the file itself: this survives the atomic temp-and-rename writes that mkirf and mkuki both perform (which appear as a directory event a stale single-file watch would miss) and catches a versioned kernel being swapped in /boot. Because the watch is non-recursive, a write to an --out path nested deeper under a watched directory does not retrigger it — but an --out sitting directly in a watched input directory would, so mkuki refuses that invocation. The --debounce window (default 5 seconds) is the settle time before a rebuild.
Options #
| Option | Effect |
|---|---|
--kernel PATH | The kernel image; becomes the .linux section. Required (except with --stub-info). |
--initramfs PATH | The initramfs cpio; becomes the .initrd section. Required (except with --stub-info). |
--cmdline TEXT | The kernel command line, given literally; becomes the .cmdline section. Mutually exclusive with --cmdline-file. |
--cmdline-file PATH | Read the kernel command line from PATH instead. Mutually exclusive with --cmdline. |
--out PATH | The output UKI path. Required (except with --stub-info). |
--stub PATH | The PE/COFF EFI stub to append sections to. Defaults to the bundled systemd stub. |
--stub-info | Print the bundled stub's provenance and exit. |
--watch | Stay resident and rebuild the UKI whenever --kernel, --initramfs, or --cmdline-file changes. Runs until killed. |
--debounce SECS | With --watch, the settle time before a rebuild. Default 5. |
Exactly one of --cmdline or --cmdline-file must be given: supplying both, or neither, is a usage error.
Exit status #
| Code | Meaning |
|---|---|
0 | The UKI was built (or --stub-info, --help, or --version was printed). |
1 | A build or watch failure — a missing or unreadable input, an invalid stub, an empty payload, or an I/O error. |
2 | A usage error — a bad or missing option, or an invalid --cmdline/--cmdline-file combination. |
See also #
- mkirf — builds the
.initrdpayloadmkukiwraps. - The initramfs stage — what the bundled initramfs does once firmware boots the UKI.
reg
Peios / Using Peios / Peiosutils / Registry tools
reg is the command-line interface to the live registry — the running LCS store, reached through the registry system calls. Where regman reads shipped documentation and tells you what a key means, reg talks to the kernel and reads or writes what a key is. It is the everyday tool for scripting configuration: querying effective values, writing to layers, masking and hiding, managing security descriptors, watching for change, and taking backups.
reg <command> [options] <key> [value] [data]
reg is the read/write half of the registry toolset and regman is the lookup half. The division is exact:
| To… | Use… |
|---|---|
| Read or change what a value is set to, on this machine, right now | reg |
| Look up what a value means — its type, default, valid range, when it applies | regman |
Consult regman first to learn the legal range for a knob, then use reg set to write a value inside it. reg never checks a value's meaning and regman never touches the live store; they are complementary.
How reg treats access and privilege #
Every reg operation goes through the kernel's access check against the single key it opens — the same access-control path as any other protected object, with no traversal check on the parent keys. reg performs no identity or membership checks of its own: it does not refuse a privileged operation up front. It attempts the operation and reports faithfully whatever the kernel returns. An operation that needs a privilege you lack (creating a link, a positive-precedence layer, a backup or restore, reading a SACL) simply fails with access denied (exit 3) rather than being blocked by the tool. A single reg command may legitimately span privilege tiers.
Addressing model #
Almost every subcommand shares one addressing scheme: a key path positional, and an optional value name positional. Understanding the split is most of understanding reg.
Key paths #
The key is a positional path argument. Both / and \ are accepted as separators — neither character is legal inside an LCS key component, so there is no ambiguity, and you may use whichever your shell quotes most comfortably:
reg get Machine/System/KMES
reg get 'Machine\System\KMES' # identical
A leading separator is optional and ignored (/Machine/X is the same as Machine/X). Paths are compared case-insensitively. The first component is the hive:
| Path | Meaning |
|---|---|
Machine\… | The machine hive — system-wide configuration. |
Users\<SID>\… | A specific principal's hive. |
CurrentUser\… | A kernel alias, rewritten to the caller's own Users\<SID>\ at the syscall boundary. |
On output, reg displays paths with a backslash by default. --sep=/ (or REG_SEP=/) switches display to forward slash for that invocation; this affects display only, never how a path is parsed.
The value-name positional #
The value name is a separate positional argument — it is never folded into the key path. This is deliberate: an LCS value name may itself contain / or \ (which a key component may not), so keeping them apart removes all ambiguity.
reg get Machine/System/KMES BufferCapacity
# └──── key path ─────┘ └── value ──┘
The presence or absence of the value argument selects what the command targets:
- No value argument ⇒ the command targets the key itself — its metadata, its values as a set, its security descriptor, its children.
- A value argument ⇒ the command targets that one named value.
- The default value (the empty-name value) is addressed by the literal token
@in the value-name position, mirroring.regconvention:reg get Machine\App @.
This split applies uniformly to get, set, del, mask, and unmask.
Value literals and types #
On set (and in a batch), the value's data is a single token whose registry type is either forced by a type: prefix or inferred from its shape. The registry value types and their literal syntax:
| Prefix | Registry type | Data syntax |
|---|---|---|
sz: | REG_SZ | UTF-8 string (rest of token, verbatim). |
expand: | REG_EXPAND_SZ | UTF-8 string, %VAR% left unexpanded on disk. |
dword: | REG_DWORD | 42 or 0x2A; must fit u32. |
dword-be: | REG_DWORD_BIG_ENDIAN | 42 or 0x2A; must fit u32. |
qword: | REG_QWORD | 42 or 0x2A; must fit u64. |
multi: | REG_MULTI_SZ | Comma-separated; \, escapes a literal comma. |
hex: / bin: | REG_BINARY | Hex bytes; :, -, and space separators are ignored. |
link: | REG_LINK | An absolute key path (a symlink target). |
none: | REG_NONE | No data (token must be empty after the prefix). |
When the substring before the first : is not a recognised keyword (for example http://host), the token is not treated as typed and inference applies:
| Token shape | Inferred type |
|---|---|
all decimal digits, fits u32 | REG_DWORD |
all decimal digits, fits u64 but not u32 | REG_QWORD |
0x… hex, ≤ 8 significant hex digits | REG_DWORD |
0x… hex, ≤ 16 significant hex digits | REG_QWORD |
| anything else (including empty) | REG_SZ |
Inference is intentionally broad, so any all-digit token becomes a number — a PIN, a zip code, or 007 becomes a REG_DWORD and loses its textual form. To force a string, prefix it with sz: (sz:007 stores the string 007; sz:dword:42 stores the literal string dword:42). Because the coercion is a known trap, reg set always echoes the resolved type on success, so a surprising conversion is never silent even under --quiet.
Global options #
Every subcommand accepts the following. Each behavioural toggle also has an environment-variable equivalent, so it can be set per-invocation (the flag) or once per shell or script (the variable). The flag wins over the variable, which wins over the built-in default.
| Option | Env var | Effect |
|---|---|---|
--json | REG_JSON=1 | Emit structured JSON instead of human text. watch emits JSON-lines. |
-v, --verbose | REG_VERBOSE=1 | More detailed output. |
-q, --quiet | — | Suppress non-essential output. (A surprising type coercion is still reported.) |
--sep=CHAR | REG_SEP | Path display separator: \ (default) or /. |
--help | — | Print help for the command and exit 0. |
--version | — | Print the version and exit 0. |
Mutating subcommands additionally accept:
| Option | Env var | Effect |
|---|---|---|
--layer NAME | REG_LAYER | Target layer for the write (default: the base layer). |
-y, --yes | REG_ASSUME_YES=1 | Skip the confirmation prompt on a destructive command. |
Destructive commands (del -r, restore, layer del) prompt for confirmation when standard input is a terminal, and auto-proceed when it is not — so interactive use is guarded while scripts are never blocked. Set -y/--yes or REG_ASSUME_YES=1 to skip the prompt explicitly.
Reading #
reg get <key> [value] #
Read the effective (resolved) value. With a value, prints that value's data; with no value, lists the key's effective values (the default @ value first, then named values sorted case-insensitively) — a shorthand for ls --values-only.
| Option | Effect |
|---|---|
-L, --layers | Annotate the value with the layer it resolved from and its sequence number. |
--raw | Write the value's raw bytes to standard output verbatim — for piping REG_BINARY data. |
--no-follow | Operate on a symlink key itself rather than following it to its target. |
The single-value default form prints data bare (no quotes; one element per line for REG_MULTI_SZ) so it pipes cleanly. -L shows the winning layer's provenance:
$ reg get Machine\System\KMES BufferCapacity
4194304
$ reg get Machine\App Theme -L
Theme = REG_SZ "dark" (layer: policy, seq 7)
Only the winning value is shown; the full shadowed stack beneath it is not yet exposed by any client ABI (the -L flag will render the whole stack unchanged once that primitive lands). See Layers for what "effective" resolves against.
reg ls <key> #
One-level listing of a key's immediate subkeys and values.
| Option | Effect |
|---|---|
-l, --long | Long form: for subkeys, child and value counts; for values, type and byte size. |
--keys-only | List subkeys only. |
--values-only | List values only. |
$ reg ls Machine\System\KMES -l
BufferCapacity = REG_QWORD 4194304 (8 bytes)
MaxEventSize = REG_DWORD 65536 (4 bytes)
reg tree <key> #
Recursively list the subkey tree rooted at key.
| Option | Effect |
|---|---|
--depth N | Limit recursion to N levels. |
--values | Include each node's values, not just its keys. |
$ reg tree Machine\System --depth 2
reg info <key> #
Print a key's metadata: leaf name, subkey and value counts, last-write time, hive generation, security-descriptor size, and the volatile and symlink flags.
| Option | Effect |
|---|---|
--no-follow | Inspect a symlink key itself rather than its target. |
$ reg info Machine\System\KMES
path Machine\System\KMES
name KMES
subkeys 0
values 4
last_write_ns 1717243800000000000
hive_generation 42
sd_size 164
volatile false
symlink false
Writing #
reg set <key> <value> <data> #
Create or update a value. The value name (or @) and the data token are both required. Writes are tagged with --layer (default: base). See value literals for the data syntax.
| Option | Effect |
|---|---|
--layer NAME | Write into layer NAME instead of the base layer. |
-p, --parents | Create any missing ancestor keys. |
--expected-seq N | Compare-and-swap guard: apply only if the value's current sequence is N; a mismatch exits 6 without writing. |
$ reg set Machine\App Build 4096
set Machine\App Build = REG_DWORD 4096 (layer: base)
$ reg set Machine\App Servers multi:alpha,beta --layer policy
--expected-seq supports lock-free read-modify-write: read the value with -L to learn its sequence, then write back with --expected-seq set to that number; if another writer changed it in between, your write fails cleanly instead of clobbering theirs.
reg new <key> #
Create a key with no values. Reports whether the key was created or already existed.
| Option | Effect |
|---|---|
--layer NAME | Create the key in layer NAME. |
-p, --parents | Create any missing ancestor keys. |
--volatile | Create a volatile (RAM-only) key that does not survive a reboot. |
$ reg new Machine\App\Cache --volatile
created Machine\App\Cache (layer: base)
reg del <key> [value] #
Delete a value, or a key. (Alias: delete.)
With a value, deletes this layer's entry for that value only — lower-layer entries resurface, because a per-layer delete is not a tombstone (use mask for that). With no value, deletes the key, which must be empty unless -r is given.
| Option | Effect |
|---|---|
--layer NAME | Delete from layer NAME. |
-r, --recursive | Delete the key and all its descendants (walks and removes children). |
-y, --yes | Skip the confirmation prompt (recursive deletes prompt on a terminal). |
$ reg del Machine\App Legacy
deleted value Legacy from Machine\App
$ reg del Machine\App\Temp -r
Recursively delete key Machine\App\Temp and all its contents? [y/N] y
deleted Machine\App\Temp (3 keys)
See Deleting keys and values for how a per-layer delete differs from a tombstone.
Masking and hiding #
These commands expose LCS's tombstone and hide primitives, which mask lower-precedence state rather than removing a layer's own entry — the distinction that makes layers revertible. The layer is chosen with --layer (default: base).
reg mask <key> [value] / reg unmask <key> [value] #
mask sets a tombstone: a per-value marker in the target layer that hides that value from all layers below it. unmask clears it.
| Option | Effect |
|---|---|
--layer NAME | The layer that carries the tombstone. |
--all | Blanket tombstone: mask all lower-layer values on the key (no value argument). |
A value is required unless --all is given.
$ reg mask Machine\App ApiKey --layer policy
set tombstone on Machine\App ApiKey (layer: policy)
$ reg mask Machine\App --all --layer policy
set blanket tombstone on Machine\App (layer: policy)
reg hide <key> / reg unhide <key> #
hide installs a HIDDEN path entry in the target layer, masking the key's existence in that layer and below. unhide removes that layer's path entry, letting the key reappear.
| Option | Effect |
|---|---|
--layer NAME | The layer that hides (or unhides) the key. |
$ reg hide Machine\App\Debug --layer policy
hid Machine\App\Debug (layer: policy)
Layers #
reg layer ls #
List layers with name, precedence, enabled state, and (informational) owner SID, ordered by descending precedence.
| Option | Effect |
|---|---|
-l, --long | Long form. |
$ reg layer ls
policy prec=100 enabled owner=S-1-5-32-544
base prec=0 enabled
reg layer new <name> #
Create a layer by writing its metadata key under Machine\System\Registry\Layers\.
| Option | Effect |
|---|---|
--precedence N | Precedence (higher wins). A precedence above 0 requires SeTcbPrivilege; the tool attempts it and reports any denial. |
--owner SID | Record an informational owner SID. |
--disabled | Create the layer disabled. |
$ reg layer new policy --precedence 100 --owner S-1-5-32-544
created layer policy (precedence 100, enabled)
reg layer set <name> #
Modify an existing layer's metadata.
| Option | Effect |
|---|---|
--precedence N | Change the precedence. |
--enable / --disable | Enable or disable the layer. |
--owner SID | Change the informational owner SID. |
reg layer del <name> #
Delete a layer — removes its metadata key; LCS tears down the layer's entries. Prompts for confirmation on a terminal.
$ reg layer del policy
Delete layer policy and all its entries? [y/N] y
deleted layer policy
Managing per-process private layer views is out of scope for reg; see private hives and layers.
Security descriptors #
reg sd <key> #
Show or set a key's security descriptor as SDDL, using the same codec as the sd tool, so its output is consistent. With no scope flags, the default is owner + group + DACL (a SACL requires the privileged ACCESS_SYSTEM_SECURITY right).
| Option | Effect |
|---|---|
--set SDDL | Apply the given SDDL. Without it, sd prints the current descriptor. |
--owner | Scope to the owner. |
--group | Scope to the group. |
--dacl | Scope to the DACL. |
--sacl | Scope to the SACL (needs ACCESS_SYSTEM_SECURITY). |
$ reg sd Machine\App
O:BAG:BAD:(A;;KA;;;BA)(A;;KR;;;WD)
$ reg sd Machine\App --set 'O:BAG:BAD:(A;;KA;;;BA)' --owner --dacl
set security descriptor on Machine\App
A security-descriptor change takes effect on future opens only (existing handles keep the access mask they were granted). Security changes are not layered and are not undone by layer removal — they mutate the key object directly. See Access control on keys.
Symlinks #
reg link <key> <target> #
Create a symlink key pointing at the absolute target key path. This creates a key with the immutable symlink flag and a default REG_LINK value holding the target. Requires KEY_CREATE_LINK and SeTcbPrivilege/Administrator; the tool attempts it and reports any denial.
| Option | Effect |
|---|---|
--layer NAME | Create the link key in layer NAME. |
$ reg link Machine\App\CurrentConfig Machine\App\Config\v3
linked Machine\App\CurrentConfig -> Machine\App\Config\v3 (layer: base)
get and info follow symlinks to their target by default; pass --no-follow to inspect the link key itself. See Advanced: registry links.
Watching #
reg watch <key> #
Arm a change watch and stream one event per change until interrupted (or until --count events). Each event faithfully means "something under the filter changed — re-read"; reg does not decode the change records' contents. With --json, emits one JSON object per line.
| Option | Effect |
|---|---|
--subtree | Watch descendant keys too, not just this key. |
--filter LIST | Comma-separated subset of value, subkey, sd (default: all). |
--count N | Exit after N events. |
$ reg watch Machine\System\KMES --subtree --filter value
changed: Machine\System\KMES
changed: Machine\System\KMES
See Watching for changes for why a service watches instead of polling.
Batch, export, backup, and restore #
Two paths move a subtree around: export/apply are the portable, reviewable path (a diffable text or JSON document); backup/restore are the exact, privileged path (an opaque kernel snapshot).
reg apply <file> #
Apply a batch of operations to one hive in a single transaction — all-or-nothing. All operations must target one hive (a cross-hive batch fails with exit 4). - reads standard input.
| Option | Effect |
|---|---|
--dir DIR | Apply every batch file in DIR (sorted), each as its own transaction. A missing or empty directory applies nothing and succeeds. |
--once-delete | Delete each batch file after it applies successfully (a drain). |
-y, --yes | Skip confirmation. |
The batch format is JSON (canonical and exact) or a line-oriented text format; apply auto-detects (a leading { or [ means JSON). Text-format input is not yet implemented — supply JSON, or generate one with reg export --json. Text export works for review.
$ reg export --json Machine\App - | reg apply -
applied 4 keys
reg export <key> [file] #
Dump a subtree to the batch format. Omit file (or pass -) to write to standard output. Human-readable text by default; --json emits the canonical exact form.
| Option | Effect |
|---|---|
--layer NAME | Export one layer's view (default: the effective state). |
$ reg export Machine\App app-config.reg
reg backup <key> <file> #
Write an opaque, exact, binary kernel snapshot of a key and its subtree to file. Requires SeBackupPrivilege.
$ reg backup Machine\System\KMES kmes.snap
backed up Machine\System\KMES -> kmes.snap
reg restore <key> <file> #
Replace a key and its entire subtree from a snapshot, in one transaction. Requires SeRestorePrivilege. Prompts for confirmation on a terminal.
$ reg restore Machine\System\KMES kmes.snap
Replace key Machine\System\KMES and its entire subtree from kmes.snap? [y/N] y
restored Machine\System\KMES from kmes.snap
See Backup and restore for when to reach for each path.
Output formats #
Default output is terse, human-readable, and grep-friendly. --json switches every command to structured output — a single self-contained JSON document, except watch, which emits JSON-lines (one object per event). In any value listing, the default (@) value is emitted first, then named values sorted case-insensitively.
Type formatting on read:
| Registry type | Human form | JSON form |
|---|---|---|
REG_SZ / REG_EXPAND_SZ | the string | {"type":"sz","data":"…"} |
REG_DWORD / REG_QWORD | decimal | {"type":"dword","data":42} |
REG_MULTI_SZ | one element per line | {"type":"multi","data":["a","b"]} |
REG_BINARY | hex | {"type":"binary","data":"deadbeef"} |
REG_LINK | the target path | {"type":"link","data":"…"} |
Exit status #
| Code | Meaning | Typical cause |
|---|---|---|
0 | Success. | — |
1 | Usage error. | Bad arguments; a required option missing. |
2 | Key or value not found. | ENOENT. |
3 | Access denied. | EACCES, EPERM — the kernel refused the operation. |
4 | Invalid specification. | Bad path, type, or literal; a cross-hive batch; a name too long (EINVAL, EXDEV, ENAMETOOLONG). |
5 | Syscall or source failure. | EIO, ETIMEDOUT, ENOSPC, ENOMEM. |
6 | Compare-and-swap conflict. | EAGAIN — an --expected-seq guard did not match; the value changed under you. |
A denial (3) reports the operation, the target, and, where relevant, the access that was required — in the style of the sd and token tools.
See also #
regman— the registry manual: what a key or value means, as opposed to what it is set to. Consult it before youreg set.- Keys, values, and types — the data model
regaddresses. - Layers — what "effective" resolves against, and what
--layer,mask, andhideoperate on. - Access control on keys — the check every
regoperation goes through.
regman
Peios / Using Peios / Peiosutils / Registry tools
Configuration, not storage established that the registry holds values but not their meaning: it keeps a type tag and some bytes, and never knows that BufferCapacity must be a power of two or what it is for. That knowledge has to live somewhere a person can look it up. It lives in regman, the registry's manual.
regman is the man of the registry. Give it a path and it tells you what a key or value actually is. It is the everyday tool for configuring a Peios system — before you touch a knob, regman is how you find out what it does and what changing it will cost.
regman, in one sentence #
regman <path> [value] documents what a registry key or value means — its type, default, valid values, when a change takes effect, and a prose description — drawn from shipped documentation, never from the live registry.
That last clause is the one to hold onto; there is a section on it below.
Looking up a value #
Give regman a key path and a value name and it prints a knob-card — an identity line, an aligned block of facts, and a description:
$ regman Machine\System\KMES BufferCapacity
Machine\System\KMES BufferCapacity documented by kmes
Type REG_QWORD
Default 4194304 (4 MB)
Valid 65536–268435456 bytes (64 KB–256 MB), power of two
Applies live — ring-buffer swap
Per-CPU ring buffer capacity, in bytes. Must be a power of two; values
that are not are treated as invalid and ignored.
Every registry value is a knob, and every knob has the same few facts worth knowing, so the card is uniform rather than free-form:
| Field | Tells you |
|---|---|
| Type | The value's registry type (REG_QWORD, REG_SZ, …). |
| Default | The value used when nothing is set. |
| Valid | The range, set, or constraint a sensible value must satisfy. |
| Applies | When a change takes effect — live, on restart, or on reboot. |
The Applies field is the one operators reach for most: it is the difference between "edit it and you're done" and "edit it and schedule a reboot". Only the fields that make sense for a given item are shown, and a knob that is being retired carries a prominent deprecation notice at the top of its card.
Looking up a key #
Give regman just a key — no value — and it does double duty as an index: the key's own description, then a list of every value documented under it, one summary line each.
$ regman Machine\System\KMES
Machine\System\KMES documented by kmes
The KMES event subsystem reads its tuning parameters from the values
under this key...
Values
BufferCapacity Per-CPU ring buffer capacity in bytes.
MaxEventSize Max total size of a userspace-emitted event.
MaxNestingDepth Max nesting depth for userspace event payloads.
MaxEmitRatePerProcess Max events per second a single process may emit.
So you can start at a subtree, see what is configurable there, and drill into any single value.
Searching, when you do not know the path #
If you don't know where a setting lives, regman -k searches names and summaries — the registry's apropos:
$ regman -k buffer
Machine\System\KMES BufferCapacity Per-CPU ring buffer capacity in bytes.
regman documents intent, not current state #
This is the boundary that matters most. regman tells you what a setting should be — what it means and which values are legal — not what it is currently set to on this machine. It reads shipped documentation, never the live registry; it does not talk to LCS at all. Ask regman about BufferCapacity and you learn it must be a power of two and defaults to 4 MB; you do not learn that this particular box has it set to 8 MB right now. Reading the current value is a separate, live query against the registry — that is reg's job. regman tells you a knob should be a power of two; reg get tells you what it is set to, and reg set changes it. Reach for regman to decide what to write, and reg to write it.
Put regman next to the other two things from Configuration, not storage and a clean division of labour appears:
| To learn… | Look at… |
|---|---|
| What a setting means, and what is valid | regman — the manual |
| What the value is set to | reg — a live query against the store |
| What the subsystem is actually running on | the event log |
regman is the one you consult first, and the only one that works before you have changed anything — because documentation ships with the software, not with the machine's state. It is the natural complement to reject-or-keep: regman tells you the valid range up front, so you set a good value the first time instead of writing one the owning subsystem will quietly refuse.
Where the documentation comes from #
regman has no built-in database of every setting. Each package ships its own documentation as files dropped into a well-known directory (/usr/share/regman/), one fragment per package documenting that package's whole configuration surface. Install a package and its registry documentation appears; remove the package and it goes away. The package that owns a set of keys is the package that documents them — the only arrangement that stays correct as the system changes.
A consequence worth knowing: regman can only describe settings that some installed package has documented. An undocumented key is invisible to it — regman is exactly as complete as the packages on the machine make it. If two packages happen to document the same item, regman shows both and flags the overlap rather than silently picking a winner.
(For the people who write that documentation, regman fmt and regman lint prepare and check fragments, and regman index keeps lookups fast on large corpora — the lookup is always correct without an index, which is purely an accelerator. These are packaging concerns rather than everyday operator ones.)
Where to go next #
For the idea regman exists to serve — why the registry holds values without holding their meaning, and what reject-or-keep means — read Configuration, not storage.
For the other half of the picture — reading what a value is actually set to, which is a live query against the store rather than a documentation lookup — read LCS and sources.
1.1 What an Event Is
Peios / Using Peios / Events / Introduction
An event is a record that something happened: an access was checked,
a service started, a package was installed, a descriptor was found
corrupt. Events are produced by the component that observed the thing,
and consumed by whatever is watching — usually eventd, sometimes a
tool reading the stream directly.
This book enumerates every event Peios emits, with the fields each one carries. It is a lookup, not an explanation. Where an event's meaning needs the mechanism behind it, the chapter links to the manual that describes that mechanism.
1.1.1 Two transports, not one #
Most events travel through KMES, the kernel message event stream: a per-CPU ring buffer that the kernel writes into and userspace reads from. Every KMES event is a binary header followed by a msgpack payload. Chapters 3 to 7 document KMES events.
Two things in this book are not KMES events, and are included because an operator looking for "what does Peios tell me" would otherwise miss them:
- eventd's synthetic events (§8) are written straight into a shard database and never touch KMES. They carry no header stamps.
- LCS watch records (§5.4) are binary records read from a key file descriptor. They are a notification mechanism, not an audit trail, and their format has nothing in common with a KMES event.
1.1.2 What is not here #
This book does not cover logs or metrics. Both reach eventd by a different path, are stored in different tables, and are not events. The eventd manual covers them.
Nor does it cover the query language for reading events back. That is PSPU §3, with the operator-facing view in the eventd manual.
1.2 The Envelope
Peios / Using Peios / Events / Introduction
Every KMES event is a packed binary header followed immediately by its msgpack payload, delivered as one contiguous byte sequence with no padding anywhere.
The emitter supplies only two things: the event type string and the payload. Everything else in the header is stamped by KMES itself, which is what makes the identity fields trustworthy — an emitter cannot forge them.
1.2.1 Fields KMES stamps #
| Field | Meaning |
|---|---|
timestamp | Wall clock at the moment KMES accepted the event, nanoseconds since the Unix epoch. |
sequence | The emitting CPU's per-boot counter. The first event on each CPU gets 1. |
cpu_id | The CPU whose ring buffer holds the event. |
origin_class | 0 for syscall emission, unconditionally. For kernel emission, the value the calling subsystem passed. |
| identity GUIDs | The effective, true and process token GUIDs of the task that caused the event. |
The identity stamps matter for reading this book: several events carry no caller in their payload at all, because the envelope already names one. StrataFS copy-up records are the clearest case — nothing in the payload names a token, and the caller is recovered from the header.
event_size, header_size and type_len are structural, computed by
KMES during construction.
1.2.2 Layout #
All fields before the event type string sit at fixed offsets. The type
string begins at offset 77, with its u16 length at offset 75, so the
header is exactly 77 + type_len bytes. The payload runs from
header_size to event_size, and the next event begins at
event_size from the start of the current one.
All multi-byte header integers are little-endian. The identity GUIDs are opaque 16-byte values.
The full field-by-field layout is normative in PSPK §2, the KMES event stream specification. This summary is enough to walk a stream; it is not enough to implement one.
1.2.3 Ordering #
timestamp is captured before sequence is assigned, so two events
with the same timestamp on the same CPU are ordered by sequence.
Across CPUs there is no global order. Two events on different CPUs with close timestamps may have been observed in either order.
1.3 Encoding Conventions
Peios / Using Peios / Events / Introduction
Every KMES payload in this book is a msgpack map with UTF-8 string keys. The key set is stable per event type.
1.3.1 Value representations #
The same conceptual types appear across many events and are always encoded the same way.
| Conceptual type | msgpack representation |
|---|---|
| SID | bin holding the binary SID, 8–68 bytes. |
| GUID | bin, exactly 16 bytes. |
| ACE | bin holding the binary ACE, copied from the descriptor. |
| Access mask | uint, 32-bit. |
| Boolean | bool. |
| Privilege name | string, UTF-8, e.g. SeBackupPrivilege. |
| Process ID | uint. |
| Path or name | string, UTF-8. |
| Timestamp | uint, unless an event's schema says otherwise. |
| Object context | bin or nil. An opaque caller-supplied blob; its contents are service-specific. |
Binary SIDs, GUIDs and ACEs are carried as bytes rather than as text because they are compared as bytes. A textual SID would have to be parsed back before it could be matched.
1.3.2 Event type strings use three different styles #
There is no single convention. What an event type looks like depends on which component emits it:
| Style | Emitters | Examples |
|---|---|---|
kebab-case | KACS | access-audit, logon-session-destroyed |
dotted.snake | peinit, peipkg, eventd | job.created, peipkg.repo-add, synthetic.config_change |
SCREAMING_SNAKE | StrataFS, LCS | STRATAFS_COPY_UP, LCS_BACKUP_START |
The dotted family is not internally consistent either: peipkg.repo-add
is dotted-then-kebab while synthetic.config_change is
dotted-then-snake.
This is recorded because a consumer matching event types has to know it, not because it is defended. Match the exact strings in this book rather than deriving one from a pattern.
1.4 Reading an Event
Peios / Using Peios / Events / Introduction
Four rules govern every consumer of this stream.
1.4.1 Ignore unknown keys #
Future versions may add fields to an event without changing the existing ones. A consumer that processes the keys it knows and ignores the rest keeps working across upgrades. A consumer that rejects unrecognised keys breaks on the first addition.
1.4.2 Do not rely on a key being absent #
A field that is optional today may become always-present later. Absence is not a signal.
1.4.3 Delivery is best-effort #
KMES is a ring buffer. The kernel writes; keeping up is the subscriber's problem.
- A subscriber that falls behind loses events. eventd notices and
records a
synthetic.gap(§8.1), which is how a gap becomes visible rather than silent. - There is no replay. An event missed is gone. Nothing can ask for it back.
- Buffers are per-subscriber. One slow reader does not affect another.
- Order is per-subscriber, not global.
For durable audit, read events from eventd's stores rather than from KMES directly. eventd drains its subscription continuously and persists what it reads; from that point the store is the record, not the ring.
1.4.4 Events are not authenticated #
Events are trusted because they came from the kernel through KMES, not because they are signed. Nothing in an event carries a signature.
Cryptographic non-repudiation is a userspace concern applied after events leave the kernel. If a deployment needs it, it is added on the far side of eventd, not here.
1.4.5 Versioning #
Event types are not versioned by a field. The schemas in this book are stable: fields may be added, but an existing field will not be renamed, retyped or removed under the same type string.
A change that would break compatibility changes the type string
instead — access-audit would become access-audit-v2 — so an existing
consumer keeps receiving the shape it understands and simply never sees
the new one. No type in this book has been versioned that way.
2.1 The Subject Record
Peios / Using Peios / Events / Common Records
The subject map identifies the effective token under which an
operation ran. It appears in every KACS event except
logon-session-destroyed.
For an event fired from an impersonating thread, the subject is the impersonation token, not the primary. For a non-impersonating thread the primary token is the effective one.
For continuous-audit (§3.2) the subject is the effective token at
the moment of the operation, not at the moment the handle was opened.
A process whose token changed since the open gets the current subject on
each subsequent operation.
2.1.1 Fields #
| Key | Type | Meaning |
|---|---|---|
user_sid | bin | The token's user SID. |
group_sids | array of bin | The token's group SIDs. |
group_attributes | array of uint | Per-group attribute bitmasks, parallel to group_sids. |
integrity_level | uint | The token's integrity RID — 0, 4096, 8192, 12288 or 16384. |
pip_type | uint | The calling process's PIP type. 0 None, 512 Protected, 1024 Isolated. |
pip_trust | uint | The calling process's PIP trust level. |
auth_id | uint | The LUID of the logon session the token belongs to. |
token_id | uint | The token's own LUID. |
impersonation_level | uint | 0–3. A primary token reports 0. |
projected_uid | uint | The Linux UID projection, for correlating with Linux-side audit data. |
Every field is always present.
auth_id is the join key to logon-session-destroyed (§3.5) and to
/sys/kernel/security/kacs/sessions. token_id correlates events from
one specific token.
2.1.2 The two parallel arrays #
group_sids[i] and group_attributes[i] describe the same group entry,
and the arrays are always the same length.
| Flag | Value | Meaning |
|---|---|---|
SE_GROUP_MANDATORY | 0x01 | Cannot be disabled. |
SE_GROUP_ENABLED_BY_DEFAULT | 0x02 | Enabled at creation. |
SE_GROUP_ENABLED | 0x04 | Currently enabled. |
SE_GROUP_OWNER | 0x08 | May act as owner for new objects. |
SE_GROUP_USE_FOR_DENY_ONLY | 0x10 | Matches deny ACEs only. |
SE_GROUP_INTEGRITY | 0x20 | Identifies an integrity SID. Present for ABI parity; MIC reads the token's integrity_level field, not this flag. |
SE_GROUP_INTEGRITY_ENABLED | 0x40 | Used with SE_GROUP_INTEGRITY. |
SE_GROUP_RESOURCE | 0x20000000 | A domain-local group from a resource domain. Metadata only. |
SE_GROUP_LOGON_ID | 0xC0000000 | The logon SID. Cannot be disabled. |
These are MS-DTYP's names, which PCDS uses. The headers declare the same
flags as KACS_SID_GROUP_*; the Peios Kernel TRM §3.A maps the two.
Reconstructing group membership from an event means applying the same
rule the access check applies: SE_GROUP_ENABLED set and
SE_GROUP_USE_FOR_DENY_ONLY clear, for allow-side matching.
2.1.3 What the subject deliberately omits #
Privileges, claims, the restricted-SID list, confinement state, and the default DACL are all absent. Each is unbounded, and an event that embedded them could grow without limit.
Code needing full token state queries the token directly with
KACS_IOC_QUERY, while it still exists. The subject record is for
correlation, not for reconstruction.
2.2 The Process Record
Peios / Using Peios / Events / Common Records
The process map identifies the process the event came from. It appears
in every KACS event except logon-session-destroyed, which has no
causing process.
| Key | Type | Meaning |
|---|---|---|
pid | uint | The process ID. |
name | string | The kernel's name for the process, typically the executable's basename. |
executable_path | string | The path resolved at exec, with symlinks already followed. |
Every field is always present. For continuous-audit this is the
operation-time process, not the one that opened the handle.
2.2.1 Correlating on it #
pid is reliable only in the short term. Process IDs are reused, so a
pid in a week-old record may name something unrelated. For durable
records, correlate on name and executable_path.
name is the kernel's internal name. It is not argv[0], and a process
that rewrote its argv is unaffected here.
2.2.2 No thread ID #
The record identifies a process, not a thread. Events that fire on one
specific thread still report only the process. Nothing in the current
event set carries a tid.
2.3 The Caller Summary
Peios / Using Peios / Events / Common Records
LCS uses its own identity submap, caller, rather than the subject
record. Six of its seven audit events carry it.
It exists separately because LCS's events are emitted from a different subsystem with a different bound on what it will serialise. The two records overlap but are not interchangeable, and a consumer handling both needs to read each on its own terms.
| Key | Meaning |
|---|---|
effective_token_guid | The token the operation ran under. |
true_token_guid | The underlying token, where impersonation is in play. |
process_guid | The calling process. |
user_sid | The effective token's user SID. |
authentication_id | The logon session LUID. |
token_id | The token's own LUID. |
token_type | Primary or impersonation. |
impersonation_level | 0 for a primary token. |
integrity_level | The token's integrity RID. |
Nine fields, and no more. Group lists, privilege arrays, claims and default DACLs are unbounded and are never included — the same reasoning that shapes the subject record (§2.1), applied independently.
2.3.1 Against the subject record #
subject | caller | |
|---|---|---|
| Emitted by | KACS | LCS |
| Identity by | SIDs | GUIDs, plus user_sid |
| Groups | group_sids with attributes | absent |
| PIP state | pip_type, pip_trust | absent |
| Linux projection | projected_uid | absent |
| Session join key | auth_id | authentication_id |
Note the session join key is spelled differently in each. Correlating a
KACS event with an LCS event on the same logon session means matching
subject.auth_id against caller.authentication_id.
3.1 access-audit
Peios / Using Peios / Events / Kernel Access Events
The most common event in the system. Fires at AccessCheck completion,
from the SACL audit walk, and from a token's audit_policy forcing an
audit that no ACE asked for.
Event type string: access-audit.
| Key | Type | Meaning |
|---|---|---|
subject | map | Subject record (§2.1). |
object_context | bin or nil | Caller-supplied opaque identifier for the object. nil if AccessCheck was not given one. |
requested_access | uint | The mask the caller requested, after generic mapping. |
granted_access | uint | The mask actually granted. |
success | bool | True when every requested bit is in granted_access. |
trigger | map | Why this event fired. Below. |
process | map | Process record (§2.2). |
Every field is always present.
3.1.1 The trigger record #
| Key | Type | Meaning |
|---|---|---|
kind | string | sacl or policy. |
ace | bin or nil | For kind = sacl, the matched ACE's bytes. For kind = policy, nil. |
kind = sacl means an audit ACE in the object's SACL — or in a central
access policy's SACL — matched this access, and ace carries the exact
ACE so a consumer can identify which rule fired.
kind = policy means nothing in any SACL asked for this. The audit
fired because the calling token's audit_policy carries
OBJECT_ACCESS_SUCCESS or OBJECT_ACCESS_FAILURE, which forces an
audit on every access that token makes.
The distinction matters when reading volume. A flood of kind = policy
events is a property of the token, and is fixed by changing the token's
policy. A flood of kind = sacl events is a property of the object, and
is fixed by changing its SACL.
3.1.2 One access can produce several events #
The event is per matching audit ACE, not per access. An access that
matches three audit ACEs produces three events, each with a different
ace in its trigger and otherwise identical.
3.1.3 Example #
A successful read where a SACL audit ACE matched:
{
"event_type": "access-audit",
"event_time": <timestamp>,
"subject": { ... },
"object_context": <bin>,
"requested_access": 0x00120089,
"granted_access": 0x00120089,
"success": true,
"trigger": { "kind": "sacl", "ace": <bin> },
"process": { ... }
}
0x00120089 is GENERIC_READ after mapping to file-specific bits.
Generic bits never survive into an event; the mask is always specific.
3.2 continuous-audit
Peios / Using Peios / Events / Kernel Access Events
Fires per operation on an already-open handle, when the operation's required access overlaps a continuous audit mask cached on that handle.
Where access-audit records the decision to open something,
continuous-audit records what was then done with it. The mask is
configured by SYSTEM_ALARM* ACEs at the access check that opened the
handle, and the event is fired afterwards by whatever kernel subsystem
enforces the operation — FACS for file handles.
Event type string: continuous-audit.
| Key | Type | Meaning |
|---|---|---|
subject | map | Subject record (§2.1), reflecting the operation-time effective token. |
object_context | bin or nil | Object identifier. May differ from the open-time context if the enforcement point keeps its own. |
operation | string | The operation name. FACS uses a file. prefix — file.read, file.write, file.fallocate. Other enforcement points use their own. |
requested_access | uint | The mask this specific operation needs. |
matched_access | uint | The subset of requested_access that overlapped the handle's continuous audit mask. |
granted_access | uint | The mask cached on the handle at open time. |
success | bool | Whether the operation itself succeeded. |
process | map | Process record (§2.2), operation-time. |
Every field is always present.
3.2.1 The three masks #
They are easy to confuse, and reading them together is the point of the event.
requested_accessis what the operation needs. A read needsFILE_READ_DATA; a write needsFILE_WRITE_DATA; anmmapwithPROT_EXECneedsFILE_EXECUTE.matched_accessisrequested_accessintersected with the handle's continuous audit mask. This is why the event exists — the bits that triggered it.granted_accessis what the handle was opened with. An operation can only succeed ifrequested_accessis a subset of it.
Read as a sentence: the operation needed these bits, of which these triggered audit, against a handle opened with these, and it succeeded or did not.
3.2.2 Why the subject is re-read #
A handle outlives the token that opened it. A process that changes its effective token — starting or stopping impersonation — keeps its handles, and every subsequent operation is audited under the token current at that moment.
An investigation that assumes the open-time identity for later operations will attribute them to the wrong principal.
3.2.3 Example #
A read on a file carrying an alarm ACE on FILE_READ_DATA:
{
"event_type": "continuous-audit",
"event_time": <timestamp>,
"subject": { ... },
"object_context": <bin>,
"operation": "file.read",
"requested_access": 0x00000001,
"matched_access": 0x00000001,
"granted_access": 0x00120089,
"success": true,
"process": { ... }
}
3.3 privilege-use
Peios / Using Peios / Events / Kernel Access Events
Fires at AccessCheck for a privilege that contributed bits to the
granted mask, when the token's audit_policy asks for it.
Event type string: privilege-use.
| Key | Type | Meaning |
|---|---|---|
subject | map | Subject record (§2.1). |
object_context | bin or nil | Object identifier from the access check. |
privilege | string | The canonical privilege name. |
requested_access | uint | The bits the caller requested that this privilege might address. |
granted_access | uint | The bits the privilege contributed, before later narrowing. |
surviving_access | uint | The subset of granted_access that reached the final granted mask. |
success | bool | True when surviving_access is non-empty. |
process | map | Process record (§2.2). |
Every field is always present.
3.3.1 Only five privileges can appear #
The privilege field carries a canonical name, and only five are
representable:
SeSecurityPrivilegeSeTakeOwnershipPrivilegeSeBackupPrivilegeSeRestorePrivilegeSeRelabelPrivilege
Any other bit fails the encoder closed rather than emitting an unnamed privilege. This is consistent with those being the only five that can influence an access check at all, and therefore the only five that can produce this event.
A consumer will never see a sixth name here. A privilege used for
something other than an access decision produces no privilege-use
event.
3.3.2 Success means it worked, not that it fired #
This is the field most often misread.
success = true — the privilege contributed bits and they survived to
the final grant. The privilege did useful work. Governed by the
PRIVILEGE_USE_SUCCESS audit policy.
success = false — the privilege contributed bits and a later layer
stripped them. The caller was in a confinement that does not permit it,
or a CAAP rule narrowed it out, or the caller was non-dominant under
PIP. Governed by PRIVILEGE_USE_FAILURE.
A success = false event is not a failed attempt to use a privilege.
It is a privilege that fired and was then overridden — which is usually
the more interesting record of the two, because it shows a boundary
doing its job.
A token can carry either policy bit, both, or neither, and each controls its own flavour independently.
3.3.3 Example #
A backup tool whose SeBackupPrivilege was stripped by confinement:
{
"event_type": "privilege-use",
"event_time": <timestamp>,
"subject": { ... },
"object_context": <bin>,
"privilege": "SeBackupPrivilege",
"requested_access": 0x00000001,
"granted_access": 0x00000001,
"surviving_access": 0x00000000,
"success": false,
"process": { ... }
}
3.4 caap-policy-diagnostic
Peios / Using Peios / Events / Kernel Access Events
Fires during the central access policy step of AccessCheck, for two
unrelated conditions distinguished by the kind field: a CAAP SACL that
failed to evaluate, and a staged policy that would have decided
differently from the effective one.
Event type string: caap-policy-diagnostic.
| Key | Type | Meaning |
|---|---|---|
subject | map | Subject record (§2.1). |
object_context | bin or nil | Caller-supplied object identifier. |
kind | string | sacl-error or staging-mismatch. |
phase | string | Which phase of CAAP evaluation produced the diagnostic. |
policy_sid | bin or nil | The policy involved. nil for staging-mismatch. |
rule_index | uint or nil | The rule involved. nil for staging-mismatch. |
reason | string or nil | Diagnostic text, for sacl-error. |
requested_access | uint | The mask the caller requested. |
effective_granted_access | uint | Total granted under the effective policy. |
staged_granted_access | uint | Total the staged policy would have granted. |
object_results_differ | bool | Whether staged and effective differed for this object. |
process | map | Process record (§2.2). |
Every key is always present; several are nil depending on kind.
3.4.1 kind = sacl-error #
A central access rule's SACL could not be evaluated. policy_sid,
rule_index and reason identify what failed and where.
This is a defect in the policy, not in the access. The access is still decided; the event says a rule that should have participated could not.
3.4.2 kind = staging-mismatch #
A staged policy — one being trialled before it takes effect — would have produced a different result from the policy actually in force. This is the mechanism's whole purpose: run the new policy in parallel and report where it would have changed something, before it can break anything.
policy_sid and rule_index are nil here. The mismatch is a property
of the whole evaluation, not attributable to one rule.
3.4.3 The limits of the mismatch report #
Worth knowing before building anything on it.
It carries the two total granted masks and a boolean. It does not identify which rule differed, and it does not report which audit events would have changed.
For a mismatch that is purely in SACL behaviour, the two masks can be
equal while object_results_differ is true. A consumer comparing
only the masks will conclude nothing changed.
3.5 logon-session-destroyed
Peios / Using Peios / Events / Kernel Access Events
Fires when a logon session loses its last token reference and the kernel destroys it.
Event type string: logon-session-destroyed.
| Key | Type | Meaning |
|---|---|---|
session_id | uint | The destroyed session's LUID. |
user_sid | bin | The session's user SID. |
logon_type | uint | Interactive, Network, and so on. |
auth_package | string | The authenticating package — Kerberos, NTLM, local. |
created_at | uint | When the session was created. |
Every field is always present.
3.5.1 No subject, no process #
This is the only KACS event with neither. The session was the subject, and it has just ended. No process caused it — the last reference simply went away, which may have been any process exiting, or none in particular.
3.5.2 Correlating #
session_id is the same LUID that every token in that session reported
as subject.auth_id (§2.1), and that LCS events report as
caller.authentication_id (§2.3). It is the join key for everything
that session ever did.
With created_at, the event bounds a session's whole lifetime, which is
what makes it useful for reconstructing a login after the fact.
The event fires exactly once per session. Consumers — authd especially — use it to release session-scoped state: Kerberos tickets, cached directory data, per-session credentials.
3.5.3 There is no matching creation event #
Nothing is emitted when a session is created, so the pair is asymmetric. See §3.6 for what else is absent and why.
3.6 corrupt-sd
Peios / Using Peios / Events / Kernel Access Events
Fires when FACS encounters a structurally invalid security descriptor on a file.
Event type string: corrupt-sd.
| Key | Type | Meaning |
|---|---|---|
subject | map | Subject record (§2.1) for the access that triggered detection. |
object_context | bin or nil | Identifier of the object whose descriptor was corrupt. |
reason | string | What was wrong — sd_too_large, acl_malformed, sid_invalid. |
process | map | Process record (§2.2). |
Every field is always present.
The subject is whoever happened to touch the file, not whoever caused the corruption. Nothing records that.
3.6.1 Rate-limited by design #
One event per inode per cache population. A corrupt descriptor read a thousand times during one mount's life produces one event, not a thousand.
This is deliberate: a filesystem with many corrupt descriptors would otherwise drown the audit stream at exactly the moment the stream is most needed. The consequence is that event count says nothing about access count — one event does not mean one access.
3.6.2 Informational only #
The kernel denies the access regardless. A corrupt descriptor fails closed, and this event is not part of that decision — it is what tells an administrator the corruption exists at all.
Without it, a file with a broken descriptor is simply inaccessible, with nothing anywhere explaining why.
3.6.3 Events that do not exist #
Three absences in the KACS set are worth stating, because each is a reasonable thing to look for:
- No
logon-session-created. Sessions are announced only when they end (§3.5). Track creation through authd's own records or by polling/sys/kernel/security/kacs/sessions. - No
token-created. A token's existence is observable through process inspection, not through an event. - No periodic or heartbeat events. Audit here is entirely event-driven. A silent stream means nothing happened, not that anything is broken.
These are intentional. The kernel's audit surface covers access decisions and session endings; other lifecycle tracking belongs to the layers above it.
4.1 STRATAFS_COPY_UP
Peios / Using Peios / Events / Filesystem Events
Fires on every StrataFS copy-up, successful or not.
Event type string: STRATAFS_COPY_UP.
| Key | Meaning |
|---|---|
path | The relative path within the mount, /-prefixed. |
provider_index | The stratum the object was copied from. |
provider_stratum | That stratum's path. |
create_index | The stratum it was copied into. |
create_stratum | That stratum's path. |
result_errno | Zero on success, the failure otherwise. |
Six keys, and no seventh.
4.1.1 No caller in the payload #
Nothing here names a token, and that is not an omission.
Copy-up preserves the source object's descriptor, so the resulting file records nothing about who caused it to exist. The caller is recovered from the envelope instead: KMES stamps the effective, true and process token GUIDs onto the header at ring-write time, and because copy-up runs in the caller's own context, those are the caller's (§1.2).
A consumer reading only payloads will conclude these events are anonymous. They are not — the identity is one level out.
4.1.2 ENOTDIR arrives here, not in the refusal event #
A parent materialisation that fails with ENOTDIR is reported through
this event, carried in result_errno, rather than through
STRATAFS_MUTATION_REFUSED (§4.2).
That is worth knowing when searching for a refusal and finding nothing: the record exists, under a different type, with all the required fields present.
4.2 STRATAFS_MUTATION_REFUSED
Peios / Using Peios / Events / Filesystem Events
Fires when a mutation is refused because of how the mount is arranged, rather than because of an access check.
Event type string: STRATAFS_MUTATION_REFUSED.
| Key | Meaning |
|---|---|
path | The relative path within the mount. |
operation | The operation name. |
provider_index | The provider stratum's index. |
provider_stratum | That stratum's path. |
errno | The refusal. |
| deferred flag | Whether the refusal was deferred. |
4.2.1 What counts as an arrangement refusal #
One explicit list, matching the specification's enumeration exactly:
EROFS, EXDEV, ENOTDIR, EISDIR, ENOTEMPTY, EEXIST, EINVAL.
Call sites cover every mutating path — writes, mappings, truncation,
fallocate, splice, copy_file_range, remap_file_range, setattr,
setxattr, removexattr, creation, tmpfile, unlink, rmdir, link,
supersede and rename.
EACCES is deliberately absent. A refusal produced by an access
check is audited by the mechanism that performed it, and StrataFS does
not duplicate those records. Looking here for a permission denial finds
nothing; look for access-audit (§3.1) instead.
4.2.2 What these records are for #
They report a mismatch between what a caller attempted and how the mount is arranged — software writing where it cannot, or an arrangement that does not admit an operation someone expected.
That is diagnostic information about configuration, and it is otherwise visible only as an error returned to a caller that may well discard it.
Rollbacks are audited under this same type with the deferred flag set: a create or link whose outer bookkeeping failed and whose lower object could not be removed again, and a failed publication rollback after a copy-up.
4.2.3 Two irregularities #
A refusal raised before a provider is known — creation, tmpfile, the
heads of link and rename — passes a provider index of -1, so
provider_stratum is emitted as an empty string. The specification asks
for the provider stratum in every refusal record, so this is a gap
rather than a design.
A refused deferred deletion is audited on any non-zero result, not
only on the arrangement errors, so one refused by an access check does
produce a StrataFS record. This is a deliberate exception to the
EACCES exclusion above: the requirement to audit a deferred deletion
is unconditional, because by then nobody is left to receive the error.
4.2.4 What is not audited #
Resolution, revalidation and enumeration emit nothing. They occur on every path operation, reveal nothing the resulting access check does not, and recording them would produce volume out of all proportion to their significance. There is no audit call anywhere in the lookup path.
Access checks against provider objects are audited by KACS under its own rules.
5.1 Registry Events
Peios / Using Peios / Events / Registry Events
LCS emits seven audit events through KMES. Six of them carry the caller summary (§2.3) rather than the subject record.
| Event | Emitted when |
|---|---|
LCS_KEY_OPEN_AUDIT | A key open matched a SACL audit ACE. |
LCS_BACKUP_START | Before REG_IOC_BACKUP reads any subtree data. |
LCS_BACKUP_COMPLETE | After a backup completes, or fails after starting. |
LCS_RESTORE_START | Before REG_IOC_RESTORE modifies any source state. |
LCS_RESTORE_COMPLETE | After a restore completes, or fails after starting. |
LCS_SOURCE_VALIDATION_FAILURE | LCS rejected malformed source data. |
LCS_SELF_CONFIG_INVALID | LCS rejected an invalid self-configuration value. |
Every payload is a msgpack map with string keys. GUIDs are 16-byte binary values; SIDs are binary KACS encodings.
5.1.1 Backup and restore are audited unconditionally #
Whatever the SACL on the target key says. They are privilege-gated bulk operations that bypass per-key access checks entirely, so the audit trail is the only record that they happened at all.
The start/complete pairing is deliberate too. LCS_BACKUP_START is
emitted before any data is read and LCS_RESTORE_START before any
state is modified, so an operation that dies partway still leaves
evidence that it began.
5.1.2 Separately: watch records #
LCS also produces watch records, read from a key file descriptor. These are not KMES events, not msgpack, and not audit — see §5.4.
5.2 LCS_KEY_OPEN_AUDIT
Peios / Using Peios / Events / Registry Events
Fires when a key open matched a SACL audit ACE.
| Key | Meaning |
|---|---|
caller | Caller summary (§2.3). |
| key GUID | The key that was opened. |
requested_access | The mask after registry generic mapping, with MAXIMUM_ALLOWED re-added if the caller asked for it. |
granted_access | The mask granted. Forced to zero on a denial. |
| decision | allowed or denied. |
sacl_match_flags | Bit 0 for a success-audit match, bit 1 for a failure-audit match. No other bits. |
granted_access being zero on a denial is enforced, not merely
intended: a denied event carrying a non-zero granted mask is rejected as
a malformed payload.
SACL evaluation follows the KACS AccessCheck algorithm — the SACL is
evaluated alongside the DACL, not separately. Reading or modifying a
SACL requires ACCESS_SYSTEM_SECURITY, itself gated by
SeSecurityPrivilege.
5.2.1 One matching SACL that produces nothing #
A request of MAXIMUM_ALLOWED alone maps to a desired mask of zero.
AccessCheck's SACL walk tests each audit ACE's mask against the mapped
desired access, and no ACE matches zero.
So an open with a matching audit ACE emits no event, and LCS emits nothing. This is a real hole in coverage for anyone auditing key opens: the one request shape that asks for everything is the one that records nothing.
5.2.2 When emission fails #
The policy is specific to this event, and differs from the bulk ones.
If LCS cannot construct a valid payload — corrupt internal state,
allocation failure, anything on the LCS side — the open fails with
EIO and no key fd is published. The audit is a precondition of the
access.
If the payload is valid but KMES cannot retain it — unavailable,
ring drops, capacity pressure, no consumer — the access decision and the
fd publication are unaffected. Loss accounting is KMES's problem, and
shows up as a synthetic.gap (§8.1) rather than as a failed open.
5.3 Validation and Configuration Failures
Peios / Using Peios / Events / Registry Events
5.3.1 LCS_SOURCE_VALIDATION_FAILURE #
Fires when LCS rejects malformed source data.
Carries the source slot identifier, then — where each is known — the
hive name, the RSI request id, the operation code and the key GUID. The
last field, validation_class, names what was wrong.
There are twelve classes:
| Group | Classes |
|---|---|
| Name fields | malformed_layer_name, malformed_key_name, malformed_value_name |
| Structural | malformed_response_payload, malformed_key_metadata, malformed_value_payload, malformed_delete_layer_orphan_list |
| Descriptors | malformed_security_descriptor, malformed_layer_metadata_security_descriptor |
| Sequencing | future_sequence_number, duplicate_winning_sequence_tie |
| Protocol | unknown_rsi_status_code |
The three name classes are field-specific — layer-name fields, key component or child-name fields, and value-name fields respectively.
The structural classes cover a response whose operation-specific payload
has the wrong shape or trailing bytes; a lookup or enumeration whose
metadata block is incomplete, duplicated, unreferenced or nil; a value
payload with an invalid type, a tombstone/data mismatch or oversized
data; and an invalid orphan GUID array from RSI_DELETE_LAYER.
5.3.2 LCS_SELF_CONFIG_INVALID #
Fires when LCS rejects an invalid self-configuration value.
Carries the parent path and value name of the offending parameter, the
expected type and numeric range, what was actually received — one of
missing, wrong_type or dword_out_of_range, with the actual type or
value where applicable — and the value LCS retained instead.
5.3.2.1 Expect nineteen of these on a first boot #
Because missing counts as invalid, a first boot before seed restore
emits one event per parameter on each refresh: nineteen events
against an empty Registry\ key.
That is correct and expected, and it is a noticeable share of the boot audit stream. A consumer alerting on validation failures needs to know it, or every fresh machine looks like it is failing.
5.4 Watch Records
Peios / Using Peios / Events / Registry Events
Watch records are not KMES events. They are binary records read from
a key file descriptor with read(), and they exist to tell a watcher
that something under a key changed.
They are documented here because an operator asking what the registry reports would otherwise miss them, but nothing else in this book applies to them: no msgpack, no envelope, no identity stamps, no audit meaning.
5.4.1 Reading #
A single read() returns as many complete records as fit in the
caller's buffer. A record is never split across two calls.
If the buffer cannot hold even the first queued record, read() fails
with EINVAL — the buffer is too small to make progress, and the caller
retries with a larger one. On an armed fd with an empty queue, read()
blocks, or returns EAGAIN under O_NONBLOCK.
Only records copied out in full are dequeued.
5.4.2 Layout #
Every record begins with the same four fields.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | total_len |
| 4 | 2 | event_type |
| 6 | 2 | name_len |
| 8 | name_len | name, UTF-8 |
All integers are little-endian. total_len is the whole record
including this header, it is how a consumer advances to the next record,
and it is the only safe way to do so.
5.4.3 Subtree watches carry a path #
A subtree watch's records carry additional fields after the name, locating the key the change happened on relative to the watched key.
| Size | Field |
|---|---|
| 2 | path_depth |
2 + n each | path_components: a u16 length, then that many UTF-8 bytes |
path_depth is the number of components from the watched key down to
the changed key. Zero means the change was on the watched key itself.
The components are length-prefixed rather than joined by a separator because registry names can contain any Unicode character and value names can contain backslashes. A concatenated path string would be ambiguous.
OVERFLOW records are emitted in the bare eight-byte form, with no path
even on a subtree watch — there is no single key to name when the queue
itself overflowed.
The header offsets are ABI, named in uapi/pkm/lcs.h and listed in the
Peios Kernel TRM §5.A.
6.1 Job Events
Peios / Using Peios / Events / Service Events
peinit emits a structured event at every job and operation lifecycle transition. All of them go into the KMES ring buffer, encoded as msgpack per the envelope in §1.2.
There is no event socket. Structured events are not sent to eventd over any connection. eventd consumes them from the ring buffer, which is why they survive eventd being down, restarted, or not yet existing. The only thing peinit sends eventd over a socket is service output, which is a different path with different guarantees.
6.1.1 The three job events #
| Event | Fires when | Carries |
|---|---|---|
job.created | The job object exists. | Job identifier, service name, type, image path, identity, operation identifier. |
job.started | exec succeeded. | Job identifier, PID, cgroup path. |
job.ended | The process exited or was killed. | Job identifier, final state, exit code or signal, duration, failure cause. |
The event type is the dotted string; the fields form the msgpack
payload. The payloads are supersets of the summaries above — job.ended
in particular carries the whole record.
6.1.2 Ordering #
When one runtime step produces several lifecycle events, peinit emits them in causal order before committing the retained state for that step. A consumer sees the events in an order consistent with what happened, not in whatever order the writes completed.
6.2 Operation Events
Peios / Using Peios / Events / Service Events
Every operation event carries the same five fields: operation_id,
type, service, source, caller — null for a
lifecycle-generated operation — and state after the transition.
Seven types, each adding its own fields:
| Event | Adds |
|---|---|
operation.requested | — |
operation.started | — |
operation.completed | duration_ns, result |
operation.failed | duration_ns, failure_reason |
operation.cancelled | reason |
operation.merged | merged_into |
operation.aborted | duration_ns, reason |
6.2.1 duration_ns measures from creation #
Not from the start of execution. The reasoning is the same as for the operation timeout: what a caller waited is what matters, and queue time is part of it.
An operation that sat in a queue for a minute and then ran for a second reports 61 seconds, not 1.
6.2.2 One field, three names #
The same value appears under three spellings across two surfaces:
failure_reasononoperation.failedreasononoperation.cancelledandoperation.abortederrorin the control interface's operation view (PSPU §4)
A consumer correlating an event stream against the control interface has to map all three onto each other.
7.1 The Event Set
Peios / Using Peios / Events / Package Events
peipkg emits eleven event types into the kernel event subsystem. The caller's identity is stamped into the envelope by the kernel (§1.2) and is not part of the payload.
| Type | Emitted for | Emitted today |
|---|---|---|
peipkg.install | A successful install | yes |
peipkg.upgrade | A successful upgrade, downgrade, or undo | yes |
peipkg.uninstall | A successful uninstall | yes |
peipkg.refresh | A repository refresh, successful or partially failed | yes |
peipkg.transaction-failed | A rejected or rolled-back transaction | yes |
peipkg.recovery | A recovery resolved through peipkg recover | yes |
peipkg.authorisation | An operator authorisation record | yes |
peipkg.repo-add | A repository add | yes |
peipkg.repo-remove | A repository remove | yes |
peipkg.claim | A claim grant or revoke | yes |
peipkg.config-change | A trust-policy or transport-flag change | no |
peipkg.config-change is specified and not emitted. A trust-policy
or transport-flag change today produces no event at all.
7.1.1 Payload fields #
| Field | Content |
|---|---|
txn_id | The transaction identifier. |
outcome | success, rejection, or rollback. |
repo | The repository, for repository operations. |
detail | The rejection reason, the operation count, or the authorised action. |
timestamp | RFC 3339, UTC. |
packages | Name, version and architecture per package. |
Note timestamp here is an RFC 3339 string, not the uint used
elsewhere in this book (§1.3). peipkg carries its own.
7.1.2 Emission depends on a privilege #
An audit privilege on the caller's token. Without it, emission fails, peipkg warns, and the operation proceeds unaudited. The absence of an event does not mean the operation did not happen.
On a kernel with no emit call, emission is a silent successful no-op.
7.2 What Is Not Recorded
Peios / Using Peios / Events / Package Events
The gaps in peipkg's audit trail are specific, and each one is a place where something happened and nothing was written.
- An install or upgrade event carries no source repository, although one is known at the time.
- A committed cross-root operation's success event carries no transaction identifier, so it cannot be correlated with the rest of its transaction.
- Automatic recovery at the head of an ordinary operation emits
nothing. Only recovery through
peipkg recoverproducespeipkg.recovery. peipkg recover's failure paths emit nothing. A recovery that fails leaves no record that it was attempted.- Declining at a prompt emits nothing.
- Enabling insecure transport emits no authorisation record, and so
does installing unsigned content under an
optionalpolicy — the two decisions most worth recording are the two that are not. peipkg-composeemits nothing at all. Image composition is entirely unaudited.
7.2.1 Reading the trail with these in mind #
Two consequences follow for anyone building on this stream.
Absence is not evidence. An operation with no event may have been performed by a caller without the audit privilege, may have taken one of the paths above, or may not have happened. The three are indistinguishable from the stream.
Transaction correlation is incomplete. txn_id ties an operation's
events together, but the cross-root success case omits it, so a
reconstruction keyed on txn_id will silently drop those.
8.1 Synthetic Events
Peios / Using Peios / Events / Event Daemon Events
Synthetic events are records eventd generates about itself. They are written straight into a shard database and never touch KMES.
They carry no KMES header: no identity stamps, no sequence number, no
origin class. What they have is a wall-clock timestamp taken when eventd
generated the record, and a type string prefixed synthetic., which is
what distinguishes them in the events table — there is no separate
record-type column.
| Condition | Type |
|---|---|
| Lost events detected on a CPU | synthetic.gap |
| eventd started and attached to KMES | synthetic.startup |
| Graceful shutdown beginning | synthetic.shutdown |
| A write to any store failed | synthetic.storage_error |
| A configuration value changed at runtime | synthetic.config_change |
Five, and no more. Payload schemas are in the eventd manual §3.2.
8.1.1 There is no event for bad input #
Deliberately. Log and metric datagrams arrive unauthenticated from arbitrary local processes, and emitting a durable record per bad datagram would hand every process an amplification primitive (PSPU §3.4).
These five are conditions eventd observed about itself, not reactions to what it was sent.
8.1.2 Which shard they land in #
CPU-specific — synthetic.gap — goes to the shard assigned to the
CPU that generated it, handed to that writer thread alongside that CPU's
ordinary events. A gap record travels with the events it describes.
Daemon-wide — startup, shutdown, config changes, storage errors — go to shard 0 when shard 0 is writable, otherwise to the lowest-numbered writable active shard. If no shard is writable, the event is skipped and the failure is logged to standard error.
A storage error is why the fallback exists. It describes a failure on one shard but is itself a daemon-wide notification, so it is not written to the failing shard unless that shard has since been replaced and is writable again. Writing the record of a shard's failure into that shard would lose it exactly when it matters.
8.1.3 The timestamp is when eventd noticed #
Not when the condition occurred.
A synthetic.gap is stamped at detection, which may be long after
the events it describes were overwritten — and after a restart it may be
the first thing written in a new boot about events lost in the previous
one.
An investigation that treats a gap record's timestamp as the time of the loss will look in the wrong window.
8.1.4 Storage and ordering #
Synthetic events live in the same shard databases as KMES events and
take part in the same batching, retention and queries. Access control
treats their types like any other, so
Machine\System\eventd\Security\Events\synthetic governs them.
They are ordered by their eventd-assigned timestamp and take no part in per-CPU sequence numbering.
Appendix A All Event Types
Peios / Using Peios / Events
Every event type in this book, in one table. Thirty-four types across six emitters, plus the registry's watch records, which are a separate mechanism.
A.1 KMES events #
| Type | Emitter | Where |
|---|---|---|
access-audit | KACS | §3.1 |
continuous-audit | KACS | §3.2 |
privilege-use | KACS | §3.3 |
caap-policy-diagnostic | KACS | §3.4 |
logon-session-destroyed | KACS | §3.5 |
corrupt-sd | KACS | §3.6 |
STRATAFS_COPY_UP | StrataFS | §4.1 |
STRATAFS_MUTATION_REFUSED | StrataFS | §4.2 |
LCS_KEY_OPEN_AUDIT | LCS | §5.2 |
LCS_BACKUP_START | LCS | §5.1 |
LCS_BACKUP_COMPLETE | LCS | §5.1 |
LCS_RESTORE_START | LCS | §5.1 |
LCS_RESTORE_COMPLETE | LCS | §5.1 |
LCS_SOURCE_VALIDATION_FAILURE | LCS | §5.3 |
LCS_SELF_CONFIG_INVALID | LCS | §5.3 |
job.created | peinit | §6.1 |
job.started | peinit | §6.1 |
job.ended | peinit | §6.1 |
operation.requested | peinit | §6.2 |
operation.started | peinit | §6.2 |
operation.completed | peinit | §6.2 |
operation.failed | peinit | §6.2 |
operation.cancelled | peinit | §6.2 |
operation.merged | peinit | §6.2 |
operation.aborted | peinit | §6.2 |
peipkg.install | peipkg | §7.1 |
peipkg.upgrade | peipkg | §7.1 |
peipkg.uninstall | peipkg | §7.1 |
peipkg.refresh | peipkg | §7.1 |
peipkg.transaction-failed | peipkg | §7.1 |
peipkg.recovery | peipkg | §7.1 |
peipkg.authorisation | peipkg | §7.1 |
peipkg.repo-add | peipkg | §7.1 |
peipkg.repo-remove | peipkg | §7.1 |
peipkg.claim | peipkg | §7.1 |
peipkg.config-change | peipkg | §7.1 — specified, not emitted |
A.2 Not KMES #
| Type | Emitter | Transport | Where |
|---|---|---|---|
synthetic.gap | eventd | Written direct to a shard | §8.1 |
synthetic.startup | eventd | Written direct to a shard | §8.1 |
synthetic.shutdown | eventd | Written direct to a shard | §8.1 |
synthetic.storage_error | eventd | Written direct to a shard | §8.1 |
synthetic.config_change | eventd | Written direct to a shard | §8.1 |
| Watch records | LCS | read() on a key fd | §5.4 |
A.3 Which carry an identity, and how #
| Events | Identity from |
|---|---|
KACS, all but logon-session-destroyed | subject record in the payload (§2.1) |
logon-session-destroyed | user_sid and session_id directly; the session was the subject |
| LCS, six of seven | caller summary in the payload (§2.3) |
| StrataFS, peinit, peipkg | The envelope only. No identity in the payload (§1.2) |
| eventd synthetic | None. eventd is describing itself |
A.4 Known holes #
Collected from the chapters, because a reader planning coverage needs them in one place:
peipkg.config-changeis never emitted (§7.1).- peipkg omits the source repository on install and upgrade, the
transaction id on committed cross-root success, and emits nothing for
automatic recovery, recovery failures, declined prompts, insecure
transport, unsigned installs under an
optionalpolicy, orpeipkg-compose(§7.2). - A registry key open requesting
MAXIMUM_ALLOWEDalone emits noLCS_KEY_OPEN_AUDIT, because the mapped desired mask is zero and no ACE matches zero (§5.2). - StrataFS refusals raised before a provider is known emit an empty
provider_stratum(§4.2). caap-policy-diagnosticwithkind = staging-mismatchdoes not identify which rule differed, and its two masks can be equal whileobject_results_differis true (§3.4).- There is no
logon-session-created, notoken-created, and no heartbeat (§3.6). - peipkg emits nothing when the caller's token lacks the audit privilege (§7.1).
Constants and catalogs
Peios / Using Peios / Constants and Catalogs
Numeric constants — right bits, type values, enum members, limits — are catalogued in exactly one place each. This topic is either that place or a pointer to it.
What lives here #
| Page | Holds |
|---|---|
| Access mask bits | Per-object-type rights for files, processes, tokens, registry keys and services; the GenericMapping tables; the *_ALL_ACCESS and STANDARD_RIGHTS_* aggregates. |
| Other constants | Impersonation levels, integrity levels, logon types, elevation types, PIP tiers, audit policy flags, create dispositions, SECURITY_INFORMATION flags, and the kernel's size limits. |
What lives elsewhere #
Three catalogues are owned by documents that also define their semantics, and are not duplicated here.
| Catalogue | Canonical home |
|---|---|
| ACE types, ACE flags, and their numeric values | PCDS §5.4 — see ACE types and flags |
| Well-known SIDs | PCDS §4.4 — see Well-known SIDs |
| Every privilege, with its LUID bit | Peios Kernel TRM §3.4.2 — see Privilege catalog |
The three pages above are signposts. Following one gets you to the table.
Related catalogues #
- Every event type the system emits: the Peios Events Index.
- Every audit event's payload schema: the same book, chapters 3 to 8.
Well-known SIDs
Peios / Using Peios / Constants and Catalogs
The well-known SID catalogue is PCDS §4.4, in the Peios Core Data Structures specification. It is normative there, and is not duplicated here.
It covers the universal SIDs (S-1-1-0 Everyone, S-1-3-0 CREATOR OWNER, and the rest), the NT authority SIDs under S-1-5, the BUILTIN groups under S-1-5-32, domain SID structure, integrity label SIDs under S-1-16, capability SIDs under S-1-15, service SIDs under S-1-5-80, and the PIP trust labels under S-1-19.
For the conceptual treatment — which principals matter and why — read Well-known principals.
A note on the BUILTIN range #
PCDS lists the BUILTIN groups KACS assigns meaning to. It deliberately does not enumerate S-1-5-32-547 through S-1-5-32-583, which Active Directory defines: KACS gives them no special semantics, and they participate in ACE matching like any other group SID.
An older revision of this reference tabulated them. That table was removed on purpose, not lost — enumerating names the system does not act on implies a behaviour that does not exist.
PIP trust labels #
The S-1-19-T-L ladder encodes two dimensions: T is the PIP type axis, L the trust axis. Dominance requires both to be greater than or equal.
The numeric tiers are in Other constants, and the full SID list is in PCDS §4.4. The mechanism is Process integrity protection.
Privilege catalog
Peios / Using Peios / Constants and Catalogs
The per-privilege catalogue — every name, its LUID bit position, and what it does — is in the Peios Kernel TRM §3.4.2, "Catalogue". It is not duplicated here.
The conceptual treatment is Privileges, and the four-category model is Categories.
Five privileges influence an access check #
Only five can contribute bits to a granted mask, and therefore only five can appear in a privilege-use event:
SeSecurityPrivilegeSeTakeOwnershipPrivilegeSeBackupPrivilegeSeRestorePrivilegeSeRelabelPrivilege
Any other bit fails the audit encoder closed rather than emitting an unnamed privilege. See the Events Index §3.3.
Two are enforced but not nameable #
SeTakeOwnershipPrivilege and SeRelabelPrivilege are enforced by KACS but absent from the published privilege table, so they cannot be named in a service's RequiredPrivileges. A service needing either declares nothing and takes its source token's defaults, or fails to start if it tries to name one.
That asymmetry is peinit's, not the kernel's — see the peinit TRM §4.5.
ACE types and flags
Peios / Using Peios / Constants and Catalogs
The ACE catalogue is PCDS §5.4, in the Peios Core Data Structures specification. It is normative there, and is not duplicated here.
It covers every AceType value from 0x00 to 0x15 — the body layout of each family, the AceFlags bits, and the ACL revision rules that constrain which types may appear.
Twenty of those values have behaviour. Two do not: 0x04 (ACCESS_ALLOWED_COMPOUND_ACE, never implemented anywhere) and 0x15 (SYSTEM_ACCESS_FILTER_ACE, an MS-DTYP type Peios has not implemented). Both are named by the kernel ABI so that a decoder can label the byte, but neither affects an access decision: they are skipped when the descriptor is evaluated and preserved unchanged when it is written back. sd has no name for either and prints them as OTHER(0x04) and OTHER(0x15).
Two names per type #
PCDS names both the ACE structure and the AceType constant that selects it, because a reader may arrive with either — one from a declaration, the other from a hex dump. ACCESS_ALLOWED_ACE is the structure; ACCESS_ALLOWED_ACE_TYPE is the constant whose value is 0x00.
The headers use a third spelling, moving the qualifier to the front: KACS_ACE_TYPE_ACCESS_ALLOWED. The Peios Kernel TRM §3.A maps the two vocabularies.
Inheritance flags #
The four propagation flags and the INHERITED_ACE provenance flag are catalogued with the inheritance algorithm in PCDS §5.6, rather than with the type values.
MIC policy bits #
The mask of a SYSTEM_MANDATORY_LABEL_ACE carries policy bits saying what a non-dominant caller may not do. Those bits are in PCDS §5.4 alongside the ACE type; the mechanism is Mandatory integrity control.
Access mask bits
Peios / Using Peios / Constants and Catalogs
The 32-bit access mask has the same shape for every object type. Bits 0–15 are object-specific, 16–20 are standard rights, 24–25 are special, 28–31 are generic. The standard, special and generic regions are uniform; the object-specific bits carry different meanings for different object types.
This page is the catalogue of right values. What the mask means — how it is evaluated, how generic bits expand — is PCDS §5.3, which is normative. The bit layout of the mask within a descriptor is PCDS §5.1.
| Bit range | Region | Examples |
|---|---|---|
| 0–15 | Object-specific | FILE_READ_DATA, PROCESS_TERMINATE, TOKEN_QUERY |
| 16–20 | Standard rights | DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE |
| 21–23 | Reserved | Rejected at parse. PCDS §5.3. |
| 24–25 | Special | ACCESS_SYSTEM_SECURITY, MAXIMUM_ALLOWED |
| 26–27 | Reserved | Rejected at parse. |
| 28–31 | Generic | GENERIC_ALL, GENERIC_EXECUTE, GENERIC_WRITE, GENERIC_READ |
File access rights #
| Right | Value | For files | For directories (alias) |
|---|---|---|---|
FILE_READ_DATA | 0x0001 | Read file content | FILE_LIST_DIRECTORY — list entries |
FILE_WRITE_DATA | 0x0002 | Write file content | FILE_ADD_FILE — create files |
FILE_APPEND_DATA | 0x0004 | Append-only write | FILE_ADD_SUBDIRECTORY — create subdirectory |
FILE_READ_EA | 0x0008 | Read extended attributes | Same |
FILE_WRITE_EA | 0x0010 | Write extended attributes | Same |
FILE_EXECUTE | 0x0020 | Execute file | FILE_TRAVERSE — traverse through |
FILE_DELETE_CHILD | 0x0040 | (not applicable) | Delete children regardless of their permissions |
FILE_READ_ATTRIBUTES | 0x0080 | Read attributes | Same |
FILE_WRITE_ATTRIBUTES | 0x0100 | Write attributes | Same |
The directory names are aliases: identical bit values, different naming convention depending on whether the object is a file or a directory.
File GenericMapping #
| Generic right | Maps to |
|---|---|
GENERIC_READ | FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE |
GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EA | READ_CONTROL | SYNCHRONIZE |
GENERIC_EXECUTE | FILE_EXECUTE | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE |
GENERIC_ALL | Every file-specific bit, plus DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE |
FILE_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0x1FF = 0x001F01FF.
Process access rights #
| Right | Value | Meaning |
|---|---|---|
PROCESS_TERMINATE | 0x0001 | Send terminating signals. |
PROCESS_SIGNAL | 0x0002 | Send non-terminating informational signals — SIGCHLD, SIGURG, SIGWINCH. |
PROCESS_VM_READ | 0x0010 | Read process memory — ptrace(PTRACE_PEEK*), process_vm_readv, /proc/<pid>/mem reads. |
PROCESS_VM_WRITE | 0x0020 | Write process memory — ptrace(PTRACE_POKE*, ATTACH), process_vm_writev. |
PROCESS_DUP_HANDLE | 0x0040 | Duplicate file descriptors out via pidfd_getfd. |
PROCESS_SET_INFORMATION | 0x0200 | Change process attributes — priority, affinity, rlimits, /proc/<pid>/* writes. |
PROCESS_QUERY_INFORMATION | 0x0400 | Detailed process info — token, full /proc/<pid>/* reads. |
PROCESS_SUSPEND_RESUME | 0x0800 | Send stop and continue signals. |
PROCESS_QUERY_LIMITED | 0x1000 | Basic info — PID, image name, state. Required by pidfd_open. |
Bits 0x0004, 0x0008, 0x0080 and 0x0100 are unused.
Process GenericMapping #
| Generic right | Maps to |
|---|---|
GENERIC_READ | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | READ_CONTROL |
GENERIC_WRITE | PROCESS_SET_INFORMATION | PROCESS_VM_WRITE | WRITE_DAC |
GENERIC_EXECUTE | PROCESS_TERMINATE | PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED |
GENERIC_ALL | All process-specific bits, plus STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE |
PROCESS_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0x1FFF = 0x001F1FFF.
Token access rights #
| Right | Value | Meaning |
|---|---|---|
TOKEN_ASSIGN_PRIMARY | 0x0001 | Install as a process's primary token. |
TOKEN_DUPLICATE | 0x0002 | Create a copy. |
TOKEN_IMPERSONATE | 0x0004 | Install as a thread's impersonation token. |
TOKEN_QUERY | 0x0008 | Read token information. |
TOKEN_QUERY_SOURCE | 0x0010 | Subsumed by TOKEN_QUERY. Reserved for format compatibility. |
TOKEN_ADJUST_PRIVILEGES | 0x0020 | Enable, disable or remove privileges. |
TOKEN_ADJUST_GROUPS | 0x0040 | Enable or disable groups. |
TOKEN_ADJUST_DEFAULT | 0x0080 | Change default DACL, owner index, primary group index. |
TOKEN_ADJUST_SESSIONID | 0x0100 | Change interactive_session_id. Additionally requires SeTcbPrivilege. |
TOKEN_QUERY_SOURCE is a documented bit position rather than an enforced right: a token fd granting TOKEN_QUERY suffices for everything, and TOKEN_QUERY_SOURCE is not separately checked.
Token GenericMapping #
| Generic right | Maps to |
|---|---|
GENERIC_READ | TOKEN_QUERY | READ_CONTROL |
GENERIC_WRITE | TOKEN_ADJUST_PRIVILEGES | TOKEN_ADJUST_GROUPS | TOKEN_ADJUST_DEFAULT | WRITE_DAC |
GENERIC_EXECUTE | TOKEN_IMPERSONATE |
GENERIC_ALL | TOKEN_ALL_ACCESS |
TOKEN_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | 0x01FF = 0x000F01FF. Note the absence of SYNCHRONIZE, unlike the file and process aggregates.
Registry-key access rights #
| Right | Value | Meaning |
|---|---|---|
KEY_QUERY_VALUE | 0x0001 | Read a value. |
KEY_SET_VALUE | 0x0002 | Write a value. |
KEY_CREATE_SUB_KEY | 0x0004 | Create a subkey. |
KEY_ENUMERATE_SUB_KEYS | 0x0008 | Enumerate subkeys. |
KEY_NOTIFY | 0x0010 | Watch for changes. |
KEY_CREATE_LINK | 0x0020 | Create a symbolic link to another key. |
Bits from 0x0040 upward are reserved.
Registry GenericMapping #
| Generic right | Maps to |
|---|---|
GENERIC_READ | KEY_QUERY_VALUE | KEY_ENUMERATE_SUB_KEYS | KEY_NOTIFY | READ_CONTROL |
GENERIC_WRITE | KEY_SET_VALUE | KEY_CREATE_SUB_KEY | READ_CONTROL |
GENERIC_EXECUTE | READ_CONTROL |
GENERIC_ALL | All key-specific bits, plus STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE |
Service access rights #
| Right | Value | Meaning |
|---|---|---|
SERVICE_QUERY_CONFIG | 0x0001 | Read configuration. |
SERVICE_CHANGE_CONFIG | 0x0002 | Modify configuration. |
SERVICE_QUERY_STATUS | 0x0004 | Read runtime status. |
SERVICE_ENUMERATE_DEPENDENTS | 0x0008 | List dependent services. |
SERVICE_START | 0x0010 | Start the service. |
SERVICE_STOP | 0x0020 | Stop the service. |
SERVICE_PAUSE_CONTINUE | 0x0040 | Pause and resume. |
SERVICE_INTERROGATE | 0x0080 | Request a status update. |
SERVICE_USER_DEFINED_CONTROL | 0x0100 | Send service-specific control codes. |
Standard, special and generic rights #
| Right | Value |
|---|---|
DELETE | 0x00010000 |
READ_CONTROL | 0x00020000 |
WRITE_DAC | 0x00040000 |
WRITE_OWNER | 0x00080000 |
SYNCHRONIZE | 0x00100000 |
ACCESS_SYSTEM_SECURITY | 0x01000000 |
MAXIMUM_ALLOWED | 0x02000000 |
GENERIC_ALL | 0x10000000 |
GENERIC_EXECUTE | 0x20000000 |
GENERIC_WRITE | 0x40000000 |
GENERIC_READ | 0x80000000 |
The STANDARD_RIGHTS aggregates #
| Constant | Value | Composition |
|---|---|---|
STANDARD_RIGHTS_REQUIRED | 0x000F0000 | DELETE | READ_CONTROL | WRITE_DAC | WRITE_OWNER |
STANDARD_RIGHTS_READ | 0x00020000 | Alias of READ_CONTROL |
STANDARD_RIGHTS_WRITE | 0x00020000 | Alias of READ_CONTROL |
STANDARD_RIGHTS_EXECUTE | 0x00020000 | Alias of READ_CONTROL |
STANDARD_RIGHTS_ALL | 0x001F0000 | All five standard rights |
Three of these are the same value. STANDARD_RIGHTS_READ, _WRITE and _EXECUTE are all aliases of READ_CONTROL, which surprises people reading a mask and expecting them to differ. Only STANDARD_RIGHTS_REQUIRED and STANDARD_RIGHTS_ALL name distinct values.
STANDARD_RIGHTS_REQUIRED is the conventional minimum included in every *_ALL_ACCESS aggregate.
Other constants
Peios / Using Peios / Constants and Catalogs
Enumerated values that are not access rights. Access rights are catalogued separately in Access mask bits.
Impersonation levels #
A token's impersonation level. The conceptual model is Impersonation levels.
| Constant | Value | Meaning |
|---|---|---|
KACS_IMLEVEL_ANONYMOUS | 0 | No identity. |
KACS_IMLEVEL_IDENTIFICATION | 1 | Inspect only. Cannot be used for an access check. |
KACS_IMLEVEL_IMPERSONATION | 2 | Act as the client locally. The default. |
KACS_IMLEVEL_DELEGATION | 3 | Act as the client locally, and forward credentials to remote machines. |
Older documents spell these KACS_LEVEL_*. That spelling does not exist in any header.
A primary token reports level 0.
Integrity levels #
The RIDs used in S-1-16-* SIDs and in a token's integrity_level field. The model is Mandatory integrity control.
| Constant | RID | Name |
|---|---|---|
INTEGRITY_LEVEL_UNTRUSTED | 0 | Untrusted |
INTEGRITY_LEVEL_LOW | 4096 | Low |
INTEGRITY_LEVEL_MEDIUM | 8192 | Medium |
INTEGRITY_LEVEL_HIGH | 12288 | High |
INTEGRITY_LEVEL_SYSTEM | 16384 | System |
Spaced by 4096, so future levels can be inserted between existing ones.
These are named levels, not a closed enum. The integrity level is the S-1-16 SID's single sub-authority as an unsigned integer, compared numerically. Any such value is valid, and non-standard ones appear in Windows-interop descriptors — medium-plus at 8448, protected at 20480. Code that switches on the five names will mishandle those.
Mandatory policy flags #
A token's mandatory_policy field. Both are immutable after token creation.
| Flag | Value | Meaning |
|---|---|---|
NO_WRITE_UP | 0x01 | MIC blocks write-category access from lower integrity. |
NEW_PROCESS_MIN | 0x02 | At exec, lower the token's integrity to match the binary if the binary is lower. |
Logon types #
A session's logon_type field. The model is Logon types.
| Constant | Value | Meaning |
|---|---|---|
LOGON_TYPE_INTERACTIVE | 2 | Console, SSH, terminal services. |
LOGON_TYPE_NETWORK | 3 | Network resource access — SMB, RPC, federated. |
LOGON_TYPE_BATCH | 4 | Scheduled job. |
LOGON_TYPE_SERVICE | 5 | Service running under a specific principal. |
LOGON_TYPE_NETWORK_CLEARTEXT | 8 | Network logon with a cleartext credential. |
LOGON_TYPE_NEW_CREDENTIALS | 9 | Keep the local identity; use alternative credentials outbound. |
Values 1, 6, 7 and 10 upward are reserved.
Elevation types #
A token's elevation_type field.
| Constant | Value | Meaning |
|---|---|---|
KACS_ELEVATION_DEFAULT | 1 | Not part of a linked pair. |
KACS_ELEVATION_FULL | 2 | The elevated half of a linked pair. |
KACS_ELEVATION_LIMITED | 3 | The non-elevated half. |
PIP tiers #
A process's PSB pip_type field.
| Value | Meaning |
|---|---|
| 0 | None. Unprotected, the default for unsigned binaries. |
| 512 | Protected. Standard PIP protection. |
| 1024 | Isolated. Reserved; no signing key targets it. |
There are no public constants for these. Nothing in uapi/pkm/ names the tiers, and PIP_TYPE_NONE / _PROTECTED / _ISOLATED — which older documents use — exist nowhere in the tree. Protected survives only as the kernel-private PKM_KACS_PIP_TYPE_PROTECTED.
A program reasoning about tiers compares the numbers. The Peios Kernel TRM §3.7 says the same.
Token audit policy flags #
A token's audit_policy field. These are what force the audit events in the Events Index.
| Flag | Value | Meaning |
|---|---|---|
OBJECT_ACCESS_SUCCESS | 0x01 | Force an audit event on every successful access. |
OBJECT_ACCESS_FAILURE | 0x02 | Force an audit event on every failed access. |
PRIVILEGE_USE_SUCCESS | 0x04 | Emit a privilege-use event when a privilege's bits survive. |
PRIVILEGE_USE_FAILURE | 0x08 | Emit a privilege-use event when its bits are stripped. |
Create dispositions #
For kacs_open.
| Constant | Value | Behaviour |
|---|---|---|
KACS_DISPOSITION_SUPERSEDE | 0 | If it exists, delete and recreate; otherwise create. |
KACS_DISPOSITION_OPEN | 1 | If it exists, open; otherwise fail with ENOENT. |
KACS_DISPOSITION_CREATE | 2 | If it exists, fail with EEXIST; otherwise create. |
KACS_DISPOSITION_OPEN_IF | 3 | If it exists, open; otherwise create. |
KACS_DISPOSITION_OVERWRITE | 4 | If it exists, truncate and open; otherwise fail with ENOENT. |
KACS_DISPOSITION_OVERWRITE_IF | 5 | If it exists, truncate and open; otherwise create. |
Older documents spell these KACS_FILE_SUPERSEDE, KACS_FILE_OPEN and so on. That spelling does not exist in any header.
SECURITY_INFORMATION flags #
For kacs_get_sd and kacs_set_sd. Declared as KACS_SECINFO_*; the names below are the MS-DTYP spellings PCDS uses.
| Flag | Value | Right to read | Right to write |
|---|---|---|---|
OWNER_SECURITY_INFORMATION | 0x01 | READ_CONTROL | WRITE_OWNER |
GROUP_SECURITY_INFORMATION | 0x02 | READ_CONTROL | WRITE_OWNER |
DACL_SECURITY_INFORMATION | 0x04 | READ_CONTROL | WRITE_DAC |
SACL_SECURITY_INFORMATION | 0x08 | ACCESS_SYSTEM_SECURITY | ACCESS_SYSTEM_SECURITY |
LABEL_SECURITY_INFORMATION | 0x10 | READ_CONTROL | WRITE_OWNER, plus the integrity rules |
SACL_SECURITY_INFORMATION and LABEL_SECURITY_INFORMATION are mutually exclusive in one call.
Process mitigation flags #
The KACS_MIT_* flags on the PSB. The catalogue of what each one gates is Process mitigations.
Size and count limits #
| Limit | Value | Context |
|---|---|---|
| Max SD size | 65,535 bytes | Any SD blob. Normative in PCDS §5.1. |
| Max ACL size | 64 KB | Any ACL within an SD |
| Max single ACE size | 64 KB | Bounded by the ACL size |
| Min SID size | 8 bytes | Revision, count and authority, no sub-authorities |
| Max SID size | 68 bytes | 15 sub-authorities |
| Max token wire spec | 64 KB | kacs_create_token input |
| Max session wire spec | 4096 bytes | kacs_create_session input |
| Max CAAP wire spec | 256 KB | kacs_set_caap input |
| Max CAAP rules per policy | 256 | |
| Max applies-to expression | 64 KB | Per CAAP rule |
| Max conditional stack depth | 1024 | Evaluator limit |
| Max TLP cache entries | 64 | Trusted Library Path prefixes |
| Max TLP path length | 4096 bytes | Per prefix |
| Max mount template SD | 64 KB | kacs_set_mount_policy template |
Wire formats reference
Peios / Using Peios / Wire Formats Reference
Byte-level layouts are specified normatively in the PCSA books and in the kernel manual's ABI appendices. This page is the map.
Security descriptors and their parts #
| Structure | Where |
|---|---|
| Security descriptor header, self-relative | PCDS §5.1 |
| ACL header and ACE array | PCDS §5.2 |
| ACE header and per-type body layouts | PCDS §5.4 |
| Access mask layout | PCDS §5.3 |
Claim entry — CLAIM_SECURITY_ATTRIBUTE_RELATIVE_V1 | PCDS §5.9 |
| Conditional-ACE bytecode, with the full opcode table | PCDS §5.11 |
| Resource attribute ACEs | PCDS §5.10 |
See also Security descriptors.
Identifiers #
| Structure | Where |
|---|---|
| SID binary format | PCDS §4.1 |
| SID string format | PCDS §4.2 |
| GUID | PCDS §2 |
| LUID | PCDS §3 |
Kernel interfaces #
| Structure | Where |
|---|---|
| Token and session wire specs | Peios Kernel TRM §3.A — see Token and session specs |
| CAAP wire format | See CAAP format |
| Registry ABI, including watch record layout | Peios Kernel TRM §5.A |
| Token query class payloads | Peios Kernel TRM §3.A |
Event stream #
| Structure | Where |
|---|---|
| KMES event header, field by field | PSPK §2 |
| Event envelope, in summary | Events Index §1.2 |
| Per-event payload schemas | Events Index, chapters 3 to 8 |
Packages and repositories #
The package container, manifest, signature and repository index formats are PSPU §5.
Security descriptors (wire format)
Peios / Using Peios / Wire Formats Reference
The byte-level layout of a security descriptor and everything inside it is specified in PCDS, the Peios Core Data Structures specification. It is normative there and is not duplicated here.
| Structure | Section |
|---|---|
Descriptor header, self-relative form — Revision, Sbz1, Control, and the four offsets | PCDS §5.1 |
| Control flags | PCDS §5.1 |
ACL header — AclRevision, AclSize, AceCount | PCDS §5.2 |
ACE header — AceType, AceFlags, AceSize | PCDS §5.4 |
| Per-type ACE body layouts | PCDS §5.4 |
| Access mask bit layout | PCDS §5.3 |
| SID binary format | PCDS §4.1 |
| Claim entry format | PCDS §5.9 |
| Conditional-ACE bytecode | PCDS §5.11 |
Peios uses the MS-DTYP §2.4.6 self-relative format without translation, so a descriptor written by a Windows domain controller and replicated through Samba is evaluated as-is. Where KACS's evaluator departs from MS-DTYP — a separate question from the format — the Peios Kernel TRM §3.B has the list.
The right values carried in an access mask are catalogued in Access mask bits.
CAAP format
Peios / Using Peios / Wire Formats Reference
The central access policy wire format — the structure kacs_set_caap accepts — is specified in the Peios Kernel TRM §3.A, the KACS ABI reference.
The conceptual model, and what the structures mean, is Policies and rules.
Shape, in summary #
Fields are length-prefixed: each part begins with a 32-bit byte count followed by that many bytes, and an absent field has length zero. That convention runs through the policy blob, its rules, and each rule's applies-to expression and SACL.
The limits that bound it — 256 KB per wire spec, 256 rules per policy, 64 KB per applies-to expression — are in Other constants.
The applies-to expression and the rule SACLs use the same conditional-ACE bytecode as a conditional ACE, specified in PCDS §5.11.
Token and session specs
Peios / Using Peios / Wire Formats Reference
The binary specs that kacs_create_token and kacs_create_session accept, and the payload format of every token query class, are laid out in the Peios Kernel TRM §3.A, the KACS ABI reference. All 46 header offsets and all 24 query classes are there.
The conceptual field list — what a token holds and what each field does — is Token types.
Shape, in summary #
Both specs are length-prefixed throughout: a field is a 32-bit byte count followed by that many bytes, and an absent field has a count of zero. Arrays are a 32-bit element count followed by that many records.
Size limits are 64 KB for a token spec and 4096 bytes for a session spec; both are in Other constants.
The enumerated values a spec carries — impersonation level, elevation type, logon type, mandatory policy, audit policy — are catalogued in Other constants too, under the names the headers declare.
For building a session spec from the command line rather than by hand, logonse types the surface for you; the underlying format is unchanged.
Kernel ABI reference
Peios / Using Peios / Kernel ABI Reference
The kernel ABI is documented in the Peios Kernel TRM, in two appendices generated from uapi/pkm/:
| Appendix | Covers |
|---|---|
| §3.A, KACS ABI Reference | Syscall numbers, ioctls, structure layouts, token query classes, and the KACS constants |
| §5.A, LCS ABI Reference | The registry ABI — syscalls, ioctls, structure layouts, and the watch record header offsets |
Two more appendices sit alongside them: §3.B lists where the KACS evaluator departs from MS-DTYP, and §3.C lists the audit events KACS emits.
Names #
Those appendices use the names uapi/pkm/ declares. A reader arriving with the name PCDS uses — which is MS-DTYP's — will find the mapping in §3.A's own divergence table. Impersonation levels, create dispositions, group attribute flags and SECURITY_INFORMATION flags all differ between the two vocabularies. See Conventions §6.5 for why neither is being renamed.
Related #
- Byte-level layouts by structure: Wire formats reference.
- Numeric constants by kind: Constants and catalogs.
- The tracepoints the kernel exposes: Kernel tracepoints.