Vitrin OS
Vitrin OS is an agent-first display server: a small trusted core
(vitrind) speaking a capability-native wire protocol, with every legacy
Wayland or X11 application confined to its own per-app nested shim — so that
humans and AI agents can operate the same GUIs concurrently, under granular,
revocable, capability-scoped authorization.
The sentence the current stack cannot express
An agent is allowed to fill in one form, in one Firefox window, for the next five minutes. It cannot see the password manager open beside it. The moment you touch the mouse, you have control back. Hold Escape for a second and its authority is gone — mid-click, mid-keystroke, whatever it was doing.
Today’s agents drive desktops screenshot-by-screenshot: capture, pick pixel coordinates, click, capture again. That loop is slow and race-prone, and it runs with all-or-nothing authority — the isolation unit is a whole VM or desktop session, so one prompt-injected agent’s blast radius is everything on screen.
The underlying protocols cannot express the sentence above. X11 grants every
client near-total authority over the session — that is its model, not a bug.
Wayland achieved isolation by removing cross-client capabilities rather
than mediating them, and its wl_seat singleton has no notion of N
concurrent authenticated principals. AT-SPI2, the accessibility tree agents
use to avoid pixels, is an unauthorized backdoor onto every application’s
widgets.
Vitrin is built around the missing primitives instead: principals that authenticate at handshake, grants that carry verbs and constraints and revoke transitively, consent rendered by the core that owns the screen, and realms that make scoping structural rather than a policy setting.
Who this book is for
| You are… | Start at |
|---|---|
| Curious, want to see it work | Run the demo in five minutes |
| Writing an agent against it | Your first agent |
| Evaluating the security model | Grants, consent, and revocation |
| Wondering how apps are isolated | Realms and shims |
| Writing a client in another language | The wire protocol |
| Building an alternate core or shim | Build your own client or shim |
Read this before you trust anything here
Phase 1 is complete — every milestone closed on a named integration test that runs against the shipped binaries with no mock on any seam it claims. That is a real bar, and it is also a narrow one.
The sandbox is half-built. Since P2.6.2 an app in a realm runs in six
namespaces with an identity uid/gid map, zero capabilities and a private mount
table it cannot reshape — verified by the core from outside, and the spawn
refused when it cannot be. Since P2.6.3 it also gets a Landlock ruleset
with an enumerated read set, enforced before the shim’s execve, and a
generated ABI matrix of what that ruleset requires of a
kernel. P2.6.3 was accepted on 2026-08-19, and on corrected criteria
rather than the ones it was written with, which is worth reading narrowly: that
matrix probes nothing, so it is a table about the build rather than about
kernels; the per-kernel one its criteria ask for exists separately, and is
measured — which kernels
this build starts on, five distribution kernels booted
under QEMU with the shipped vitrind, three of them refused below the floor and
two admitted — though every row there is a kernel reading taken in a bare
initramfs and not a statement about the distribution that ships that kernel, so
the number of distributions measured as such is still one, and five machines
somebody might be refused on is not a sweep of the ABI ladder; and the ABI floor
that replaced the degradation ladder narrowed the task rather than finishing it.
But there is
since P2.6.4 a seccomp deny-list rather than a syscall boundary: it closes
the 13 rows vitrind --print-seccomp prints and leaves the rest of the
kernel’s syscall surface unenumerated, so the realm is filesystem-confined and
filtered against a named list and not
syscall-confined, and it keeps the invoking user’s supplementary groups. Environment hygiene confines
the well-behaved; it does not contain the hostile.
Do not deploy this against untrusted applications or untrusted agents. Where this is honest about its limits is the full list, and it is worth reading before the architecture convinces you of more than it should.
Other documents
- PRD and Technical Architecture — the canonical vision and design doc.
- Protocol conventions — normative wire format, object-id rules, error taxonomy.
protocol/vitrin-v0.xml— the IDL, which is the source of truth. Where this book and the IDL disagree, the IDL wins and this book has a bug.- SECURITY.md — what is in scope, and what is known-broken.
Run the demo in five minutes
At the end of this you will have watched an agent connect over a real Unix socket, petition for a capability, capture a real application’s pixels, click into it, type into it, and prove the text landed — with the trusted core mediating every step.
Nothing here is mocked. cargo xtask demo fails loudly rather than
substituting a stand-in.
What you need
Linux, and:
# Debian/Ubuntu
sudo apt-get install -y libxkbcommon-dev libpixman-1-dev weston xmlstarlet
# Arch
sudo pacman -S --needed libxkbcommon pixman weston meson
weston is there for weston-terminal, the real application the headless
demo drives. The Rust toolchain pins itself — rust-toolchain.toml makes
rustup install the right version on your first cargo command.
Build it
git clone https://github.com/vitrin-os/vitrin-os.git
cd vitrin-os
# The Rust side: vitrind, xtask, and the test fixtures.
cargo build --workspace
# The C side: the per-app wlroots shim. It lives outside the Cargo
# workspace by design and needs its own dependency step.
bash shim/ci/install-deps.sh
meson setup shim/build shim && meson compile -C shim/build
The shim is not optional. cargo xtask demo looks for it at
shim/build/vitrin-shim (or wherever VITRIN_C_SHIM_BIN points) and stops
with the exact meson command above if it is missing.
Run it
cargo xtask demo --headless
Expect output ending in xtask demo: PASS, plus paths to the run’s flight
recorder (flight.jsonl) and its captured frames.
What just happened
cargo xtask demo --headless
│
├─ boots vitrind --headless the trusted core, software-rendered
│ │
│ ├─ fork/execs vitrin-shim a real wlroots compositor, one per app,
│ │ │ with a scrubbed environment and its own
│ │ │ private runtime dir
│ │ └─ fork/execs weston-terminal
│ │ WAYLAND_DISPLAY points only at the shim's own socket, so
│ │ the app's entire universe is that shim
│ │
│ └─ listens on a Unix socket for agent principals
│
└─ runs examples/agent-demo/run_demo.py
connect → request_grant → await consent → settle → capture
→ click → type → capture → assert the typed text landed
The two captures are not compared naïvely. An earlier version of this gate
asked only for 24 changed pixels between them, which weston-terminal’s own
startup paint clears without any agent involvement — it passed whether or
not the click and keystrokes reached anything. It now settles the app,
watches it idle at least as long as it later polls, and demands a change
shaped like a typed line: enough changed pixels and a densely inked
run of them along one scanline.
That detail is in this getting-started page on purpose. It is the difference between a demo and a test.
Look at the evidence
The flight recorder journals every decision the core made:
# The path is printed at the end of the run.
jq -c 'select(.event | test("grant|consent|refus"))' /path/to/flight.jsonl
You will see the petition arrive, the consent decision resolve, and each actuation checked at the chokepoint — with the grant it was checked against.
The full integration suite
The demo is one test. To run every named milestone gate:
VITRIN_C_SHIM_BIN="$PWD/shim/build/vitrin-shim" bash tests/integration/run.sh
That drives the shipped vitrind binary against real applications —
weston-terminal, a GTK entry probe, and Firefox ESR — over a real socket.
tests/integration/README.md
maps each test to the milestone it closes, and is explicit about which tests
are component tests that close nothing.
Nested mode
Drop --headless and the core draws a real window on your own Wayland
session, with Firefox ESR in the realm:
cargo xtask demo
# If your Firefox is not at firefox-esr:
VITRIN_DEMO_FIREFOX=/usr/bin/firefox cargo xtask demo
This needs a running compositor (GNOME, Hyprland, …) and a browser installed. It is never a CI dependency — nested mode has no headless equivalent by design.
Nested mode is also the only way to experience the two properties the headless run can only simulate: clicking Allow on a consent prompt the core drew itself, and physically holding Escape to watch a live grant die mid-actuation.
If it fails
| Symptom | Cause |
|---|---|
vitrin-shim not found | The meson step above did not run, or VITRIN_C_SHIM_BIN points somewhere stale. |
| Hangs before any capture | weston-terminal is not installed, so the realm has nothing to draw. |
AuthFailed at connect | The demo identity is not in the principals.toml the core booted with. |
| Nested mode opens nothing | No Wayland session — check echo $WAYLAND_DISPLAY. |
vitrind exits at startup naming landlock as the mechanism it could not get | This kernel has no usable Landlock, which since P2.6.3 is a startup requirement rather than an optimisation. Check three things, in order: uname -r ≥ 5.13, zgrep CONFIG_SECURITY_LANDLOCK /proc/config.gz, and cat /sys/kernel/security/lsm for landlock — a kernel can carry the code and leave it out of lsm=. vitrind --print-isolation prints all three as landlock.abi=N. A fourth condition is a build one, not a host misconfiguration: the number must be at or above build.landlock_min_abi from vitrind --print-floor (6 here), and a working Landlock below it is refused as below-floor(abi=N,required=M) with a newer kernel as the only remedy. --landlock=off starts realms with no ruleset at all and is the wrong answer to a kernel that could be configured. This is not the row below: that one is about user namespaces and its remedy does nothing here — no sysctl makes a kernel report a Landlock ABI. The word the refusal prints (landlock vs namespaces) is the diagnosis. |
A realm’s log says WARNING: Glycin running without sandbox. | Expected, and it is a published cost rather than a bug. A Landlock domain denies every mount, so glycin cannot build the bwrap sandbox it decodes images in; a realm makes that refusal arrive early and legibly by refusing nested user namespaces (/proc/sys/user/max_user_namespaces = 0 inside the realm), so bwrap fails at unshare(CLONE_NEWUSER) with a message glycin recognises and glycin takes the no-sandbox fallback it already ships. The decode then runs inside your realm with no second boundary around it. Read landlock-breaks-nested-image-sandboxes on the limits page before deciding whether that is acceptable for what you are opening. |
A GTK app in a realm aborts on startup with Gtk:ERROR:…gtkiconhelper.c:495 and Loader process exited early with status '1' | This was the shipped behaviour until 2026-08-15 and should no longer happen; if it does, your glycin does not recognise the refusal above. The abort is glycin concluding its bwrap sandbox is available, spawning a loader that then dies, and GTK treating the failed icon load as fatal. glycin classifies the availability probe by matching its stderr against a list of namespace-refusal strings; check the realm’s log for what bwrap actually printed, and see landlock-breaks-nested-image-sandboxes on the limits page for the full measurement. Check whether this path applies to you at all with ldd /usr/lib/libgdk_pixbuf-2.0.so.0 | grep glycin and command -v bwrap. |
vitrind exits at startup naming an isolation mechanism it could not get | Your host does not let an unprivileged user namespace carry its capabilities, so the default confinement cannot be built and the core refuses rather than running unconfined. Run vitrind --print-isolation to see the same probe on its own. On the one machine where this was measured — a GitHub ubuntu-latest runner, kernel 6.17.0-1020-azure, 2026-08-14 — the knob was kernel.apparmor_restrict_unprivileged_userns, set to 1. That is one runner on one date, and that machine’s /etc/os-release was never opened, so do not read it as “Ubuntu does this”; the refusal names the knobs your machine answered with. Packaging so this is arranged for you is #286, and the limits page states the requirement and the bound on that measurement. There is now a profile in the tree for the AppArmor case — packaging/apparmor/vitrind — and since 2026-08-15 it has been loaded and measured, though on one kernel (6.17.0-1022-azure, not the one this row names above) and one CI image: with it installed mount.in_userns moves from restricted-by-policy(errno=13) to available, the realm spawn from refused-as-expected to ok, the real-app confinement gate passes in the same run, and removing the profile makes the spawn fail again. Nobody has loaded it on an installed Ubuntu system, no second AppArmor-carrying distribution has been measured, and nothing here installs it for you, so treat it as a result from one runner rather than as a fix for your machine. The install steps are in that file’s own header and are deliberately not repeated here: the header states them as byte-for-byte what the apparmor-profile CI job runs, and a copy on this page is a third recipe nobody has tested the moment either one is edited. That header also says what the profile grants, what it costs, and why copying the binaries rather than symlinking them is load-bearing; the limits page carries the numbers that run reported and the boundary they do not clear. If the mechanism it names is landlock, this is the wrong row — see the row above, whose remedy is a kernel build and not a sysctl, and which no AppArmor profile touches. |
Still stuck? Open an issue with the flight.jsonl attached; it is usually
the single most useful artifact.
Next: Your first agent.
Your first agent
The SDK is pure Python — standard library only, no dependencies, by decision
(D8). If you can run python3, you can drive a Vitrin core.
python -m pip install -e 'sdk/python[dev]'
Requires Python 3.11 or newer. The [dev] extra is only for the SDK’s own
test suite; the client itself pulls in nothing.
The whole thing
import vitrin_os
conn = vitrin_os.connect(
"/run/user/1000/vitrin-0/core.sock",
identity="vitrin://local/agent/demo",
)
grant = conn.request_grant(
realm="realm-0",
verbs=("observe", "actuate.pointer", "actuate.text"),
expiry_ms=300_000, # five minutes, enforced by the core
)
grant.await_consent() # blocks until a human decides
frame = grant.observe()
frame.to_png("before.png")
grant.pointer.click(640, 84)
grant.text.type("example.com\n")
grant.observe().to_png("after.png")
conn.close()
That is a complete agent. Everything below is what each line means and how it fails.
Connecting
conn = vitrin_os.connect(path, *, identity, credential_type="static-token",
credential="", timeout=None)
The handshake happens inside connect. It either returns a bound
connection or raises — and the socket is closed on every failure path, so
there is no half-open state to clean up.
from vitrin_os import AuthFailed, VersionUnsupported
try:
conn = vitrin_os.connect(sock, identity="vitrin://local/agent/demo", timeout=30)
except AuthFailed:
... # the core does not know this identity
except VersionUnsupported:
... # this SDK speaks a protocol version the core will not serve
Version 1 identities are static tokens listed in the core’s
principals.toml. The IDL is shaped for SPIFFE/OIDC credentials; that lands
later.
Petitioning for a grant
grant = conn.request_grant(
realm="realm-0",
resource=None, # None = the whole realm view
verbs=("observe", "actuate.pointer", "actuate.text"),
expiry_ms=300_000,
max_event_rate=0, # 0 = the core's default ceiling
persistence=vitrin_os.Persistence.ONCE,
)
One request co-mints the grant, a consent observer, and the facets you asked
for — grant.observe(), grant.pointer, grant.text. The facets are
born inert. They exist as objects immediately, and they confer nothing
until the grant resolves. There is no window in which you hold a usable
handle to an unapproved capability.
Verbs can be strings, or the Verb flag enum:
from vitrin_os import Verb
verbs = Verb.OBSERVE | Verb.ACTUATE_POINTER
The enum has nine members, and the petition above asked for three of
them: OBSERVE, ACTUATE_POINTER and ACTUATE_TEXT. Three more are
served by this core on the same terms — LAYOUT_ARRANGE, LAYOUT_FOCUS
and REALM_LAUNCH, exercised by grant.set_fullscreen(), grant.focus() and
grant.launch().
The remaining three — OBSERVE_CURSOR, DESIGNATE_FILE and EGRESS — are
defined and resolve
unsupported: the first because per-principal cursor delivery does not exist
yet, the second because no core-drawn file picker and no consent copy for it
exist yet, the third because the out-of-core proxy an outbound connection
would be
made through does not exist. DESIGNATE_FILE and EGRESS both have a facet
on the wire
(vitrin_powerbox and vitrin_egress), which is worth knowing only so it is
not mistaken for
evidence either verb works: a facet is the request you would ask through, and
there is nothing behind it to answer.
Whether a verb is served is a property of the deployment, not of the
protocol — a deployment that will not host process creation refuses
REALM_LAUNCH even though this one serves it — so read unsupported as “not
here, not now” rather than “not in this protocol”.
The SDK carries every defined bit either way, for a precise reason: an
out-of-range verb bit is a fatal invalid_argument that kills the
connection, so an SDK that omitted one would turn a recoverable “not yet”
into a dead socket.
Waiting for consent
grant.await_consent()
Blocks until the petition resolves. In nested mode that means a human looked at a prompt the core drew and clicked Allow. Your agent’s code is identical either way — which is the point. It cannot tell, and must not care, how the decision was reached.
Failure is typed:
from vitrin_os import (
Busy, ConsentTimeout, GrantDenied, GrantUnsupported, LayoutHeld,
RealmUnavailable,
)
try:
grant.await_consent()
except GrantDenied:
... # a human said no
except ConsentTimeout:
... # nobody answered
except RealmUnavailable:
... # no such realm, or it is not running
except GrantUnsupported:
... # this deployment does not serve a verb you named
except Busy:
... # another petition is already up
except LayoutHeld:
... # somebody else holds layout.arrange for this output
There is one class per non-granted outcome, and the list is exhaustive on
purpose: an outcome the SDK could not map would raise
ServerContractViolation and close the connection, which is the opposite
of what a recoverable answer is for.
After it returns, inspect what you actually got — the core may have attenuated your request:
print(grant.effective_verbs()) # may be narrower than you asked for
print(grant.effective_expiry_ms()) # may be shorter
Never assume the grant you hold is the grant you requested.
Observing
frame = grant.observe()
print(frame.width, frame.height, frame.format, frame.stride)
frame.to_png("shot.png")
raw = frame.raw # stride * height bytes, xrgb/argb8888
observe() is poll-model: you ask, you get the current frame. There is no
subscription and no push.
One race worth knowing: await_consent() can return before the app inside
the realm has painted anything. The honest reply then is NoSurface — and
it is judged before the rate-limit bucket, so retrying costs you no
budget:
from vitrin_os import NoSurface
import time
for _ in range(50):
try:
frame = grant.observe()
break
except NoSurface:
time.sleep(0.1)
Actuating
grant.pointer.move(x, y)
grant.pointer.button(vitrin_os.BTN_LEFT, vitrin_os.ButtonState.PRESSED)
grant.pointer.scroll(vitrin_os.Axis.VERTICAL, 120)
grant.pointer.click(x, y) # move + press + release
grant.text.type("héllo 世界\n") # a trailing newline presses Enter
Text goes in as text, not as scancodes — the core synthesises the keymap needed to deliver it, so non-ASCII works without your agent knowing anything about layouts.
For a batch, suppress the per-call flush and bound it with one barrier:
grant.pointer.move(10, 10, flush=False)
grant.pointer.move(20, 20, flush=False)
grant.text.type("hello", flush=False)
conn.sync(grant) # pass the grant so refusals raise here
conn.sync(grant) is a real barrier: it returns once every prior request
has been processed and its events delivered. Pass the grant and any refusal
in the batch surfaces as a typed exception at that point. Omit it and
refusals stay queued on the grant until its next barrier — which is a good
way to not notice that nothing you sent landed.
When actuation is refused
Every refusal is a distinct exception, because they mean genuinely different things:
from vitrin_os import (NotGranted, GrantExpired, Revoked, RateLimited,
Preempted, ConsentHeld, NoSurface, OperationFailed)
| Exception | Meaning | Retry? |
|---|---|---|
NotGranted | This verb is not in your grant. | No — fix the petition. |
GrantExpired | expiry_ms elapsed. | No — petition again. |
Revoked | Someone revoked it. Often the dead-man switch. | No. A human meant this. |
RateLimited | Over the event-rate ceiling. | Yes, after retry_after_ms. |
Preempted | A human touched the input device. | Yes, but back off — a person is using the machine. |
ConsentHeld | A consent prompt is up; actuation is frozen. | Yes, once it resolves. |
NoSurface | Nothing to act on yet. | Yes, cheaply. |
OperationFailed | The core tried and could not. | Maybe. |
There is a ninth, AtCapacity, and it is missing from the list above on
purpose: it is only ever produced by realm.launch, so an actuation can never
see it. If you hold a launch grant, it means the session is already running as
many realms as it will, and retrying is legal once one exits.
Revoked and Preempted are the two that matter for behaving well. Both
mean a human intervened, and an agent that hammers through them is exactly
the failure mode this project exists to make structurally impossible — so it
will not work, but writing it that way is still bad manners.
try:
grant.pointer.click(x, y)
except RateLimited as e:
time.sleep(e.retry_after_ms / 1000)
except (Revoked, Preempted):
return # a human took over. stop.
Fatal versus recoverable
The exception hierarchy encodes the protocol’s central razor:
GrantRefusedandGrantResolutionErrorare recoverable. Your request failed; the connection is fine.FatalErrormeans you violated the protocol. The core has closed the connection.InvalidObject,InvalidOpcode,InvalidArgument,Oversized,FdViolation,PreHandshake— all of these mean the bug is in your client, and no retry will help.
If you are catching FatalError and retrying, you have misread the model.
Where to go next
examples/agent-demo/run_demo.py is a real agent that does all of the
above, including the parts this page glossed — settling the app before
capture, locating a UI feature by pixels, and asserting the actuation
landed. It is also the M1.5 gate, so it is kept honest by CI.
Next: Grants, consent, and revocation.
Grants, consent, and revocation
This is the security model. If you read one chapter, read this one.
No ambient authority
The rule the whole design turns on: a connection confers nothing. Being connected, being authenticated, even being a highly trusted principal — none of it lets you observe or touch anything. Authority exists only as grants, and a grant is checked on every single action.
Compare what the alternatives do:
| Unit of authority | Who can revoke | Granularity | |
|---|---|---|---|
| X11 | The connection | Nobody, really | The whole session |
| Wayland | The connection | Nobody | Your own surfaces only |
| AT-SPI2 | Ambient — anyone on the bus | Nobody | Every widget of every app |
| VM-per-agent | The VM | Destroy the VM | One whole desktop |
| Vitrin | The grant | Anyone, immediately | Verb × resource × constraints |
What a grant is
A row in the core’s grant table:
(principal × resource × verbs × constraints)
- principal — who. Authenticated at handshake, never asserted by the requester afterwards.
- resource — what. A realm, or a specific surface within it.
- verbs — which actions.
observe,actuate.pointer,actuate.text, the twolayout.*verbs andrealm.launch(added at wire version 2) today; three more are defined and refuseunsupported—observe.cursor,designate.file(added at wire version 2, and unserved by every deployment until the core-drawn file picker and its consent copy exist), andegress(added at P2.7.2, whose facet has landed and whose mediating proxy has not). Defining a verb before serving it is deliberate: it makes asking for one a recoverable refusal instead of a fatal out-of-range bit. Which of the defined verbs a deployment actually serves is that deployment’s property, not the wire’s — a deployment that will not host process creation refusesrealm.launcheven though this one serves it. - constraints — under what limits: expiry, event-rate ceiling, focus conditions, persistence.
Three properties make it a capability rather than a permission bit:
Sender-constrained. The grant is bound to the connection that petitioned for it. Stealing the identifier gets you nothing; you would have to be that connection.
Attenuable. A grant can be narrowed — never widened — and handed on. An agent that needs a sub-agent to do one thing can pass a grant that permits exactly that thing and expires sooner.
Revocable, immediately and transitively. Revoking a grant kills everything attenuated from it, in the same operation. Not eventually. Not at the next check-in.
The lifecycle
request_grant()
│
▼
┌─────────┐ the facets exist here, and confer NOTHING
│ pending │ ← consent prompt is up; actuation refuses ConsentHeld
└─────────┘
│
├──── denied / timeout / busy ──→ raises, no authority ever existed
│
▼
┌──────────┐
│ resolved │ ← effective_verbs() may be NARROWER than requested
└──────────┘
│
├──── expiry_ms elapses ────────→ GrantExpired
├──── human holds Escape ───────→ Revoked (and everything attenuated)
├──── human touches the mouse ──→ Preempted
└──── over the rate ceiling ────→ RateLimited (retry_after_ms)
A grant resolves exactly once. There is no path back to pending, and no way to re-open a resolved grant into a wider one.
Consent the core draws itself
The prompt asking you to approve a petition is rendered by vitrind — the
process that owns the screen and the input devices. That is the whole
security argument, and it is worth being precise about why it works.
An application cannot draw a convincing fake, because:
- The core composites the prompt above every client surface. There is no z-order a client can request that goes higher.
- The core takes an exclusive input grab while it is up. Clicks land on the prompt, not on whatever is beneath it.
- Actuation on already-granted grants refuses
ConsentHeldwhile a prompt is up — so an agent cannot act during the window in which a human is being asked about it.
This has its own mock-free gate, tests/integration/test_real_consent.py,
and the gate is stricter than “a prompt appeared”. It proves the exported
footprint really is a card raster at exactly the rectangle the core named —
accent ring on all four edges, exact perimeter count, opaque body, buttons,
antialiased text — and then that it carries zero of the app’s pixels.
Separately, it proves the prompt does not leak into the capture path:
the realm-view dump taken mid-prompt is byte-identical to a settled control,
and the agent’s own observe() agrees with it.
Then it proves the freeze: a mid-prompt actuation on an already-granted
grant, on a second connection of the same principal, refuses ConsentHeld
specifically — and the journal shows that refusal falling strictly between
the prompt’s shown and its resolution.
The trusted indicator, and what it does not prove
The core paints a band it owns, in a colour randomised per session. A client cannot match a colour it cannot observe.
test_real_trust_band.py proves the negative rigorously: a real app repaints
its entire surface, band rows included, and the band’s rows still carry the
app’s colour in both capture artifacts rather than the indicator’s — with a
core-side witness reporting zero band changes across every composite it
evaluated, held up by counterweights so a witness wired only into the reply
path would fail. The harness never learns the indicator colour.
This is a proof that the band cannot be forged by a client. It is not a proof that a human notices when it is wrong. Those are different claims, and the second one needs user research this project has not done. The plan explicitly adjudicated unspoofability out of M1.4’s criteria for that reason — so do not cite the milestone as evidence for it.
Human override
Physical input preempts agent input by construction, not by a race. Input is origin-tagged at the core: the router knows which events came from a human device and which came from an agent’s actuation call, and the human wins because the code says so, not because it arrived first.
Preemption is per realm, and if you run several that matters: your hand on
the keyboard suspends agents acting on the realm you are in, not agents
working in the other fifteen. The dead-man switch below is deliberately the
opposite — it is session-wide, because an off-switch scoped to whatever you
happen to be looking at is not an off-switch. The narrowing of preempted is
published as a limit (limits).
…and for the two layout verbs only, you can suspend it yourself. Tap
Super and, for about a second, a client holding layout_focus or
layout_arrange is not refused preempted — once. That is not you granting
anything: the client could already do it, and what you withdrew was a courtesy
the core was extending to your own typing. It exists because otherwise a shell
running inside a realm can never switch realms — the Enter that sends the
request is the physical input that forbids it. The core eats that key
everywhere, no app ever sees it, and while the window is open a small marker
sits just below the trusted band. A focus change with no marker up was not
yours. The costs, including that either of two layout holders may take the
press, are in limits.
The dead-man switch
Hold Escape for one second. Every live grant is revoked.
The agent’s very next call — observe() or any actuation — raises
Revoked. Not “at its next poll”, not “within a few seconds”:
test_real_deadman.py asserts both refuse on the immediately following
check, that the real app’s target is left untouched (read via
--capture-dump, which bypasses the now-revoked grant entirely), and that
the flight recorder journals dead_man_triggered followed by
grant_revoked.
Headless has no physical key to hold, so that gate uses a signal to stand in
for the chord. The nested recipe for a genuinely held Escape is in
shim/docs/firefox.md
§9 — and it is worth doing once by hand, because watching an agent die
mid-keystroke is the moment the model stops being abstract.
The chokepoint
Every one of these checks happens in one place. There is no fast path, no cache that skips the grant table, and no module that can act without going through it. That is what makes the trusted core auditable: the interesting question is only ever “what does the chokepoint do”, never “which of forty call sites forgot to check”.
The flight recorder journals each decision, so a run is reconstructible after the fact:
jq -c 'select(.event | test("grant|consent|revok|refus"))' flight.jsonl
Next: Realms and shims.
Realms and shims
Grants control what a principal may do. Realms control what an application can see. The two are independent, and the second one is structural.
The idea
A legacy application never talks to the trusted core. It talks to its own private Wayland compositor — a shim — which is itself an unprivileged client of the core.
┌──────────────────────────────────────────┐
│ vitrind — the trusted core │
│ capability kernel · grant store │
│ compositor · input router · consent │
└──────────────────────────────────────────┘
▲ ▲
frames up │ input down frames up │ input down
(dmabuf/ │ (origin- (dmabuf/ │ (origin-
shm fd) │ tagged) shm fd) │ tagged)
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ realm-0 │ │ realm-1 │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ vitrin- │ │ │ │ vitrin- │ │
│ │ shim │ │ │ │ shim │ │
│ └───────────┘ │ │ └───────────┘ │
│ ▲ │ │ ▲ │
│ WAYLAND_DISPLAY│ │ WAYLAND_DISPLAY│
│ │ │ │ │ │
│ ┌───────────┐ │ │ ┌───────────┐ │
│ │ Firefox │ │ │ │ a terminal│ │
│ └───────────┘ │ │ └───────────┘ │
└─────────────────┘ └─────────────────┘
Firefox in realm-0 cannot enumerate the terminal in realm-1, cannot see
its surfaces, and cannot receive its input — not because a policy forbids
it, but because its entire Wayland universe is a compositor that contains
only itself. There is nothing to enumerate. Scoping is structural.
This is the gamescope and Qubes precedent, applied per-application rather than per-session.
Why the shim is a separate process in C
The shim is a real wlroots compositor, written in C, built with Meson, deliberately outside the Cargo workspace. That looks like an odd choice in a Rust project until you see what it buys:
Legacy complexity is exiled from the TCB. Serving the full Wayland
protocol surface — xdg-shell, subsurfaces, buffer management, all the
quirks real toolkits rely on — is a large, messy job. None of it belongs in
a process that also holds the grant table. The shim absorbs that
complexity while being untrusted: the core assumes nothing about its
behaviour.
It is disposable. One shim per application. It crashes, that app dies, and nothing else notices.
Patching it costs you nothing. The trademark policy makes this explicit:
modifying the trusted core means renaming your build, but shim/ sits
outside the TCB, so patching a shim or writing a whole new one does not
change what the core enforces and does not cost you the name.
What confines a realm today
Be precise here, because the honest answer is short.
When the core launches a realm’s app it:
- forks a per-app shim and hands it one end of a socketpair as its identity — no credential, no handshake; holding the descriptor is being that realm’s shim;
- gives it a private
0700runtime directory; - builds its environment from nothing — only names the operator
allow-listed in
realm.toml, plus aWAYLAND_DISPLAYpointing at that realm’s own socket.DISPLAY, the hostWAYLAND_DISPLAY,WAYLAND_SOCKET,XAUTHORITYand the hostXDG_RUNTIME_DIRcannot reach the app; - lets no unrelated descriptor cross the fork — not the agent listener,
not the flight-recorder log, not other realms’ sockets, not capture
memfds — via a
close_rangesweep betweenforkandexecve; - resets signal dispositions, so the child does not inherit whatever the operator’s shell was ignoring.
Those last two are enforced by the fork itself rather than by every other
module remembering to be careful. The full path is documented in
crates/vitrin-core/src/spawn.rs.
That is the complete list. Read the next section before drawing conclusions from it.
One prerequisite, because it decides whether any of the above happens at all:
the namespace set is built from an unprivileged CLONE_NEWUSER, so the
host has to let such a namespace carry its capabilities. Where a host permits
the unshare and then strips them, vitrind --isolation=default refuses to
start rather than running a weaker session — see
the limits page for the requirement, the one measurement behind
it, and what is tracked to make the grant routine.
Since P2.6.3 there is a second such prerequisite, and it is a different
condition with a different remedy: the kernel must actually have Landlock —
≥ 5.13, built with CONFIG_SECURITY_LANDLOCK=y, and with landlock in the
active LSM list (/sys/kernel/security/lsm) — and, since 2026-08-15, an ABI
at or above this build’s declared floor (build.landlock_min_abi from
vitrind --print-floor, 6 here). The ruleset below is part of the
confinement floor, so without all four the core refuses to start rather than
confining a realm one mechanism less than its own journal claims. The fourth is
the one a correctly configured kernel can still fail, and its remedy is a newer
kernel rather than any knob. The refusal
names the mechanism it could not get — namespaces for the paragraph above,
landlock for this one — and that word is the diagnosis: the two remedies do
not substitute for each other. vitrind --print-isolation answers both,
without spawning anything.
What does not confine a realm
The sandbox is half-built. Decisions D9, D-020, D-036. The shim and its app run in six namespaces with an identity uid/gid map, zero capabilities, a private mount table and — since P2.6.3 — a Landlock ruleset enforced before the shim’s
execve, whose read set is enumerated rather than granted at the realm root and whose write set reaches eight hierarchies (the four writable mounts in full, plusWRITE_FILEalone on/proc,/dev,/dev/ptsand each render node — eight with one render node bound, one more for each additional one). What that ruleset requires of a kernel is published as a generated, CI-held table — the Landlock ABI matrix — and P2.6.3 was accepted on 2026-08-19 on corrected criteria rather than the ones it was written with, so read that narrowly: that table measures no kernel, the per-kernel one its criteria ask for exists on a page of its own — which kernels this build starts on, five distribution kernels booted under QEMU with the shippedvitrind, two admitted and three refusedbelow-floor— but every row there is a kernel reading taken in a bare initramfs and not a statement about the distribution that ships that kernel, so the number of distributions measured as such is still one, and the ABI floor narrowed the task rather than closing it. Since P2.6.4 there is also a seccomp deny-list, installed immediately before the shim’sexecveand inherited by every process the shim forks: it closes the 13 rowsvitrind --print-seccompprints, each naming the escape class it answers and the errno it returns, and leaves the rest of the kernel’s syscall surface unenumerated. So the realm is filesystem-confined and filtered against a named list and not syscall-confined. At--isolation=offnone of it applies and the paragraph below holds in full;--landlock=offturns off the ruleset alone, and both say so in every journal entry. There is no--seccomp=off: a kernel that cannot accept a filter refuses the session instead of running one unfiltered.
An application that ignores WAYLAND_DISPLAY and connects directly to a
path it already knows is not stopped by anything in this MVP.
And an app’s own sandbox no longer confines anything here. A Landlock
domain denies every mount-topology change to a realm’s app and its
descendants, unconditionally — mounting is not an access right, so no rule
grants it and widening the ruleset cannot restore it. A nested sandbox
therefore cannot be built inside a realm, and an app that decodes images in
one (GTK → glycin → bwrap) decodes them unsandboxed instead. A realm
additionally refuses nested user namespaces outright, which takes no
capability away — a namespace that cannot mount was already useless — and
turns that into the conventional refusal such libraries already handle rather
than an unexpected mount(2) failure. The measurement, and what it costs, are
on the limits page.
Two further specifics worth naming rather than leaving to be discovered:
The session D-Bus is reachable at --isolation=off, and closed twice over at
--isolation=default. The core advertises no DBUS_SESSION_BUS_ADDRESS and
redirects XDG_RUNTIME_DIR either way, so a well-behaved client finds no bus —
but advertisement is not reachability, and at off that is the whole of it:
/run/user/<uid>/bus is still on the filesystem, still connectable by any
process of that uid, and the abstract-socket namespace is still shared. Since
P2.6.2 the default closes both halves, and neither closure is this project’s
cleverness: the mount namespace removes /run/user/<uid>/bus as a path — the
realm’s /run holds one entry, vitrin — and the network namespace removes the
abstract-socket namespace the bus also listens on, because abstract sockets are
scoped to a network namespace. So an operator who allow-lists
DBUS_SESSION_BUS_ADDRESS in realm.toml at off turns an implicit hole into
an audited one, and the same line at default names something that is not
there. That closure is not the same claim as a measurement, though, and the
distinction is worth its own sentence: it is derived from the mount table
rather than measured — no test asserts the absence of /run/user, and
tests/integration/test_real_confinement.py lists “that a realm cannot reach
the session bus by other means” among the things it explicitly does not
prove. The adversarial probe that would attempt org.a11y.Bus activation on
every bus reachable from inside a realm has still not been written. Two
residuals survive that, and both are narrower than what closed:
binds names any absolute path outside / and /home, so an operator who
binds the host’s runtime directory into a realm puts the bus socket back inside
it under a key that says nothing about buses; and the designated-egress half
of the network answer — reachability as a granted, host:port-scoped capability
rather than as nothing at all — is still P13’s, unbuilt.
Same-uid separation is not attempted. The 0700 runtime directory bounds
other users on the machine, not other processes of this user, and the app runs
as the core’s uid in either isolation mode. What the realm’s XDG_RUNTIME_DIR
names stopped being the same thing at P2.6.2, though. At --isolation=off it
is $XDG_RUNTIME_DIR/vitrin-0/<realm>, one level below the directory holding the
core’s own agent socket and the run’s flight-recorder log — it names the control
plane as much as it hides it, and relocating the tree would not help, since a
child of the core’s uid derives /run/user/<uid> from getuid() with or without
a variable pointing at it. At --isolation=default the value is the fixed
in-realm /run/vitrin, a bind of that same core-created directory, and ..
resolves to the realm’s own /run, where there is no core.sock and no recorder
log. The closure is the mount namespace’s rather than the path’s, and it is
checked rather than argued: both are canaries every confined spawn probes through
/proc/<shim>/root.
Environment hygiene confines the well-behaved. It does not contain the hostile. Real sandboxing arrives with the Phase-2 powerbox (E2.6/E2.7).
Configuring a realm
realm.toml names what a realm runs and what environment names may reach
it:
[[realm]]
id = "realm-0"
command = "/usr/bin/firefox-esr"
args = ["--no-remote", "--new-window", "about:blank"]
env_allow = [
"HOME", "LANG", "XDG_SESSION_TYPE",
"MOZ_ENABLE_WAYLAND", "GDK_BACKEND",
"DBUS_SESSION_BUS_ADDRESS", # see above -- an audited hole at --isolation=off
]
env_allow is an allow-list of names, and values are copied from
vitrind’s own environment. That is the only route by which a realm’s
environment grows. examples/realm.toml
carries the security rules inline.
A second realm is a second [[realm]] table, up to 16, and one of them
must be realm-0 — the one realm name a client can know without being told,
since there is still no way to enumerate realms on the wire. Each gets its
own shim, its own private runtime tree and its own socket, exactly as the
diagram above shows. Ids are otherwise free-form, with one refusal worth
knowing: a realm’s lock file sits beside its directory as <id>.lock, so
a realm named foo.lock would collide with realm foo, and startup refuses
that naming both.
What a second realm does get is its own scene and its own capture: an
observe grant returns the pixels of the realm it names, hidden or not, and
a grant over a realm whose app has died refuses no_surface regardless of
what its siblings are doing. What it does not get is its own output:
the core composites one output from one realm’s scene, so with several
realms running only the realm the output is bound to is visible — the first
one to attach. Which realm that is is now somebody’s to choose: a client
holding the layout.focus verb moves the output, and the human’s own keyboard
and pointer move with it — one act, because showing a realm and typing into it
must never come apart. Absent such a client the binding moves on the one event
nobody chooses: the bound realm’s app exiting, after which the output follows to
the first realm still serving, and to no realm at all once none is. Every other
realm still
renders, and pays for it. Read Known limits before configuring
more than one.
--no-remote in that example is load-bearing, not hygiene: without it, a
firefox --new-window on a machine already running Firefox hands the window
to the existing instance over its remoting protocol — never touching the
confined process at all, silently defeating the entire arrangement.
The buffer path
Frames move shim→core as file descriptors over SCM_RIGHTS. Two paths:
- shm — universal, always available, one copy. CI runs entirely on it.
- dmabuf — zero-copy on a real GPU. Version 0 imports exactly
xrgb8888/argb8888with the linear modifier implied; the allow-list is checked before any driver call. Failure produces an explicitbuffer_done(import_failed)telling the shim to fall back to shm — never a silent black frame.
MVP success does not depend on zero-copy working, which is why CI can stay GPU-free.
Next: The wire protocol.
The wire protocol
You need this chapter if you are writing a client in a language the project does not ship an SDK for, or debugging one that exists.
The IDL is the source of truth.
protocol/vitrin-v0.xml
defines every interface, and where this book and an IDL <description>
disagree, the IDL wins.
docs/protocol/00-conventions.md
is the normative conventions page this chapter summarises.
Shape
Wayland-influenced and deliberately so: object-oriented, per-connection
object ids, requests and events over a Unix socket, file descriptors passed
by SCM_RIGHTS. If you have written a Wayland client, this will feel
familiar.
What is different is that authority is a first-class object. vitrin_grant
is on the wire, with a lifecycle you can observe.
Framing
One message, one frame. All multi-byte integers little-endian. Every frame opens with an 8-byte header:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+---------------------------------------------------------------+
| object_id (u32) |
+-------------------------------+---------------+---------------+
| size (u16) | opcode (u8) | fd_count (u8) |
+-------------------------------+---------------+---------------+
| argument payload |
| Field | Notes |
|---|---|
object_id | The target. Per-connection; referencing an unknown id is fatal invalid_object. |
size | The whole frame including this 8-byte header. Below 8, or a payload shorter than declared, is fatal oversized. The u16 ceiling of 65535 binds senders: do not construct a larger frame. |
opcode | The request or event opcode within the target’s interface, at the negotiated version. |
fd_count | How many descriptors accompany this frame. |
fd_count living in the header is a deliberate framing invariant, not an
accident of the current signatures: a receiver can drop a frame it cannot
interpret and still consume exactly the right number of descriptors, so an
unknown message never desynchronises the fd stream. If the header’s
fd_count disagrees with the target message’s signature, that is fatal.
At most one fd per message. Nothing in v0 needs two.
Argument types
| Type | Encoding |
|---|---|
int | signed 32-bit LE |
uint | unsigned 32-bit LE |
object | u32 object id — may be 0 only where the IDL declares allow-null |
new_id | u32 id the sender allocates for the object being created |
string | length-prefixed, see the conventions page for padding |
fd | not in the payload — carried out-of-band, counted by fd_count |
Two connection classes, one wire format
| Class | Reaches the core by | Interfaces |
|---|---|---|
| Agent principal | Connecting to the core’s listening socket and authenticating | vitrin_handshake, vitrin_principal, vitrin_realm, vitrin_grant, vitrin_consent, vitrin_view, vitrin_actuator_*, vitrin_launcher, vitrin_layout_*, vitrin_egress |
| Shim | Inheriting a socketpair from the core across fork/exec | vitrin_shim_session, vitrin_shim_surface, vitrin_shim_seat |
The classes are mutually unreachable. A message using the other class’s
opcodes dies as fatal invalid_opcode, with no special-casing anywhere.
Note what a shim’s identity is: holding the inherited descriptor. There is no shim credential and no shim handshake — the socketpair is the authentication, because the core created both ends itself.
The interfaces
| Interface | Purpose |
|---|---|
vitrin_handshake | Version + identity hello, resolving to a bound principal |
vitrin_principal | The authenticated principal — root of the connection’s authority chain |
vitrin_realm | Realm address; an authority-free scope handle (realm-0 is the well-known realm and is always served; a deployment may configure more) |
vitrin_grant | Capability handle — the wire projection of one grant-table row |
vitrin_consent | Consent-prompt visibility for one petition (events only, no authority) |
vitrin_view | Observation facet — poll-model frame capture |
vitrin_actuator_pointer | Pointer actuation facet |
vitrin_actuator_text | Text actuation facet |
vitrin_shim_session | Shim connection bootstrap |
vitrin_shim_surface | Shim-to-core buffer path |
vitrin_shim_seat | Input delivery to the shim (events only, origin-tagged) |
vitrin_launcher | Realm-launch facet (since wire version 2) — fork a new realm instance from an operator-written template, under a core-minted id; launch carries no arguments, so the command never crosses the wire |
vitrin_layout_focus | Focus facet (since wire version 2) — bind the output to the granted realm and send the human’s own input there, one act |
vitrin_layout_arrange | Arrangement facet (since wire version 2) — fill the output, or compose at the app’s own size; place, resize, raise and stacking are absent rather than refused |
vitrin_powerbox | Designation facet (since wire version 2) — ask the human to pick one file or one directory subtree and have the descriptor delivered to the realm; no path crosses the wire in either direction. Vocabulary only so far: no deployment serves the verb, so vitrind mints the facet (issue #322) and refuses every ask not_granted until the picker lands |
vitrin_egress | Egress facet (since wire version 2) — one outbound connection to the single host:port the grant names, handed back as a socket fd. No deployment serves the egress verb: vitrind mints the facet and refuses every request_connect not_granted (issue #322), because the out-of-core mediating proxy does not exist |
Each has a prose page under
docs/protocol/.
The petition, in one request
A grant petition co-mints, in a single request: the grant, a consent
observer, and the facets (view, pointer, text). The client allocates a
new_id for each.
The facets are born inert. They are real objects from the moment the request is sent, and they confer nothing until the grant resolves. That shape is why there is no window in which a client holds a live handle to an unapproved capability — the alternative, minting facets after approval, would need a second round trip and a state machine on both sides.
client core
│ request_grant(new_id grant, │
│ new_id consent, │
│ new_id view, ptr, text) │
├────────────────────────────────────────────>│
│ │ petition registered,
│ consent.state(shown) │ prompt raised
│<────────────────────────────────────────────┤
│ │
│ grant.resolved(outcome, verbs, │ human decides
│ expiry_ms, ...) │
│<────────────────────────────────────────────┤
│ │
│ view.capture_frame() │
├────────────────────────────────────────────>│
│ view.frame_ready(+ memfd) │
│<────────────────────────────────────────────┤
resolved carries the effective verbs and expiry, which may be narrower
than requested. A client that assumes it got what it asked for is wrong.
The error razor
The single most important thing to get right.
Fatal — you violated the protocol. The core closes the connection. No retry helps, because the bug is in your client.
invalid_object · invalid_opcode · invalid_argument · oversized ·
fd_violation · pre_handshake · version_unsupported · auth_failed ·
internal · resource_exhausted
Recoverable — your request failed. The connection is healthy and you may try something else.
not_granted · expired · revoked · rate_limited · preempted ·
consent_held · no_surface · internal · capacity
Those are the IDL’s own spellings, which is what a client in another language
has to match. The Python SDK’s exception names are deliberately not all
identical to them — expired is GrantExpired, internal (7, recoverable) is
OperationFailed, capacity is AtCapacity, and the fatal internal (8) is
InternalError — so transcribe from the IDL, never from an SDK.
The line is: did the client send something incoherent, or did a coherent
request get refused? Setting an out-of-range verb bit is incoherent —
fatal. Asking for a verb the core does not serve is coherent — refused,
unsupported. This is exactly why the Python SDK carries every defined verb
bit, served or not, and every defined outcome and refusal code: one it did not
know would turn a recoverable answer into a dead socket.
Ordering
Requests on one connection are processed in order, and events for one object
arrive in order. sync is a barrier: it returns once all prior requests are
processed and their events delivered — petition resolution excepted, since
that waits on a human.
Versioning
Wayland-style. Interfaces grow by appending messages and appending enum values; existing opcodes never change meaning. The negotiated version comes out of the handshake, and each side serves only what that version defines.
Version 0 is frozen for Phase 1 — not forever. The wire integer is now 2, and it appends:
- the
realm_launchverb, andvitrin_principal’sattentionevent; - five structural mints on
vitrin_grant—get_launcher,get_layout_focus,get_layout_arrange,get_powerbox,get_egress; - the five interfaces they mint —
vitrin_launcher(launch/launched),vitrin_layout_focus(focus),vitrin_layout_arrange(set_fullscreen),vitrin_powerbox(request_file,request_dir,designated,refused),vitrin_egress(request_connect,connected,connect_failed) — and thevitrin_layout_arrange.mode,vitrin_powerbox.mode,.kindand.refusal, andvitrin_egress.failureenums; - the
designate_fileverb, thefile:/dir:resource prefixes, andvitrin_shim_session.designation— the powerbox vocabulary, refusedunsupportedby every deployment until the core-drawn picker and its consent copy exist. This core dispatches the messages as of issue #322 —get_powerboxmints, and both requests on the facet it mints refusenot_grantedrecoverably. Until then it had no arm for any of the three, so sending one was fatalinvalid_opcoderather than a mint or a refusal; - the
egressverb (128, at P2.7.2) and thenet:HOST:PORTvalue its authority is named with. That half landed as a verb bit and aresourcegrammar, and no message at all; its facet followed separately. Every deployment still refuses the verbunsupported, because the out-of-core proxy a connection would be made through does not exist — a facet is a request to ask through, not a mechanism to answer with.get_egressandvitrin_egress.request_connectwere unhandled on the same terms, and issue #322 closed both halves at once: the five requests this core did not dispatch were the two mints and the three facet requests behind them — not the two mints alone, which is what this list said while the two facets were landing on parallel branches, and the count is why one fix had to cover all five; - the
capacityrefusal code and thelayout_heldoutcome; - on
vitrin_shim_session, the cross-realm clipboard, the pointer constraints and the idle inhibit (request_selection,offer_selection,pointer_constraint_state,selection,pointer_constraint,idle_inhibit), and onvitrin_shim_seat,relative_motionand the four gesture events — plus the enums their arguments carry.
It changes nothing else: every version-1 signature is byte-identical at
version 2. The complete, normative enumeration — every message, and every
enum with a count to check the list against — is
00-conventions.md §7.3,
and this list restates it. Phase 2 brings semantic trees and epoch/CAS action
semantics. Expect to move.
Validating your understanding
The IDL is machine-checked and so is the code generated from it:
xmllint --noout --relaxng protocol/vitrin-v0.rng protocol/vitrin-v0.xml
cargo xtask codegen --check # generated Rust + C header match the IDL
crates/vitrin-golden holds golden frame vectors, and the Python SDK’s
tests/test_golden_vectors.py checks its encoder against the same bytes.
Those vectors are the cheapest way to validate a new client’s codec.
Next: Build your own client or shim.
Build your own client or shim
Three things you might build, in increasing order of effort.
Licensing, up front, because it decides what you may do: the protocol,
everything generated from it, the code generator, the conformance
instruments and the SDKs are Apache-2.0, patent grant included. You
never have to touch a copyleft file to write a client, an alternate
compositor, or an integration. The MPL-2.0 copyleft binds one group only:
people modifying the trusted core itself.
NOTICE is the
normative path→license map.
An SDK in another language
The codegen already emits Rust and a C header from the same IDL. A third language is a fork of that path, not a from-scratch effort.
Start here:
crates/vitrin-scanner/ the generator: IDL XML -> Rust + C header
crates/xtask/ its driver (cargo xtask codegen)
crates/vitrin-golden/ golden frame vectors
sdk/python/ a complete client, stdlib-only, ~2k lines
crates/vitrin-scanner is Apache-2.0 precisely so that a third party
writing a Go, TypeScript or C++ SDK forks it.
The order that works:
- Codec first, against the golden vectors. Encode and decode every
message, and check your bytes against
crates/vitrin-golden. Do not move on until they match exactly — every later bug looks like a codec bug anyway. - Handshake. Version and identity hello, resolving to a bound
principal. Get
version_unsupportedandauth_failedright; they are the first two errors you will actually hit. - Petition and resolution. One request minting grant + consent + facets,
then blocking on
resolved. Read the effective verbs it returns rather than the ones you asked for. - Observe. Receive a memfd over
SCM_RIGHTS, honourstride(never assumewidth * 4— v0 pins it, later versions need not), and map the format. - Actuate. Pointer and text.
Three things to get right, because they are what a naïve port breaks:
- Carry every defined verb, whether or not this core serves it
(
observe,actuate.pointer,actuate.text,observe.cursor,layout.arrange,layout.focus,designate.file,egress,realm.launch). An out-of-range verb bit is fatalinvalid_argumentand kills the connection. Omitting one turns a recoverableunsupportedrefusal into a dead socket for any user who petitions it. Which of them a deployment serves is that deployment’s business and can change under you — this core refusesobserve.cursor,designate.fileandegress, and serves the other six (egressbecause no mediating proxy exists, not because its facet is missing —vitrin_egressis on the wire, exactly asvitrin_powerboxis fordesignate.file) — so never bake the served set into a client. Transcribe the values from the IDL rather than assuming consecutive bits —realm.launchis 512, because 256 is allocated to a verb the IDL does not define yet and is still out of range; 64 was in that gap untildesignate.filelanded on it, and 128 untilegressdid at P2.7.2 — so the set that sentence names is down to one. The Python SDK’stest_verb_parity.pypins this against the IDL; write the equivalent. - Model fatal versus recoverable in your type system. If your users can catch a fatal error and retry, your API is lying to them. The Python SDK makes them separate hierarchies for this reason.
- Read
fd_countfrom the header, always. It is what lets you skip a frame you do not understand without desynchronising the descriptor stream. A client that infers fd counts from opcodes will corrupt itself the first time it meets a message from a newer version.
An alternate shim
A shim is an unprivileged Wayland (or X11) compositor that forwards frames up to the core and replays origin-tagged input down into its app. The core assumes nothing about its behaviour, which is what makes writing a new one reasonable.
shim/ is the reference: wlroots, C, Meson, outside the Cargo workspace.
shim/include/vitrin-protocol.h is generated from the IDL and is
Apache-2.0 despite living under shim/ — writing a C client must never
require touching copyleft code.
The contract:
- Your identity is the socketpair you inherited. There is no handshake and no credential — holding the descriptor is being that realm’s shim.
- Forward composited frames via
vitrin_shim_surface, as shm or dmabuf. If you offer dmabuf, handlebuffer_done(import_failed)by falling back to shm. Do not paint a black frame. - Receive input on
vitrin_shim_seat— events only, origin-tagged — and replay it into your app through your ownwl_seat. - Serve your app whatever protocol surface it needs. That is your problem, not the core’s, and it is the whole reason this is a separate process.
Text actuation avoids text-input-v3 deliberately (decision D7) and
synthesises a keymap instead; shim/docs/ covers why, and it matters if
your shim serves toolkits with their own IME assumptions.
A patched or replacement shim does not cost you the name. The trademark
policy draws its line at the trusted core, and shim/ is outside the TCB by
design.
An alternate core
The hard one, and the one with real obligations.
crates/vitrin-core and crates/vitrin-ipc are MPL-2.0. The copyleft
is deliberate: the project’s claim is a small, auditable trusted core, and a
modified capability kernel should not be shippable as a black box. MPL is
per-file, so this does not reach applications running under it, and MPL
§3.3’s Larger Work allowance keeps linking against MIT-licensed wlroots and
Smithay clean.
If your change alters what the core enforces — the chokepoint, the grant
lifecycle, the consent surface, the dead-man switch, input origin tagging —
then TRADEMARK.md
asks you to rename or ask first. The reasoning is Firefox/Iceweasel: the
name is what tells someone which build actually enforces the security
claims, and a rename is a remedy rather than a punishment. The default
answer to asking is yes.
Conformance instruments, all Apache-2.0:
| Tool | What it checks |
|---|---|
crates/vitrin-golden | Per-pixel + SSIM frame comparison |
crates/vitrin-mock-shim | A controllable synthetic shim peer — component tests only, never milestone evidence |
fuzz/ | cargo-fuzz targets for protocol decode and vitrin-ipc framing, with a checked-in corpus |
tests/integration/ | Drives the shipped binary against real apps over a real socket |
shim/wlcs/ | Advisory WLCS conformance. GPL-3.0-only — never built by default, never linked into vitrin-shim |
That last row is the one licensing trap in the tree: shim/wlcs/ compiles
MPL-2.0 shim sources into a GPL-3.0-only module, which is lawful only
because MPL keeps GPL-3.0 as a Secondary License. Never add MPL Exhibit B
anywhere in the tree — it would switch that off and make the module
undistributable.
Getting it reviewed
The project would rather have a second implementation than a perfect first one — a protocol with one implementation is a format, not a standard. Open an issue describing what you are building. Two specific asks:
- Report IDL ambiguities as bugs. If you had to guess, the
<description>is underspecified and that is a defect worth fixing while v0 is young. - Say plainly what you have not tested. The project’s own docs are written to that standard, and it is the most useful thing a second implementer can offer.
Getting out of a wedged session
vitrind --drm takes DRM master and the seat. When it is running, it is the
display: nothing else can put a pixel on the panel, and the kernel has stopped
handling the chord you would normally use to leave. So the ordinary question a
display server never has to answer becomes the first one this one must — how
do you get out when it stops responding?
This page is the answer, written for the one machine this project is developed
on, because a generic recovery page is one you have to translate at the worst
possible moment. It is a companion to
docs/drm-bringup.md,
which is the bring-up procedure; that page’s step 0 is where the escape
route is proven before you start, and this page is what you read when you
need it. Where the two overlap, step 0 is the source and this page links to it
rather than restating it.
The honest frame, first. Every route below needs something to still be working: a keyboard, or a shell, or the ability to power-cycle and boot a USB stick. There is no route that works when all of those are gone. The project declined to run an SSH server on this machine (D-031, the first of two entries with that number), which is the one mitigation that would have been independent of the display, the seat and the local keyboard. That trade was made knowingly and its cost is exactly this paragraph.
Verified vs inferred
Same convention as the bring-up page, for the same reason — a recovery page that reads as tested when it is not is worse than one that admits it:
- [verified] — read from this machine on 2026-08-10, read-only (no VT
switched, no
vitrindstarted, no destructive SysRq letter executed, no power state touched), or observed on hardware during the 2026-08-11 run recorded at the bottom of this page. - [inferred] — from the kernel’s own source or documentation, or from a configuration that is in place but whose behaviour was not exercised here.
Route 2 is the only route that has ever recovered a wedged session — on
2026-08-09, and again on 2026-08-13. Both times the command this page published
was wrong for the wedge in front of it, in two different ways, so read the
route itself before you rely on it: the pkill -f form was broken three ways
(#260) and the kill -TERM
that replaced it is inert against a stopped process
(#277). What actually
recovered the 2026-08-13 wedge was kill -CONT, and it recovered it in the
next logged millisecond.
Route 1’s chord is confirmed to work from a healthy session (10 of 10 on
2026-08-11, L1 below; 5 more on 2026-08-09). That is a different claim from it
gets you out of a wedge, and 2026-08-13 sharpened how it fails: against a
SIGSTOPped session the chord is not refused and not lost — it is queued,
and it completes the instant the session resumes. For 163.8 s the panel showed
the previous VT while the kernel already considered the target VT active.
Route 3 is documented and unexecuted and route 4 has never been used. Treat both as careful predictions.
Which route, by symptom
Work down. Do not skip to a later route because an earlier one feels slow — the later ones cost you more, and the last one costs you the machine’s uptime.
| What you are looking at | Route |
|---|---|
| Panel wrong or frozen, keyboard works | 1 — Ctrl-Alt-F<n> |
| Panel dark or wrong, you have a shell somewhere (another VT, or the Hyprland session on tty1) | 2 — a shell and a signal |
vitrind will not die, or it died and the machine is still stuck; you have a shell with sudo | 3 — SysRq through /proc/sysrq-trigger |
| Nothing responds at all | 4 — power cycle, then the installer USB |
A wedged session does not have to look dark, and on 2026-08-13 it did not.
The operator pressed Ctrl-Alt-F3 to return to a SIGSTOPped session and the
panel kept showing the previous VT’s last console content — tty2’s shell,
sitting there apparently fine. The kernel had already made tty3 active
(/sys/class/tty/tty0/active read tty3 for the whole wedge), but the stopped
compositor never acknowledged the acquire and never set a mode, so the
framebuffer simply retained what was last scanned out.
To a human that reads as “the VT switch did nothing”, which is the wrong
diagnosis and points at the wrong route. Check /sys/class/tty/tty0/active
before believing the screen: if it names the VT you asked for and you are not
looking at it, the session on that VT is wedged, not the switch.
The switch was never refused, either. It sat pending for the whole 163.8 s
and completed in the same millisecond the process resumed — the first line
logged after SIGCONT was the seat activated this session; reclaiming the panel. Route 1’s chord had been accepted all along and was queued behind the
wedge.
Route 1 — Ctrl-Alt-F<n>, which this core implements itself
Press Ctrl-Alt-F1 to get back to the Hyprland session on tty1, or
Ctrl-Alt-F2…F12 for another terminal.
This works only because the core implements it. Once a process holds DRM master and the VT is in graphics mode the kernel stops handling that chord, so a compositor either implements it or abolishes it — there is no third option, and the first bare-metal run of this backend proved it the hard way by not implementing it and trapping the maintainer on tty3 (D-031, the second entry with that number).
Four things worth knowing before you rely on it:
- It works while the screen is locked and while a consent prompt is up. Deliberate: being trapped is worst in the state where you cannot dismiss what is in front of you. It is never a way past the lock — the lock is still up, and still wants your passphrase, when you come back.
- Know your own VT number before you start. The startup banner logs it. A human who can leave and cannot come back is only half rescued.
- If the switch fails you will see it on the panel, in a red band naming
what happened —
crate::notice::CoreNotice, added precisely because a log line is worth nothing to somebody who cannot leave the screen to read it. The flight recorder carriesvt_switch_requested,vt_switch_refusedandvt_switch_stalled. [verified:crates/vitrin-core/src/recorder.rs] - If that red band appears, this route is gone. Go to route 2.
Confirmed working; not confirmed as an escape. All 19 chords on record — 10
in L1 below, 9 in the bring-up page’s item 12 — were pressed against a healthy
compositor, and every one behaved. The one deliberate wedge on record, L6, is
exactly the case this route exists for, and the chord did not get the operator
out of it: a SIGSTOPed compositor cannot call Session::change_vt, because
the code that would call it is inside the stopped process. That is the boundary
of what a compositor-implemented chord can do rather than a defect in it, and it
is why route 2 is below this one on the page and still ahead of it in evidence.
Route 2 — a shell somewhere else, and a signal
This is the only route that has ever actually recovered a session. On 2026-08-09 the first bare-metal run wedged with no working VT chord, and what freed it was a terminal in the still-running Hyprland session on tty1.
Resolve the PID first, then signal that number. Never signal a pattern:
# 1. Find it. `-x` matches the process NAME, so nothing that merely mentions
# vitrind in its own command line can match. `-a` prints each command line,
# so you can see which session you are about to end before you end it.
pgrep -x -a vitrind
# 2. READ ITS STATE. This decides which signal, and getting it wrong looks
# exactly like the signal not working.
ps -o pid,stat,args -p <PID>
# 3a. STAT contains `T` -- the process is STOPPED. `kill -TERM` is INERT here:
# a stopped process cannot handle SIGTERM, so it queues as pending and
# nothing observable happens. SIGCONT is the recovery, and it PRESERVES
# the session -- the compositor resumes and carries on.
kill -CONT <PID>
# 3b. STAT is `S` or `R` -- running but unresponsive. This is the case route 2
# was written for, and here TERM is right.
kill -TERM <PID>
Step 3a is not a footnote; it is #277.
This page’s own L6 rung wedges the session with SIGSTOP, and until
2026-08-13 the only signal it published was TERM — which cannot recover that
wedge. Verified twice on that date, once against a controlled process and once
against the real vitrind:
14:16:46 SIGSTOP -> state Tl+
14:16:49 SIGTERM, 3 s wait -> STILL ALIVE, state Tl+
14:16:52 SIGCONT -> EXITED immediately; the pending TERM landed on resume
SIGKILL also works on a stopped process, immediately — but it discards the
session, where SIGCONT gives it back.
Half of that is verified and half is not, and the difference matters here. Step 1 was run read-only against a live session on 2026-08-11 and returned exactly that session’s PID and nothing else [verified 2026-08-11]. Step 2 has never been used to recover a wedged session in this form — route 2’s one real recovery, on 2026-08-09, was typed as the broken command below. Run step 1 once while nothing is wrong, for the same reason bring-up step 0.1b makes you exercise the VT chord before you need it.
Do not “simplify” that back to
pkill -TERM -f "vitrind --drm". That is what this page published until 2026-08-11, and it is broken three ways at once (#260):
- Through a shell it is too greedy, and it aims at you.
pkill -fmatches whole command lines, so a shell that runs the command has the pattern in its ownargv.pkillskips its own PID but not its parent — so-TERMends the rescuer at the moment the rescue is being attempted.- On this machine it never matches the target at all. The
~/.local/bin/vitrindwrapper inserts--shim <path>between the binary and whatever you typed, so the literal stringvitrind --drmdoes not appear anywhere in the real process’s command line — it is not a pattern that describes this process. One injected argument is enough; the wrapper’s other job is environment variables, which never reachargvat all, and--blank-idleis the operator’s own flag, which lands after--drm. Checked read-only against the running session:pgrep -f 'vitrind --drm'returned only the invoking shell, whilepgrep -x -a vitrindreturned the one real PID. [verified 2026-08-11]- Wrapped in a unit it is silently empty.
systemd-rundoes not go through a shell, so the quotes are stripped and--drmarrives as a second argument.pkilltakes one pattern, matches nothing, and the unit exits1having signalled nothing — while the operator believes the session was rescued. That is exactly what happened on 2026-08-11. [verified 2026-08-11]A recovery command that fails silently is worse than one that fails loudly, because it is used precisely when nobody is reading the output.
A signal wrapped in a unit or a timer must not depend on shell quoting. There is no shell there to do the quoting. Resolve the PID before you arm it and give the unit a literal number:
# A standby rescue, armed before you wedge anything, with the PID already known.
systemd-run --on-active=120 --unit=l6-rescue /usr/bin/kill -CONT <PID>
The property this route depends on is that a vitrind session on tty3 does not
disturb Hyprland on tty1 — so a terminal there, or an agent session running in
one, still reaches the machine. Leave one open before you start anything. See
bring-up step 0.1.
Escalate within the route rather than jumping out of it, on the same PID throughout:
PID=<the number you read above> # one number, not a pattern
kill -INT "$PID" # ask for a clean shutdown first
sleep 2
kill -0 "$PID" 2>/dev/null && echo "still alive" || echo "gone"
kill -KILL "$PID" # only if -INT did nothing
pkill -x vitrin-shim # shims are children of vitrind; check anyway
-INT before -KILL matters: a clean exit runs the realm shutdown ladder and
drops DRM master in order. -KILL leaves the kernel to reclaim master, which
usually works and occasionally leaves the panel in a bad mode — see the
bring-up page’s recovery section R3 for that case.
wayvnc is not this route and must not be reached for. It runs through
Hyprland as a wlr-screencopy client, so the moment vitrind takes master and
Hyprland goes inactive it has nothing to capture and no compositor to talk to.
It is named here so that you do not count on it. [verified 2026-08-09, recorded
on the bring-up page]
Route 3 — SysRq through /proc/sysrq-trigger, sudo only
This route brings the machine down safely without the power button, when
vitrind will not die, or when the ordinary shutdown path is itself stuck.
It is not a way to un-wedge the display; it is a way to end the session
without corrupting the filesystem.
Read the caveat before the commands. This path needs a reachable shell. It covers “
vitrindwedged the display” once a VT or the Hyprland-side shell is reachable. It does not cover “input is completely dead” — which is the only case the physicalAlt+SysRqcombo would have covered, and which is not available here (below). That trade is made knowingly. If you have no shell, this route does not exist for you; go to route 4.
The keyboard combo is not available here, and the mask is not being changed
/proc/sys/kernel/sysrq is 16 on this machine, set by
/usr/lib/sysctl.d/50-default.conf:19. [verified 2026-08-10] 16 is
0x10 — enable sync command and nothing else. So from the physical keyboard
Alt+SysRq+s works and every other letter does not: no r (unraw), no
e/i (signal processes), no u (remount read-only), no b (reboot). The
physical REISUB sequence is inert here by configuration.
That configuration stands, and raising it is not on this page. Handing
REISUB to anyone at the physical keyboard of a machine whose entire premise is
confining what runs on it is a trade this project declines. An earlier version
of the bring-up page recommended raising the mask as an optional pre-step; that
recommendation has been deleted rather than left standing.
Why the trigger file works anyway
The mask gates the keyboard path only. From the kernel’s own documentation, verbatim:
Note that the value of
/proc/sys/kernel/sysrqinfluences only the invocation via a keyboard. Invocation of any operation via/proc/sysrq-triggeris always allowed (by a user with admin privileges).
— Documentation/admin-guide/sysrq.rst,
read 2026-08-10. [verified]
The mechanism behind that sentence, so it is not taken on trust: the file’s
write handler calls __handle_sysrq(c, false), and that second argument is
check_mask — the trigger path asks the kernel not to consult the bitmask.
[verified against drivers/tty/sysrq.c::write_sysrq_trigger, mainline, read
2026-08-10]
/proc/sysrq-trigger is --w------- root root on this machine. [verified
2026-08-10] So the capability is the root user’s alone, which is the whole
point: it is reachable by sudo and by nothing at the keyboard.
The sequence — and it is deliberately not REISUB
Do not write _reisub. The kernel’s own documentation offers
echo _reisub > /proc/sysrq-trigger as its bulk-mode example, and on this
machine it would be a hard reboot with two no-ops in front of it. Two
independent findings, both checked against the kernel source on 2026-08-10:
sandudo not do the work; they queue it.sysrq_handle_synccallsemergency_sync(), which isschedule_work(do_sync_work)and returns immediately;sysrq_handle_mountrocallsemergency_remount(), which isschedule_work(do_emergency_remount)and returns immediately. [verified:fs/sync.c,fs/super.c]sysrq_handle_rebootcallsemergency_restart(), which does not return at all.- Bulk mode runs the whole string inside one
write(), with no pause between letters — that is exactly what the leading_buys. [verified:write_sysrq_triggersetsbulk = trueon_and loops the buffer]
Put together: _reisub queues a sync, queues a remount, and then reboots before
either queued job can run. The kernel documentation says the same thing in its
own words about the sync — “the sync hasn’t taken place until you see the “OK”
and “Done” appear on the screen“ — and bulk mode is precisely the form that
gives you no chance to see them.
And e and i are wrong for this machine anyway. e sends SIGTERM to
every process except init and i sends SIGKILL. On this machine the escape
route is a shell in the Hyprland session, and that session is the maintainer’s
real work — so e destroys the thing you are recovering with, along with
everything you have open. Their purpose in the keyboard sequence is to get
processes out of the way when you have no shell; here you have one, and route
2’s signal to one resolved PID is the aimed version of the same idea.
So the correct procedure is three separate writes, waiting between them:
# In a second shell, so you can see the kernel's own completion messages.
# kernel.dmesg_restrict = 1 here, so this needs root. [verified 2026-08-10]
sudo dmesg -w
# STEP 1 — SAFE. Flush the page cache to disk.
printf 's' | sudo tee /proc/sysrq-trigger
# WAIT for "Emergency Sync complete" in the dmesg -w window before continuing.
# STEP 2 — *** DESTRUCTIVE: every filesystem becomes read-only. ***
# Nothing on this machine can write to disk afterwards. Do not run this and
# then decide to keep working.
printf 'u' | sudo tee /proc/sysrq-trigger
# WAIT for "Emergency Remount complete" before continuing.
# STEP 3 — *** DESTRUCTIVE: reboots the machine immediately, no unmount. ***
# Everything unsaved that steps 1 and 2 did not reach is gone.
printf 'b' | sudo tee /proc/sysrq-trigger
Step 2 does not strand step 3. The remount only touches superblocks with a
backing block device — do_emergency_remount_callback tests sb->s_bdev before
it does anything — and /proc has none, so the trigger file is still writable
after every real filesystem has gone read-only, and sudo dmesg -w still reads.
[verified: fs/super.c, read 2026-08-10] This is worth knowing in advance,
because hesitating between steps 2 and 3 is exactly what a read-only filesystem
invites.
One optional, non-destructive rung, useful only in a narrow case:
# SAFE. Console keyboard is dead after vitrind died: put it back in XLATE mode.
printf 'r' | sudo tee /proc/sysrq-trigger
r addresses the console keyboard mode only — the kernel documents it as
“Turns off keyboard raw mode and sets it to XLATE”. It does not put the VT
back into text mode, so a console left in graphics mode stays blank whatever r
does. Only meaningful once vitrind is no longer running. [inferred: the letter’s
documented behaviour is verified; that it helps in this specific case is not]
Two traps, one of them tested here
The redirect happens in the shell you are already in. echo s > /proc/sysrq-trigger fails as an unprivileged user, and sudo echo s > /proc/sysrq-trigger fails identically, because sudo applies to echo and not
to the >. It must be | sudo tee (above) or sudo sh -c '...'.
Tested here, non-destructively: the unprivileged redirect returns
permission denied and exits 1, writing nothing. [verified 2026-08-10]
You will probably not see the kernel’s messages on the console.
kernel.printk is 1 4 1 4 on this machine [verified 2026-08-10], so the
console log level is 1 — only KERN_EMERG reaches the screen, and the
completion messages are below that. The kernel documentation’s advice to wait
until you see them on the screen therefore does not work here as configured.
sudo dmesg -w in a second shell is the observable, which is another reason
this route needs a shell rather than a keyboard.
What was not executed, and why
The sudo write path has never been run on this machine. s is the one
harmless letter and it is the only one permitted to be exercised, but
sudo -n true fails here — a password is required, and the session that wrote
this page could not supply one. [verified 2026-08-10] So step 1 above is
documented and unexecuted.
Run step 1 yourself, once, while nothing is wrong. It is a sync; it costs a
second and it is the only way to find out that tee typo before the day you
need it. A recovery path you have never used is a plan. That is what bring-up
step 0.1b already says about the VT chord, and it applies here unchanged.
Route 4 — the installer USB and a chroot
Real, and slower by orders of magnitude: physical access, a hard power cycle,
boot the Arch USB, mount the root (and /boot, and unlock LUKS if the disk is
encrypted), arch-chroot, undo whatever wedged it, exit, reboot. Minutes to
tens of minutes, against seconds for a console command.
Have the USB physically in the room before you start. If it is in a drawer somewhere else, you do not have a fourth route; you have an errand. See bring-up step 0.3.
Note that a DRM mode does not survive a reboot — a panel left in a bad mode is annoying rather than persistent, and is not a reason to reach for the USB.
What none of this covers
-
Input completely dead. The one case the physical
Alt+SysRqcombo would have covered, and it is not available here (route 3). Every route above needs either a keyboard or a shell. -
A kernel-side wedge in i915. If the GPU hangs, none of this applies: you get a hard freeze, and the only response is a power cycle and reading
journalctl -b -1 -k -g i915afterwards. That is a driver bug, not avitrindbug. -
A dark screen that is not a wedge. As of this release
vitrindcan turn the panel off on an idle timer, and a blanked session looks exactly like a dead one. Press a key before you conclude anything — any physical input wakes it. If the panel does not come back within a couple of seconds, treat it as a wedge and start at route 1. This is a new confusion that did not exist before the blank, and it is published as a limit rather than left to be discovered: Where this is honest about its limits.From a shell, you can now tell the two apart without looking at the panel. A blank logs
the session went idle— it always did — but until this release that was the only line the whole cycle produced, so a wake that worked and a modeset that left the panel dark were both followed by silence. A wake now logsthe panel is lit again, and a wake that never completed logsTHE WAKE WAS NOT CONFIRMEDatWARN— the case that is genuinely indistinguishable from a wedge at the panel, and the one that means route 1. The flight recorder carries the pair asscreen_blankedandscreen_woke, neither of which existed before; the wake entry’soutcomefield isflip_landed(the panel came back),no_flip(it may not have) orseat_lost(the blank ended because you switched VT), and both entries carrylocked, because an idle blank never raises a lock but can perfectly well go up behind one. [verified:crates/vitrin-core/src/session.rs,crates/vitrin-core/src/recorder.rs]
The settings this depends on, which this repository does not own
Suspend, lid and power-key policy is systemd-logind’s, not vitrind’s —
that is a deliberate decision, not an omission, because reimplementing it would
put session policy inside the trusted core. The consequence is that “suspend
works” is not reproducible from a checkout of this repository alone. So the
values are published here, read from the running logind on 2026-08-10 rather
than transcribed from a config file:
| Property | Value on this machine | |
|---|---|---|
HandlePowerKey | poweroff | [verified] |
HandlePowerKeyLongPress | ignore | [verified] |
HandleSuspendKey | suspend | [verified] |
HandleHibernateKey | hibernate | [verified] |
HandleLidSwitch | suspend | [verified] |
HandleLidSwitchExternalPower | unset — falls through to HandleLidSwitch | [verified] |
HandleLidSwitchDocked | ignore | [verified] |
IdleAction / IdleActionSec | ignore / 1800 s | [verified] |
InhibitDelayMaxSec | 5 s | [verified] |
BlockInhibited | (empty) | [verified] |
DelayInhibited | shutdown:sleep | [verified] |
Read with:
busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \
org.freedesktop.login1.Manager \
HandlePowerKey HandleSuspendKey HandleLidSwitch \
HandleLidSwitchDocked HandleLidSwitchExternalPower IdleAction
systemd-inhibit --list
Four things that matter more than the table:
- These are the machine’s, not the project’s.
/etc/systemd/logind.confcontains a bare[Login]header and nothing else on this machine [verified 2026-08-10], so every value above is systemd’s compiled-in default (systemd 261) rather than a choice anyone made. They can change without any change to this repository, and a run recorded under different values is a run of a different system. Record them with your results. - Nothing currently blocks logind’s handling.
BlockInhibitedis empty, so no application has taken ahandle-lid-switch,handle-power-keyorhandle-suspend-keylock. If one ever does, the lid stops suspending the machine and nothing invitrindwill tell you why —systemd-inhibit --listis where that shows up. - Six
delayinhibitors are held (NetworkManager, rtkit, UPower, and two desktop applications) [verified 2026-08-10], bounded byInhibitDelayMaxSec = 5 s. So a suspend on this machine is delayed by up to five seconds and then proceeds. If a resume looks late, that is where the first five seconds went. - The lid is
SW_LIDonevent0[verified 2026-08-10, from/proc/bus/input/devices], andvitrindsees it and drops it at intake — switch events have no wire event, whichcrate::input::intake_physical’s own doc comment states. So closing the lid produces novitrindbehaviour at all; everything you observe is logind’s.
The hardware checklist
CI structurally cannot test any of this. A GitHub runner has no seat, no VT, no DRM device, no ACPI and no backlight. That is stated rather than dressed up as a criterion, and it is why this checklist exists.
Run it after a vitrind --drm session is up per the bring-up page, with the
Hyprland-side shell of step 0.1 open the whole time. Rungs are numbered L1–L7
so they do not collide with the bring-up page’s own 1–15. (L7 was added by the
fix for #257, which the first
run of L4 found; a rung added because a run found something is what this table
is for.)
| # | Do | Expect | Worst credible failure |
|---|---|---|---|
| L1 | 10 VT switches away and back. Ctrl-Alt-F2, wait, Ctrl-Alt-F3 back. Ten times. | Session survives every one; band the same colour each return; recorder shows paired pause/activate | A dead session, or a black panel with vitrind alive (master not reacquired) |
| L2 | 5 suspend/resume cycles. systemctl suspend from the escape shell. | Machine sleeps, wakes, panel comes back, apps still there | Panel never returns; or apps frozen because no frame clock restarted |
| L3 | 5 lid close/open cycles. | logind suspends on close, resumes on open, panel returns | Lid does nothing (check systemd-inhibit --list); or resume leaves a black panel |
| L4 | Blank and unblank. Start with --blank-idle, leave the machine alone past the timeout, then press a key. | Panel goes dark; any physical input brings it back; the log carries the session went idle and the panel is lit again, and the recorder a screen_blanked/screen_woke pair with outcome: flip_landed | Panel dark and input swallowed — this is indistinguishable from a wedge, go to route 1. THE WAKE WAS NOT CONFIRMED in the log, or outcome: no_flip in the recorder, is that case naming itself |
| L5 | Confirm the blank did not lock. After L4’s wake, look at what is on screen, and at the screen_blanked entry the recorder wrote. | The session as you left it — not a lock card — and locked: false on the entry | A lock card, which would mean idle-blank and idle-lock got coupled. locked: true with no lock card on screen means the entry is wrong, which is its own defect |
| L6 | One deliberate wedge, recovered by a documented route. Choose the route before you wedge anything and write it down as you use it — see the warning below. | Route 1 or 2 recovers it | Neither does; record how far down the table you had to go |
| L7 | Leave and come back with the blank armed. With --blank-idle 60 (and, on a second pass, --lock-idle 60 as well), switch to another VT, stay there longer than the timeout, and switch back. Time how long the panel stays lit after the return. | The panel stays lit for the full timeout measured from the return — 60 s, not 1.5 s — and the lock does not raise | The panel blanks within a couple of seconds of coming back, or the session demands a passphrase for returning: the idle clock is being stamped with an instant from before the absence (#257) |
Three rungs deserve their own warnings.
L2 and L3 are complete as of 2026-08-13, and the second run fixed more than the counts. 2026-08-11 managed 4 of 5 suspend/resume cycles and 2 of 5 lid cycles. 2026-08-13 added the fifth suspend and three more lid cycles that reached sleep, taking both to 5 of 5.
It also closed a hole the first run could not have seen. The 2026-08-11 session
carried no --keymap, so nothing could be typed into the app — and an idle
terminal produces no frames, which makes “the app is correctly idle” and “the
app is frozen” the same artefact. The counts were met over a client that could
not be proven alive. On 2026-08-13, with the keymap passed, the operator typed
after each resume and the log carries the proof directly:
56 frames, 96 keys before any suspend
=== suspend / resume ===
7 frames, 18 keys typed after the systemctl suspend
=== lid close / open ===
13 frames, 24 keys typed after the lid cycle
New frames after both resumes: the frame clock restarts, and the failure mode this rung names does not occur. Run these with a keymap — without one the rung passes on evidence it does not have.
Also worth keeping: on 2026-08-13 a lid close reopened within the same second never reached sleep at all. That is correct behaviour, not a miss, and it is the short-lid-close case one sample could never have established.
Do them with the escape shell open and with nothing unsaved.
L6’s answer was lost once, and is now recovered. On 2026-08-11 the wedge
recovered in ~69 s and which route did it could not be reconstructed
afterwards — not from the journal, not from either flight recorder, not from
the process tree, not from two units’ exit codes. The page then guessed “fg
typed blind”.
2026-08-13 settled it, and the guess was right in substance: fg sends
SIGCONT, and SIGCONT is what recovered the second wedge. The route is now
named, timed, and mechanised:
14:07:55.141 last log line -- session paused
... SIGSTOP, 163.8 s wedged ...
14:10:38.963 "the seat activated this session; reclaiming the panel"
Three things that run established which the first could not:
kill -TERMdoes nothing to this wedge — verified against the real binary (#277). Route 2’s published command was the wrong signal for the page’s own rung.kill -CONTrecovers it and keeps the session, in the next logged millisecond.- Route 1’s chord was queued, not defeated. It completed the instant the process resumed, having sat pending for the whole wedge.
Decide the route before you wedge anything anyway, and write the time down as you use it. Reconstructing it four minutes later did not work the first time.
L4 can produce exactly the symptom this whole page is about. Unblanking is a full modeset, and a modeset that fails leaves a dark panel with the session running. If L4 does not come back, you are in route 1 — and that is a result to record, not a mishap.
The numbers this checklist owes
These were owed to issue #223 and were pasted into it on 2026-08-11. The second run, 2026-08-13, discharged the rest. Both runs are recorded below.
-
L1 — 10 of 10 (19 switches, 0 stalled; chord-to-pause median 240 ms). 2026-08-11.
-
L2 — 5 of 5. Four on 2026-08-11 (resume-to-panel 24–31 ms, one 2100 ms outlier), the fifth on 2026-08-13. Liveness proven on the second run only: the app took keystrokes and produced new frames after the resume. The first run had no keymap and therefore no way to tell an idle app from a frozen one.
-
L3 — 5 of 5. Two on 2026-08-11 (one usable), three on 2026-08-13, all three reaching sleep, with the same typed-after-resume liveness proof. A fourth close/open inside one second correctly never suspended.
-
L4 — blank at 61.2 s; wake confirmed. 2026-08-11 for the transition, 2026-08-12 for the log line and recorder pair (#258, #259), and observed a second time on 2026-08-13’s L7 run —
the panel is lit againpresent,THE WAKE WAS NOT CONFIRMEDabsent,outcome: flip_landed. No wake has ever failed on this machine, so the WARN arm remains unexercised; that is the pass condition, not a gap. -
L6 —
SIGCONT, 163.8 s. The route is named at last, and the mechanism with it:kill -TERMis inert against this wedge (#277), route 1’s chord was queued rather than defeated, and the panel showed the previous VT rather than going dark. 2026-08-13. -
L7 — 61.214 s, measured. 2026-08-13,
--blank-idle 60. The seat returned at 14:22:21.655 andscreen_blankedwas journalled at 14:23:22.869, so the panel stayed lit for 61.214 s counted from the return against a 60 s timeout. The ~1.2 s over is service-loop granularity and matches L4’s independently measured 61.2 s.This replaces the by-eye pass of 2026-08-11, which ran at a 20 s timeout and could not distinguish “the full 20 s” from “17 s”. The figure this rung asked for exists now. Both instants come from
vitrinditself — the seat-return line from the log, the blank from the recorder’swall_ms— so no cross-process clock is involved. See the note on clocks in the 2026-08-13 record below before computing anything of this shape yourself.
Record the run
Date it and record the environment, the same shape
docs/drm-bringup.md
uses. The value of a manual runbook is entirely in whether anyone can tell when
it was last actually executed. The next run copies the shape below, blanks the
values and fills them in from its own eyes.
First run — 2026-08-11, L1–L6 on the target machine
Numbers below are read from the flight recorders, the tee’d logs and
journalctl; the visual observations are the owner’s. Nothing here is inferred
from source.
Executed: 2026-08-11, JST
By: @tahaayan
Kernel: 7.1.6-arch1-1 Mesa 26.1.6
GPU: i915, /dev/dri/card1, eDP-1 @ 2560x1600, scale 1
Binary: target/release/vitrind --features drm-backend, built 2026-08-11
logind values, read at the time of the run (/etc/systemd/logind.conf carries
only the [Login] header, so these are the defaults in effect):
HandleLidSwitch=suspend HandlePowerKey=poweroff
HandleSuspendKey=suspend IdleAction=ignore
InhibitDelayMaxSec=5s
Delay inhibitors on sleep: NetworkManager, rtkit-daemon, upowerd
L1. 10 VT switches ............. 10/10 survived; band colour stable? YES
L2. suspend/resume ............. 4 of 5 cycles run; 4/4 panel returned
L3. lid close/open ............. 2 of 5 cycles run; 1 suspended and behaved
as L2, 1 never reached Sleep at all
L4. Blank and unblank .......... blank after 61.2 s; unblank OK
L5. Blank did not lock ......... PASS (session as left, no lock card)
L6. Deliberate wedge ........... recovered in ~69 s, route INDETERMINATE
L7. Return from another VT ..... RUN SEPARATELY, later on 2026-08-11, at
--blank-idle 20 --lock-idle 20 (not 60).
Panel stayed lit on the return -- it did NOT
blank in ~1.5 s. Lock did NOT raise on the
return; it raised only after the countdown
ran again from the return. Both by eye:
seconds NOT timed, absence NOT timed.
Next run: panel stayed lit ___ s after the
return (must be the full --blank-idle
timeout); absence ___ s.
SysRq step 1 (`printf 's' | sudo tee /proc/sysrq-trigger`) executed? NO
(still documented and unexecuted)
L1 — 10/10, and the chord is confirmed on hardware from a healthy session.
0 refused, 0 stalled over this run’s 10, out of 19 chords across the two
runs. The other nine are on the bring-up page’s item 12, which records them as
5 switches honoured plus 4 vt_switch_refused already_here — the human
chording the VT he was already on, i.e. the code declining a no-op rather than a
switch failing. The two records are quoted side by side rather than merged into
one refusal count, because only the operator’s recorder logs can say whether the
0 refused above was scoped to this run or to all 19, and nobody has gone back
to them.
Zero stalls is a positive result rather than absent instrumentation:
VtSwitchStalled is live code fired from a timer for the case where
libseat_switch_session returns Ok and no PauseSession follows — the “chord
appears to work and does not” shape that trapped the maintainer on the first
bare-metal run. It never fired. Chord → seat pause
latency, n=9: min 209 ms, median 240 ms, max 312 ms. Pause/activate pairing
is exact: 9 pauses against 8 activates in the second run, the missing activate
being the pause the session was left in.
What L1 does not establish is route 1. Every one of those 19 chords was pressed against a healthy compositor. The rung asks whether the chord works, not whether it gets you out — and L6 below, the only wedge on record, is the case where it does not.
L2 — 4 cycles, not 5. Kernel resume → vitrind reclaimed the panel:
| resume | latency |
|---|---|
| 13:03:44 | 24 ms |
| 13:04:33 | 31 ms |
| 13:06:26 | 2100 ms |
| 13:07:00 | 31 ms |
Recorded as 4/5, not 5/5. The journal shows four systemctl suspend cycles
where the rung asks for five. Every cycle that ran returned with a working panel
and live apps. The 2100 ms outlier followed the shortest suspend of the set
(5.6 s).
L3 — 2 cycles, not 5, and they disagreed. Recorded as 2/5. Both cycles behaved as L2 when they suspended, but only one of the two suspended at all:
- Lid closed 13:07:12.96, opened 13:07:19.30 (6.3 s closed) — never reached Sleep. No suspend entry in the journal.
- Lid closed 13:07:27.77 → suspend entry 13:07:28.08 → opened 13:07:38.57 → suspend exit 13:07:39.29 → panel reclaimed 30 ms later.
Whether a short lid close reliably does not suspend on this machine is not established by two samples and is not claimed here.
The summary line above is corrected, not transcribed. The #223 comment this
record comes from writes L3’s one-line summary as 2/2 behaved as L2 while its
own detail — the two bullets above, which are verbatim — says only one of the
two suspended at all. The detail is what is right, so the line in the block
reads 1 suspended and behaved as L2, 1 never reached Sleep at all. Against a
rung asking for 5 cycles, L3 is 1 usable sample.
L4 / L5 — the blank and the lock behaved; the run still filed three defects
against L4. Blank fired at 61.2 s against --blank-idle 60. The panel
returned on ordinary physical input, twice, with the session unchanged and no
lock card — so idle blank and idle lock are confirmed uncoupled on hardware, as
D-033 intends, which is L5 and it passes. L4 is not a clean pass: the run
found #257 on the return path, and #258 and #259 came out of the same session
— the unblank logged nothing, so a successful wake and a modeset that left the
panel dark were indistinguishable, and neither transition reached the flight
recorder at all. All three now have fixes. #257’s has since been observed on
hardware — see the L7 record below, run later the same day — but #258’s and
#259’s have not: the enriched expectations in L4’s own row (the the panel is lit again line, the screen_blanked/screen_woke pair) describe output that
did not exist when this run was made and was not looked for during the L7 run
either.
L6 — recovered, route indeterminate. The wedge was produced by SIGSTOP on
the compositor while it held DRM master and the libinput devices — a faithful
“compositor hung”, reversible, and it does defeat Ctrl+Alt+F<n> exactly as
this page predicts.
13:15:01.8 SIGSTOP -- wedge begins
13:16:10.8 alive again, processing a VT chord ~69 s wedged
13:16:29 standby rescue fired into an already-running process (no-op)
The route that recovered it is not recoverable after the fact, and that is
recorded rather than guessed. Ruled out by evidence: it was not Ctrl+C (the
tee in the same foreground process group survived, and SIGINT does not
resume a stopped process); it was not either standby timer (one ran 80 s before
the wedge and exited 1 — that is the pkill -f defect in route 2 above — and
the other fired 19 s after recovery). Something delivered SIGCONT from the
tty3 session, most plausibly fg typed blind, but the operator did not recall
four minutes later and no artefact records it. That indeterminacy is itself
the finding, and it is why L6 now tells you to choose the route first.
Not done, and not quietly dropped:
- The VKMS rung was not attempted by hand during this run. It is, however, attempted by CI on every pull request, and on 2026-08-13 that attempt was read rather than assumed. See the VKMS note below the third run’s record.
/proc/sysrq-triggerroute 3 was not exercised. Still documented and unexecuted.- L2 and L3 are short of their stated counts, as recorded above.
L7did not exist during this run — it was written from this run’s #257. It has since been executed, separately and later the same day; the result is the block below.- L4’s new log and recorder expectations were observed by nobody. They were
added by the #258/#259 fixes after this run, so nothing in the block above
reached them — and the L7 run that followed did not look at the log or the
recorder either. A third run, on 2026-08-12, did: see
L4 (second execution)below.
Filed from this run: #257 (returning to a paused session blanks the panel in
~1.5 s), #258 (the unblank is silent; success and failure look identical), #259
(blank/unblank leave no flight-recorder event) and #260 (this page’s published
recovery command signalled the rescuer under a shell and nothing at all under
systemd-run — corrected in route 2 above).
L7 — same day, separate run, --blank-idle 20 --lock-idle 20
Run after the fixes for #257–#259 landed on main, at a 20 s timeout rather
than the 60 s the rung suggests. Both passes were done in one sitting.
Executed: 2026-08-11, JST, same machine and binary family as above
(rebuilt from main after #263 merged)
Flags: --drm --blank-idle 20 (pass 1)
--drm --blank-idle 20 --lock-idle 20 (pass 2)
--lock-on-seat-change: not passed, so the default `never`
Pass 1. Panel on return ........ STAYED LIT. It did not blank on the way back
in, which is the ~1.5 s symptom #257 filed.
Pass 2. Lock on return ......... DID NOT RAISE on the return. It raised only
after the countdown ran again *from* the
return, with the session sitting idle --
which is what `--lock-on-seat-change never`
is specified to do.
Timed? ....................... NO. Both observations are by eye. The seconds
the panel stayed lit were not measured, and
the absence was not measured either.
Log lines checked? ........... NO -- `the panel is lit again` (#258) was not
looked for.
Recorder checked? ............ NO -- the `screen_blanked`/`screen_woke` pair
and `outcome: flip_landed` (#259) were not
looked at.
What this settles and what it does not. It settles #257, which is a symptom question — the panel blanked ~1.5 s after a return, and it no longer does; the lock demanded a passphrase for coming back, and it no longer does. Both symptoms are gone on the machine that produced them, under the default seat policy. It settles nothing about #258 or #259: those are about what the wake says, and nobody read the log or the recorder during this run. It also produces no number — an eyeball pass at a 20 s timeout cannot distinguish “the full 20 s” from “17 s”, so the rung’s own question, how long did the panel stay lit, is still unanswered and the record block above says so.
L4 (second execution) — 2026-08-12, --blank-idle 60
The first run that read the log and the recorder rather than only the panel. #258 and #259 are settled by it, and nothing else is.
Executed: 2026-08-12 14:00:57 JST (+0900), by the maintainer, on the same
machine as every block above.
Binary: vitrind rebuilt from `main` at 13:46 the same day.
--blank-idle 60; --lock-idle NOT passed.
Panel .......................... blanked on the idle timer, stayed dark, and
came back on a keypress. Observed by eye.
Log line (#258) ................ YES.
the panel is lit again: physical input woke the session and the modeset
was accepted. The wake itself restores no authority -- an idle blank
never took any.
THE WAKE WAS NOT CONFIRMED ..... 0 occurrences.
Recorder (#259) ................ YES, the pair, from the same wake:
{"kind":"screen_blanked","live_grants":0,"locked":false}
{"kind":"screen_woke","dark_ms":5630,"outcome":"flip_landed",
"live_grants":0,"locked":false}
Timed? ......................... NO. `dark_ms` is how long the panel was dark
before a key was pressed, not a latency: it
measures the human, not the wake.
The earlier run recorded nothing because of the binary, not the code. The
13:42 attempt the same day used a vitrind built on 2026-08-11 at 12:34 — five
hours older than the commit that added both the line and the pair — so it
blanked and woke while carrying no code to write either down. A wake that
logs nothing and a build that cannot log are indistinguishable in the artifact;
only the binary’s mtime tells them apart. Check what you are running before
reading a silence as a result.
What it does not settle. The WARN arm is unexercised: no wake failed, so
THE WAKE WAS NOT CONFIRMED has still never been emitted on hardware, and its
absence here is the pass condition rather than a gap. No figure was taken, so
L7’s question is still unanswered.
A narrow point about L5, which passed on 2026-08-11 and is not reopened
here. That row asks for two things from one run: no lock card on screen, and
locked: false on the screen_blanked entry. The 2026-08-11 run checked the
screen half with the lock armed and passed it; the recorder entry did not exist
yet, so there was nothing to read. This run has the entry and it reads
locked: false, but it did not arm the lock, so its clean screen is what an
unarmed lock looks like rather than evidence about the boundary. Both halves
hold, from different runs; the row as written has not been satisfied by a single
one.
Adjudicated closed by the maintainer on 2026-08-12, and recorded as an
adjudication rather than as an observation, because that is what it is. His
reading is that the 2026-08-11 pass is the substantive one — the lock was armed,
the blank fired, and no card came up — and that a second run to put both halves
in one artifact is bookkeeping rather than evidence. That is a reasonable call
and it is his to make; what it costs is stated here so nobody later reads L5
as something it was not. What a single arming pass would still buy, and only
it: the case where the screen and the journal disagree — a locked: true
entry under a screen with no card, which this row’s own failure column names as
its own defect. Two runs cannot catch a disagreement between them by
construction. Nobody believes that state is live; it simply has not been looked
for.
Third run — 2026-08-13, L2/L3 completion, L6 and L7
Executed: 2026-08-13, by the maintainer, on the same machine as every block
above. Artefacts in ~/vitrin-runs/223-{cycles,l7}-*.{log,jsonl}.
Binary: vitrind rebuilt 12:26 that day from a clean tree at 9b6239e,
--features drm-backend. Shim rebuilt 12:28 against the VENDORED
wlroots 0.19.3 -- a system upgrade had replaced 0.19 with 0.20 and
the previously built shim could not start at all.
L2 ....... 5 of 5. The fifth cycle, plus liveness (below).
L3 ....... 5 of 5. Three more cycles reaching sleep, plus liveness. A fourth
close/open inside one second correctly never suspended.
LIVENESS . NEW, and the reason the counts now mean something. With --keymap
passed, typing after each resume produced frames:
before any suspend .... 56 frames, 96 keys
after the suspend ...... 7 frames, 18 keys
after the lid cycle .... 13 frames, 24 keys
L6 ....... SIGCONT, 163.8 s wedged. Route named for the first time.
L7 ....... 61.214 s lit, measured from the seat's return, against
--blank-idle 60. The figure this rung has owed since it was
written.
L4 ....... re-observed in passing on the L7 run: `the panel is lit again`
present, `THE WAKE WAS NOT CONFIRMED` absent, flip_landed.
L7 pass 2 ATTEMPTED. Caught no absence -- but see below: under the default
seat policy the question is already answered by pass 1 plus the
single shared idle clock, and 12a is the rung that is actually owed.
L7’s second pass is still owed, and saying so costs nothing. A
--blank-idle 60 --lock-idle 60 session was run at 14:46:03. Its recorder
carries zero seat activations: the first seat event of the run is the pause
at 14:47:29, when the operator left at the end. There was no absence and no
return inside it. What it recorded instead is the plain idle path, which is
worth keeping:
14:47:10.060 session_locked cause: idle both 60 s timers, same expiry
14:47:10.084 screen_blanked locked: true 24 ms after the lock
14:47:19.793 screen_woke flip_landed woken while locked
14:47:24.577 session_unlocked
So --lock-idle fires with the right cause, a wake works while locked, and
unlock works.
And that is enough, under the default policy — a third run would add nothing.
Pass 2 asks whether the lock raises on the return from an absence longer than
the timeout. It cannot, and the reason is structural rather than observational:
the lock and the blank read one clock, not two. last_activity was lifted
out of LockScreen into backend::blank::SessionActivity behind an
Rc<RefCell<..>> by WS-E.4.3 for exactly this reason — “two fields would be two
clocks, they would drift, and the drift would be invisible” — and the seat’s
return restamps that single field in set_seat_absent(false, now).
Three facts then compose to the answer:
- The restamp is measured. Pass 1’s 61.214 s is the blank firing 61.214 s after the seat returned, which is a measurement of the shared field being restamped on return.
- The lock reads that same field. This run shows it: the lock and the blank fired 24 ms apart off one expiry.
- Both runs were
on_seat_change="never", under which the absence is not charged at all.
There is no second clock left that could have kept running, so the raise pass 2 looks for has nothing to fire from. The by-eye pass at a 20 s timeout on 2026-08-11 agrees, and so does the operator’s observation on 2026-08-13 that no lock card was present.
What is genuinely untested is a different rung on a different page.
set_seat_absent has three branches and only Never has ever run on hardware.
Under Idle the absence is charged, so a long absence should return
locked — the opposite result, from the same call site. Under Immediate the
raise happens on seat loss. Those are docs/drm-bringup.md step 12a
(issue #246), written and never executed. If you want the lock-on-return
question exercised for real, run 12a, not another L7.
Two method notes, both of which cost time before they were understood.
Never correlate the shim’s clock with vitrind’s. The shim’s 00:00:00.000
starts at shim launch, not core launch, and the tee’d log interleaves two
writers with different buffering, so line order is not time order. A defect
was briefly read into existence this way on the same day’s 13a run. Where one
clock is needed, use the flight recorder: every entry carries both mono_us and
wall_ms, and wall_ms correlates directly with the log’s tracing timestamps
because both come from the same process. L7’s figure above was computed exactly
that way.
A second vitrind refuses to start, and says so. An accidental second
launch during this session exited with fatal: another vitrind already holds this runtime tree (its lock on /run/user/1000/vitrin-0/core.sock.lock…). It did
not fight for DRM master. Worth knowing, because the failed attempt still writes
its own near-empty log, and picking that file by timestamp will make a
successful run look like it recorded nothing.
The VKMS rung: attempted every PR, and what it actually returns
.github/vkms/run-advisory.sh runs on every pull request, and the green check
means nothing — the script exits 0 on a declared skip exactly as it does on a
real probe, deliberately, so the rung can never start gating merges. The
evidence is in the job log, not the checkmark. Read on 2026-08-13:
-- module state: loaded
-- no vkms card node appeared; skipping the GBM/EGL probe
-- probe summary: no-vkms-card-node
So the honest state is a third outcome, and not the one the rung’s own
acceptance criterion anticipated. The module is not unavailable — it loads. But
no /dev/dri/card* node appears behind it on the hosted runner, so nothing
downstream runs: no connector enumeration, no mode set, no atomic commit, no
page flip, and no GBM/EGL probe. The rung is attempted continuously and
currently covers nothing.
That is worth stating plainly rather than leaving as “not attempted”, because
the two are different claims and only one of them is true. What would change it
is a host where the card node does appear — a local machine with udev and root,
rather than a container. That has not been done here, and the reason is
recorded rather than skipped: loading a new DRM device on a machine running a
live compositor risks that compositor enumerating it and attaching an output to
it. On the maintainer’s one laptop that is a live-session risk taken for a rung
which, by its own header, can never prove the thing that matters — that the
backend lights a real panel. docs/drm-bringup.md, executed by a human, remains
the only evidence for that.
This page has been executed twice, on 2026-08-11 and 2026-08-13, and is now a pass on every rung it can reach. L1 through L7 have all been run and all have their numbers, with L2/L3 at full count and proven live, L6’s route named, and L7 timed. What remains unexecuted is named rather than implied: routes 3 and 4 are still careful predictions, the VKMS rung is attempted on every PR and currently covers nothing (the module loads, no card node appears), only the
neverseat policy has ever run on hardware – step 12a’simmediateandidleare written and unexecuted, andidleis the branch that would return locked –L5is adjudicated closed rather than re-run, and the WARN arm of L4 has never fired because no wake has ever failed here.Both runs’ headline findings were defects in this page’s own recovery command —
pkill -fin 2026-08-11 (#260),kill -TERMagainst a stopped process in 2026-08-13 (#277). A recovery page that has been wrong twice about its own central instruction is a page to read sceptically. Correct it from your own eyes, and treat a failed observation as a result worth recording rather than a step to retry until it passes.
Session app matrix
Which applications this project has actually run, on one machine, at what bar, with the observable that was checked named in every cell.
This page is generated, and the generator can only emit a cell somebody executed. An application nobody ran does not appear as a row here no matter how confident anyone is that it would work; it appears in Requested, and not emitted instead. That is the whole design: the app set cannot widen by assertion, only by running something and landing its evidence.
The machine, the build, and the date
One machine was measured. Everything below is one laptop, one Intel iGPU, one internal panel, one kernel. None of it generalises, and reading this page as “Vitrin runs these applications” would be false.
- Inventory read: 2026-08-10
- Last recorded run in this corpus: 2026-08-11 — the operator-driven bare-metal session that ran alacritty and nautilus and produced #268. The last checklist run is still 2026-08-09, the second bare-metal DRM run (
docs/drm-bringup.md) - Kernel:
7.1.6-arch1-1— this is not the kernel the bare-metal evidence was taken on. Both DRM runs ran on7.1.5-arch1-2(docs/drm-bringup.md); the machine has since moved up one release - Mesa:
1:26.1.6-1 - wlroots:
wlroots0.19 0.19.3-1— the built shim linkslibwlroots-0.19.so(readelf -dW shim/build/vitrin-shim). 0.17 and 0.20 are also installed on this machine and are not what is used vitrindrevision:38978f6—v0.1.0-56-g38978f6, the revision of the tree at the last regeneration. Not a self-reportedvitrind --version: nothing was launched to produce this page. Individual runs predate it and name their own where one is recorded — the nested lock-screen run recorded coref9f2b8a, and the second DRM run followed the three fixes incf0e7ff- Machine: one laptop. Intel iGPU on
/dev/dri/card1(i915) drives the only connected output,eDP-1, at 2560x1600 @ 240 Hz, scale 1. A second card (/dev/dri/card2,nvidia) has every connector disconnected and is not in the display path - Host compositor for nested runs: Hyprland 0.56.2-1,
XDG_SESSION_TYPE=wayland
CI cannot produce this page’s contents. A GitHub runner has no DRM device, no seat
and no GPU, so it cannot run vitrind against a panel and cannot run a GUI application
at all. What CI can do, and does, is assert that the checked-in page is byte-identical
to what the generator emits — which catches a hand edit, and catches nothing else.
The measurements themselves come from a human executing the runbook at the bottom of
this page, on the target machine.
How to read a cell
The bar is weak, and here is exactly how weak
Most rows below were scored at one bar: the application mapped a window and repainted. Nothing was typed into it, nothing was clicked, and nothing was checked for correct rendering.
An application can map a window and repaint and still be unusable, because a realm is missing things a desktop application assumes it has:
- No cross-realm clipboard through the app’s own clipboard interface. The shim
advertises
wl_data_device_manager, but seeshim/src/globals.c:217-224: “THIS GLOBAL STILL GRANTS NOTHING ACROSS THE REALM BOUNDARY” — a shim serves exactly one client on exactly one private socket, so both ends of any transfer through it are the same application. What exists across realms instead is a core-mediated channel driven by two physical human chords (WS-E.2.1, D-024), reachable by no client at any verb set. - No portals. No file chooser, no screen share, no opening a link.
- No session bus. A realm has no session D-Bus of its own, so anything that expects one degrades or fails.
- No IME. Nothing here serves
text-input/input-method, so composing text in any non-Latin script does not work. - No XWayland child processes. There is no X server anywhere in this stack, so an application that forks an X11 helper loses that helper.
Where a row was proved by a named task instead — an integration gate, a runbook checklist, an issue’s acceptance criterion — the row names that task, and the task is what the row means. Those rows assert something specific and are much stronger than the weak bar.
The four evidence classes on this page
| Class | What it proves | Where it appears |
|---|---|---|
| Named task | The stated assertion held in a run of the shipped chain | Bar column reads named task: ... |
| Operator drove | A human used the application and the named interactions worked. One person’s session, not a repeatable task — it proves the app was usable that once, by someone who was there | Bar column reads operator drove: ... |
| Weak bar | The application mapped a window and repainted, and nothing else was checked | Bar column reads weak bar |
| Linkage | An ELF/strings measurement of a binary on the machine. Not a run. | The three inventory tables near the bottom |
Linkage is the weakest class here and it has demonstrated false positives in both directions on this very machine — see What this page does not measure. It is published because the question “what on this machine actually needs X11” has no better answer that does not require launching everything, and it is never mixed into the two execution tables.
Desktop applications executed against vitrind
Software a person would daily drive. Every row is a recorded execution against vitrind; there are no inferred rows.
| App | Version | Where it ran | Bar | Observable checked | Outcome | Recorded, and where |
|---|---|---|---|---|---|---|
| Firefox ESR | 140.12.0esr, sha256 3323ee13…f433d92 (pinned by this repo) | headless | named task: tests/integration/test_real_firefox.py | real vitrind execs the real vitrin-shim, which execs this pinned Firefox rendering a local file:// page; the real Python SDK captures a frame through the real enforcement/capture path and asserts its dominant colour is the served #0000ff, and that the globals ledger contains nothing outside shim/docs/firefox-refused-globals.txt. No mock on any seam | met the bar | every PR in CI since 1ebeee2 (2026-07-22) — tests/integration/test_real_firefox.py, shim/docs/firefox.md §7 (the M1.2 milestone proof) |
| alacritty | 0.17.0-1 (installed at inventory) | nested under Hyprland | named task: issue #203 acceptance criterion 1 | “a real toolkit terminal (alacritty) runs to completion under vitrind --nested” — i.e. a live nested run to completion, after the eager-set_mode abort that killed it beforehand | met the bar | 2026-08-06 (the fix landed in af98130 at 16:38:43 +0900) — issue #203, checked acceptance criterion; commit af98130 |
| alacritty | 0.17.0-1 (installed at inventory) | bare-metal DRM/KMS | operator drove: typed into, used as a terminal, and used to launch a second application | the first real desktop application ever run on the bare-metal backend — every earlier DRM row is solid-client, a test client from shim/tests. It mapped as realm-0’s app, took keyboard focus, and the operator typed at its prompt and read the output. It survived the session’s VT switches. --blank-idle blanked and unblanked over it | met the bar | 2026-08-11, during the L1–L7 rung session — /tmp/vitrind-drm-B.log from that session — realm configured … realm=realm-0 command=/usr/bin/alacritty, spawned app pid=…: /usr/bin/alacritty, keyboard focus taken by the app surface, app window mapped: "Alacritty" (Alacritty), and 399 seat-replay: … event=key origin=physical delivered=1 reason=ok lines. Reported by the operator; issue #268 |
| Cursor | not recorded at the time of the attempt (installed at inventory) | bare-metal DRM/KMS | weak bar | launched from the alacritty prompt in realm-0, the same way nautilus was launched a moment later. Nothing appeared on the panel | did not map — it did not open. The cause is not established, and nothing here guesses one | 2026-08-11, during the L1–L7 rung session — operator report. No log covers it — the retained /tmp/vitrind-drm-B.log segment ends before this attempt, and neither the application’s own stderr nor its exit status was kept |
| nautilus | 50.2.2-1 (installed at inventory) | bare-metal DRM/KMS | operator drove: navigated with the mouse, then closed from its own title-bar close button | launched from the alacritty prompt, so it was a second toplevel of realm-0 under the same shim rather than a realm of its own — the only way a second application has ever been started on this backend, since realms come from realm.toml and that file names one. It mapped over alacritty, took the keyboard from it, was navigated by mouse, and closed cleanly on its own close button | met the bar | 2026-08-11, during the L1–L7 rung session — operator report; the mechanism is read out in issue #268 against shim/src/xdg.c and shim/src/seat.c |
| kitty | 0.48.2-1 (installed at inventory) | headless | weak bar | mapped a window and repainted. Nothing was typed into it, clicked, or checked for correct rendering | met the bar | undated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured” |
| Chromium | 151.0.7922.108-1 (installed at inventory) | headless | weak bar | mapped a window and repainted. Nothing was typed into it, clicked, or checked for correct rendering | met the bar | undated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured” |
| Visual Studio Code | visual-studio-code-bin 1.131.0-1 (installed at inventory) | headless | weak bar | mapped a window and repainted. Nothing was typed into it, clicked, or checked for correct rendering | met the bar | undated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured” |
| nautilus | 50.2.2-1 (installed at inventory) | headless | weak bar | mapped a window and repainted. Nothing was typed into it, clicked, or checked for correct rendering | met the bar | undated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured” |
| gimp | 3.2.4-2 (installed at inventory) | headless | weak bar | mapped a window and repainted. Nothing was typed into it, clicked, or checked for correct rendering | met the bar | undated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured” |
| inkscape | 1.4.4-4 (installed at inventory) | headless | weak bar | mapped a window and repainted. Nothing was typed into it, clicked, or checked for correct rendering | met the bar | undated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured” |
| pavucontrol | 1:6.2-1 (installed at inventory) | headless | weak bar | mapped a window and repainted. Nothing was typed into it, clicked, or checked for correct rendering | met the bar | undated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured” |
| xterm | 410-1 (installed at inventory) | headless | weak bar | started in a realm and never mapped: it reported it could not open a display. There is no XWayland anywhere in this stack — not in the core, not in the shim’s advertised global set, not as a process the shim ever execs | did not map — needs an X server, and there is none | undated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured” |
| waybar | 0.15.0-2 (installed at inventory) | headless | weak bar | connected, bound six globals, and never mapped. The shim advertises no zwlr_layer_shell_v1, which is the interface a bar maps through | did not map — there is no zwlr_layer_shell_v1, so it has nothing to map into. Not an X11 gap; owned by WS-E Stage 2, which owns layer-shell | undated; recorded by 7863702 (2026-08-06) — the same row, plus D-021’s cost list in docs/plan/20-decision-log.md, which attaches the measurement: “measured: waybar connects, binds six globals and never maps” |
| rofi | 2.0.0-1 (installed at inventory) | headless | weak bar | started in a realm and never mapped, in the same recorded row as waybar | did not map — there is no zwlr_layer_shell_v1, so it has nothing to map into. Not an X11 gap; owned by WS-E Stage 2, which owns layer-shell | undated; recorded by 7863702 (2026-08-06) — docs/plan/14-workstream-session-mode.md §2 “What already works, measured” |
What these rows over-claim if read quickly:
- Firefox ESR — this is the pinned Mozilla ESR tarball, not a distro package — Arch ships no
firefox-esr, and what is installed on the measured machine isfirefox-developer-edition, which was never run againstvitrind. The row is about 140.12.0esr and nothing else. The commit that first brought Firefox up in a realm,cae70f0(2026-07-20), wired onlyfirefox_bringup.shinto CI — under the mock core and declared SKIPPED withVITRIN_SKIP_FIREFOX_GATE=1— so it is not this row’s provenance. - alacritty — #203 records only
vitrind --nested; it does not name the host compositor, so the venue here is this machine’s host and not something the record states. - alacritty — Nothing was measured. No latency, no frame count, no correctness check on what the terminal drew — the operator used it and reports that it worked. The log retained on disk covers a 60-second segment ending at a VT switch away, so it corroborates the launch, the focus and the keystroke delivery, and does not cover the later part of the session in which #268 was found.
- Cursor — Plausible causes exist and none was checked, so none is recorded: an Electron app needs environment this realm’s
env_allowmay not carry, and the row above shows a second toplevel in a realm is a path with a known defect (#268). Both are guesses. What the corpus has is: it was launched, and nothing appeared. Re-running it with the realm’s stderr captured is what would turn this row into a cause. - nautilus — Closing it broke the session’s keyboard, which is issue #268.
toplevel_unmapclears keyboard focus to nobody, so alacritty — still mapped and still taking pointer events — could not be typed into again. Read this row as “nautilus itself worked” and not as “two windows in a realm work”: the second half of that is a filed bug. Minimize and maximize did nothing, which is not a defect —VITRIN_WM_CAPABILITIESwithholds minimize deliberately and every window is single-maximized at the view size. - kitty — kitty was proved to crash before #203’s fix — the abort was reproduced with alacritty and with kitty. #203’s acceptance criterion names alacritty alone as re-run to completion afterwards, and no record anywhere shows kitty re-run post-fix. So this row is the weak bar and not #203’s named task.
- Visual Studio Code — the recorded row reads “Electron (VS Code — so also Discord, Slack, Obsidian)”. Only VS Code was run; the other three are an inference from a shared runtime and are declined below rather than given rows.
- xterm — the repository holds only the fragment
Can't open display, never the as-emitted line.libXt’s format string isCan't open display: %s, so the real line carries a colon and the display name; those bytes were not captured and are not reconstructed here. - rofi — the six-globals count is recorded for waybar specifically, in D-021; the recorded row groups rofi with it but attaches no separate measurement. rofi also keeps an X11 path, so it appears in the Wayland-mode inventory table too.
Repository test clients executed against vitrind
Clients this repository wrote. Nobody daily drives them, and the strongest evidence in the tree is about them — which is exactly why they are in a separate table. The two checklist runs of the bare-metal backend both used solid-client; the desktop applications that have since run on it (2026-08-11) were driven by hand in one session and are recorded at the operator drove bar, which is not a repeatable task.
| App | Version | Where it ran | Bar | Observable checked | Outcome | Recorded, and where |
|---|---|---|---|---|---|---|
| weston-terminal | weston 15.0.1-3 on the measured machine — NOT the build the cited CI run used | headless | named task: tests/integration/test_real_app.py | real vitrind execs the real C shim which fork/execs weston-terminal; the application’s own frames flow shim → core and are byte-captured, and its identity is read from procfs. The #105 / M1.2 bottom-rung gate, no mock on any seam | met the bar | every PR in CI since e742f25 (2026-07-22) — tests/integration/test_real_app.py (APP_NAME = "weston-terminal") |
| gtk-entry-probe | shim/tests/gtk_entry_probe.c at this revision (built from this repo) | headless | named task: tests/integration/test_real_gtk.py | a real GTK3 GtkEntry client renders a grey toplevel with a white text field headlessly, and its committed frame carries real non-uniform content through the real chain. The gate asserts render, explicitly not input | met the bar | every PR in CI since 1ebeee2 (2026-07-22) — tests/integration/test_real_gtk.py, shim/tests/gtk_entry_probe.c |
| clipboard-peer | shim/tests at this revision (built from this repo) | headless | named task: tests/integration/test_real_clipboard.py | two realms, two real clipboard-peers under two real C shims: the offer chord against an empty slot sends nothing, the promote chord still reaches no other realm, and only the second chord in the realm the output moved to produces offer_selection — after which the receiving application has the string byte for byte through a real wl_data_device transfer | met the bar | every PR in CI since 2f7c7cf (2026-08-08) — tests/integration/test_real_clipboard.py, described in tests/integration/README.md (WS-E.2.1, issue #213) |
| solid-client | shim/tests at this revision, debug build (built from this repo) | bare-metal DRM/KMS | named task: docs/drm-bringup.md observation checklist, first run | first execution of the DRM/KMS backend by anyone: 2560x1600 @ 240 Hz set on card1; the client’s green square drew; the trusted band drew MIRRORED along the bottom edge; VT switch away and back was impossible, so the human could not leave; vitrind at 99.1% CPU over a 471 s run | met the bar | 2026-08-09, on kernel 7.1.5-arch1-2 — docs/drm-bringup.md, first-run record |
| solid-client | shim/tests at this revision, release build (built from this repo) | bare-metal DRM/KMS | named task: docs/drm-bringup.md observation checklist, second run | after the first run’s three fixes: trusted band at the TOP (the mirror is fixed); consent card drawn on the panel and clicked with a real mouse, petition granted, 13 captures served; held-Esc revocation against a LIVE grant (dead_man_triggered held_ms=1005 revoked=1); 5 chorded VT switches honoured; 32.9% CPU release against 99.1% debug; centre pixel read #55aa00ff for a realm configured 00aa55, which is exactly xrgb8888 little-endian | met the bar | 2026-08-09, after the three fixes in cf0e7ff — docs/drm-bringup.md, second-run record |
| input-echo-client | shim/tests at this revision (built from this repo) | nested under Hyprland | named task: shim/docs/nested-lock-screen.md, eight-step by-eye checklist | PASS on steps 1–7 with the client resolving keys through xkbcommon as a real toolkit does (a/b/c → keysym=0x61/0x62/0x63); a held Shift did not postpone the idle lock, and its release while locked was handled | met the bar | 2026-08-09, core f9f2b8a — shim/docs/nested-lock-screen.md, executed record |
What these rows over-claim if read quickly:
- weston-terminal — a real third-party Wayland client with a mock-free gate — stronger evidence than any weak-bar row above — but nobody daily drives it, so it says nothing about a desktop. The cited gate runs on
ubuntu-latestagainst apt’sweston(shim/ci/install-deps.sh), whose version this page does not record. - gtk-entry-probe — this is a GTK3 fixture, not nautilus, gimp or inkscape, and it must not be cited as evidence for those three. No GTK4 or Qt6 fixture exists.
- clipboard-peer — the repository’s own precedent for how to record a substitution honestly: #213 names alacritty and Firefox, neither of which can be made in CI to put a known string on a clipboard without a human’s mouse, so a toolkit-free client stands in and the README says so. Not asserted: that a real chord on real hardware produces any of it.
- solid-client —
solid-clientcommitswl_shmbuffers and is not a desktop application. On the date of this run no desktop application had ever reached bare metal; two did on 2026-08-11, by hand, and their rows are above. The second run’s own open list notes the shim never emits dmabuf, so the zero-copy scanout path is dead code against every real application either way. - solid-client — still
solid-client. Three items stayed open after this run:vitrind’s own log line renders the connector name empty, the shim never emits dmabuf, andrefresh_view_cachecomposes for absent consumers. - input-echo-client — the run found a shipped defect —
TextureKey::currentenumerated every input tocompose_human_visibleexcept the lock, which is a locked session that looked unlocked. Two further warnings: the page states step 7 is weaker than it reads (input-echo-clientis static, so every frame carried the identical digest), and the page contradicts itself by also carrying an empty “not yet executed” record block further down. It was executed.
Failures that are not X11 gaps
Issue #221 asks for this section by name, because a reader counting failures would otherwise fold every one of them into “no X11” and overstate what an X11 shim would buy.
- waybar — there is no
zwlr_layer_shell_v1, so it has nothing to map into. This is WS-E Stage 2, which owns layer-shell. - rofi — there is no
zwlr_layer_shell_v1, so it has nothing to map into. This is WS-E Stage 2, which owns layer-shell.
Issue #203 is closed and fixed on main, and is described here in the past tense.
It belongs in this section because it removed applications from the measurable set for a
reason that had nothing to do with X11: on_new_deco answered an xdg-decoration
request eagerly, and wlr_xdg_toplevel_decoration_v1_set_mode schedules a configure
whose wlr_xdg_surface_schedule_configure asserts surface->initialized — which is
correctly false at new_toplevel_decoration time, because xdg-decoration requires the
decoration object to be created before the surface’s first commit. Every
decoration-aware client therefore aborted the shim at startup. The fix defers the mode
reply to the initial commit; shim/src/globals.c:55-77 carries the reasoning, and
shim/tests/xdg_conformance_client.c’s FACT 4 is the regression test. Firefox never
binds zxdg_decoration_manager_v1 at all, which is the only reason the entire
acceptance suite missed it.
The machine’s X11-only software (linkage, not execution)
Measured on this machine by ELF linkage, not by running anything. These are the applications that would need Phase 3 E3.2 (per-app rootless X server with an embedded window manager) before they could run in a realm at all.
Two entries are here for completeness and are not an argument for E3.2: picom and openbox are an X11 compositor and an X11 window manager, so they are X11-only by definition rather than for want of a port, and running either inside a per-app rootless X server is not a thing anyone wants.
The interim, stated as what it is. Until E3.2 lands, the owner keeps a second session for the software in this table. So “I did not have to reboot into Hyprland” is false for this named set, and the claim must not be made without that carve-out. This is a workaround the owner accepts as a cost, not a mitigation the project offers: nothing in this stack confines that second session, it runs another compositor with full access to the same devices, and switching to it leaves the confined world entirely. See Where this is honest about its limits.
| Binary or app | Version | Wayland mode | What was measured |
|---|---|---|---|
| xterm | 410-1 (installed at inventory) | none exists — there is no Wayland port of xterm | readelf -dW /usr/bin/xterm → libXaw.so.7, libXt.so.6, libX11.so.6, libXmu.so.6, libXext.so.6, libICE.so.6 and no Wayland library. strings -a matching any of libwayland-client, wl_compositor or wayland → 0 hits |
| feh | 3.12.2-1 (installed at inventory) | none — raw Xlib plus imlib2, so there is no toolkit backend to switch | readelf -dW /usr/bin/feh → libX11.so.6, libXinerama.so.1, libImlib2.so.1; no Wayland library in the transitive closure. strings -a wayland matches → 0 |
| nvidia-settings | 610.57.04-1 (installed at inventory) | none — libXxf86vm is an X-server-only extension with no Wayland equivalent | readelf -dW → exactly libXxf86vm.so.1, libjansson.so.4, libX11.so.6, libXext.so.6, libm.so.6, libc.so.6. It does carry nine case-insensitive wayland strings, and they are a trap: every one is an NVIDIA probe symbol — wconn_get_wayland_display, wconn_get_wayland_output_info, libnvidia-wayland-client.so.610.57.04, Wayland Connector Library failed to connect. — for querying outputs under a Wayland session, not a GUI backend. A grep-only method misclassifies this one |
| dmenu | 5.4-1 (installed at inventory) | none — suckless raw-Xlib launcher | readelf -dW /usr/bin/dmenu → libX11.so.6, libXinerama.so.1, libXft.so.2, libfontconfig.so.1, libc.so.6 — no Wayland library, and strings -a finds no Wayland name to dlopen either. Note it would be unusable for two independent reasons: it is X11-only and it is a launcher, which is the layer-shell gap above |
| slock | 1.7-1 (installed at inventory) | none, and none could exist — it locks by grabbing the X server | readelf -dW /usr/bin/slock → libX11.so.6, libXrandr.so.2, libcrypt.so.2, libc.so.6; wayland strings → 0 |
| polybar | 3.7.2-2 (installed at inventory) | none — EWMH and ICCCM are X11 window-manager protocols with no Wayland analogue | readelf -dW /usr/bin/polybar → libxcb-ewmh.so.2, libxcb-icccm.so.4, libxcb-randr.so.0, libxcb-xkb.so.1, libxcb-cursor.so.0 and the rest of the xcb set; wayland strings → 0 |
| lemonbar | lemonbar-git v1.5.r2.g59b0d28-1 (installed at inventory) | none — raw xcb bar | readelf -dW /usr/bin/lemonbar → libxcb.so.1, libxcb-randr.so.0; wayland strings → 0 |
| picom | picom-git 2855_12.197.g6d676824_2026.06.02-1 (installed at inventory) | none, by definition — it is an X11 compositing manager | readelf -dW /usr/bin/picom → libxcb-composite.so.0, libxcb-damage.so.0, libxcb-glx.so.0, libxcb-present.so.0, libxcb-xfixes.so.0, libX11-xcb.so.1; wayland strings → 0. Not an E3.2 requirement: E3.2 gives each X application its own rootless X server, which is not a thing to run a compositor inside |
| openbox | 3.6.1-14 (installed at inventory) | none, by definition — it is an X11 window manager | readelf -dW /usr/bin/openbox → libX11.so.6, libXcursor.so.1, libXinerama.so.1, libXrandr.so.2, libXext.so.6, libSM.so.6, libICE.so.6; the 64-soname transitive closure contains no libwayland-* and wayland strings → 0. Not an E3.2 requirement, for the same reason as picom |
| xsel | 1.2.1-2 (installed at inventory) | none — an Xlib selection client | readelf -dW /usr/bin/xsel → libX11.so.6, libc.so.6, and nothing else; the 6-soname closure contains no libwayland-*. Directly relevant to this workstream: the cross-realm clipboard (D-024) is the project’s answer to this class and xsel cannot participate in it. xdotool and xclip are not installed on this machine |
| OpenJDK desktop AWT/Swing (the system JVM) | jdk-openjdk 26.0.2.u10-1 (installed at inventory) | none — the system JVM ships no Wayland AWT backend | find /usr/lib/jvm -name 'libawt*.so' → exactly libawt.so, libawt_xawt.so, libawt_headless.so, and nothing else. libawt_xawt.so’s closure is libX11.so.6, libXext.so.6, libXi.so.6, libXrender.so.1, libXtst.so.6, libxcb.so.1 with no Wayland library. A capability statement about the runtime; no Swing application was launched |
Software with a Wayland mode, so not an X11 dependency (linkage, not execution)
Measured on this machine by ELF linkage and strings, not by running anything. These are not X11 dependencies: each carries a Wayland path, selected by the switch in the third column. Counting any of them toward the X11 gap would overstate it.
| Binary or app | Version | Wayland mode | What was measured |
|---|---|---|---|
| Chromium | 151.0.7922.108-1 (installed at inventory) | --ozone-platform=wayland, or --ozone-platform-hint=auto | linkage alone would have called this X11-only and been wrong. The 89-soname DT_NEEDED closure carries libX11.so.6 and no libwayland-* at all, because Chromium statically links its own libwayland from third_party. What settles it is strings -a /usr/lib/chromium/chromium, which yields the flag ozone-platform and the Wayland wire-protocol interface names wl_compositor and xdg_wm_base. Corroborated by its weak-bar execution row above |
| Visual Studio Code | visual-studio-code-bin 1.131.0-1 (installed at inventory) | --ozone-platform-hint=auto, already configured by the owner | the strongest non-execution evidence on this page, because the owner has already configured it: ~/.config/code-flags.conf exists and contains, verbatim, --ozone-platform-hint=auto and --enable-wayland-ime under the comment “Native Wayland + text-input-v3 so fcitx5 works without GTK_IM_MODULE”. Linkage is weaker and must be read carefully: /usr/share/code/code links no libwayland-* directly; its 95-soname closure reaches all three only through libgtk-3.so.0, which is GTK’s Wayland and not Electron’s. The binary’s own 5 ozone/Wayland strings are what say the ozone machinery is compiled in |
| Electron runtime (and the Electron applications on it) | electron43 43.3.0-1, plus ten other runtimes (installed at inventory) | --ozone-platform=wayland / --ozone-platform-hint=auto | /usr/lib/electron43/electron links no libwayland-* directly — its 103-soname closure reaches libwayland-client.so.0, libwayland-cursor.so.0 and libwayland-egl.so.1 only through libgtk-3.so.0. What says the ozone machinery is present is strings -a, which matches ozone-platform/wl_compositor/libwayland-client 5 times. /usr/lib/slack/slack measures identically (95-soname closure, no direct Wayland linkage). discord 1:1.0.152-1, obsidian 1.13.4-2 and element-desktop 1.12.23-1 are installed but their real binaries could not be resolved from their launchers without executing them, so they are asserted by runtime family and not measured individually — which is exactly why they are declined rather than given rows |
| alacritty | 0.17.0-1 (installed at inventory) | native — prefers Wayland when WAYLAND_DISPLAY is set, falls back to X11 | the DT_NEEDED closure carries neither libX11 nor libwayland-client: everything is dlopened. strings -a /usr/bin/alacritty yields both libwayland-client.so.0 and libX11.so.6. Corroborated by the #203 run, which happened under vitrind --nested, a stack serving no X11 at all |
| kitty | 0.48.2-1 (installed at inventory) | native, via dlopen | linkage says nothing either way — only 5 sonames in the closure and neither family among them, and /usr/bin/kitty is a small launcher so strings finds neither name. What places it here is that this repository ran it under vitrind, which serves no X11 |
| nautilus, gimp, inkscape (GTK3/GTK4) | 50.2.2-1, 3.2.4-2, 1.4.4-4 (installed at inventory) | GDK_BACKEND=wayland — GTK selects Wayland automatically when WAYLAND_DISPLAY is set | each carries both families, and each by a different route, which is the point. /usr/bin/nautilus links libwayland-client.so.0 directly (145-soname closure, which adds cursor and egl). /usr/bin/gimp-3.2 links none directly; its 115-soname closure reaches all three through GTK. /usr/bin/inkscape is a 19-soname launcher stub (7 direct NEEDED entries) whose GUI lives in /usr/lib/inkscape/libinkscape_base.so, a 147-soname closure carrying all three. GTK carries both backends in one library and picks at run time — which is exactly why shim/docs/firefox.md sets GDK_BACKEND=wayland explicitly, so a stray DISPLAY cannot silently drop the browser onto X11 |
| blender | 17:5.2.0-4 (installed at inventory) | native (GHOST_Wayland), via dlopen | a second case where linkage alone is wrong: the 266-soname closure carries libX11.so.6 and libX11-xcb.so.1 and no libwayland-* at all, yet strings -a /usr/bin/blender matches libwayland-client, wl_compositor or GHOST_Wayland 3 times — Blender dlopens its Wayland backend |
| scrcpy | 4.1-2, on sdl3 3.4.14-1 (installed at inventory) | native via SDL3, which dlopens libwayland-client.so.0 and libdecor-0.so.0 | the clearest false positive in the scan. scrcpy’s direct NEEDED is libavformat, libavcodec, libavutil, libswresample, libSDL3.so.0, libavdevice, libusb-1.0 — so the X11 in its 163-soname closure arrives through libavdevice, which is ffmpeg’s x11grab capture input, not its GUI. Its GUI is SDL3, and libSDL3.so.0 has a 3-soname closure with no hard X11 and no hard Wayland while its strings carry libwayland-client.so.0, libdecor-0.so.0 and wl_compositor |
| openrgb | 1.0rc3-1 (installed at inventory) | QT_QPA_PLATFORM=wayland | readelf -dW /usr/bin/openrgb → libQt5Widgets.so.5, libQt5Gui.so.5 and nothing else relevant: Qt loads its platform plugin by name at run time, which is why the binary shows zero wayland strings. The plugin is installed (qt5-wayland 5.15.19+kde+r55-1; libqwayland-egl.so and libqwayland-generic.so are present) |
| rpi-imager | 2.0.9-1 (installed at inventory) | QT_QPA_PLATFORM=wayland | Qt6 Quick application (libQt6Quick.so.6, libQt6Gui.so.6); qt6-wayland 6.11.1-1 is installed and libqwayland.so sits beside libqxcb.so in the Qt6 platform plugin directory |
| fcitx5-config-qt | fcitx5-configtool 5.1.14-1 (installed at inventory) | QT_QPA_PLATFORM=wayland | Qt6 (libQt6Widgets.so.6, libQt6Gui.so.6) with qt6-wayland installed and libqwayland.so present |
| Android Studio (JetBrains Runtime) | android-studio 2026.1.3.7-1, JBR 25.0.2 (installed at inventory) | a native Wayland AWT backend in the bundled runtime | find /opt/android-studio -name 'libawt_*.so' → libawt_xawt.so, libawt_headless.so and libawt_wlawt.so, against a system JDK that ships no such file. A genuine split worth recording, because it contradicts the system-JDK row above. Not launched, and it was not verified that the backend is enabled by default |
| gamescope | 3.16.25-1 (installed at inventory) | native — it is itself a Wayland compositor, and a Wayland client when nested | links both families directly: a 56-soname closure with libwayland-client.so.0 and libwayland-server.so.0 beside libX11.so.6, libICE.so.6 and libSM.so.6. Named here for two reasons: it is the one piece of the Steam stack on this machine that speaks Wayland natively, and it is the prior art docs/plan/03-phase-3-network-x11-fleet.md cites for E3.2’s per-app-Xwayland design |
| waybar, rofi | 0.15.0-2, 2.0.0-1 (installed at inventory) | native — both link libwayland-client directly | waybar links libwayland-client.so.0 directly; its 108-soname closure adds cursor and egl, and its X11 entries are GTK’s. rofi links libwayland-client.so.0 and libwayland-cursor.so.0 directly while keeping its whole xcb path (libxcb-ewmh, libxcb-icccm, libxcb-randr, libxcb-cursor) in a 60-soname closure, so it classifies as both. Listed here so their failure above is never misfiled as an X11 gap — it is layer-shell. wofi is not installed, so its cell cannot be regenerated on this machine at all |
Where linkage did not settle the question (linkage, not execution)
Linkage contradicted itself and only a run settles these. They are published as explicit unknowns rather than guessed into one of the tables above.
Games are recorded here as a measured dependency of this machine and as nothing else. E3.2’s exit criteria (docs/plan/03-phase-3-network-x11-fleet.md §E3.2) say nothing about games; games additionally need relative pointer, pointer constraints, gamepads and GPU features far beyond E3.2. This row is a measurement, not a commitment, and no schedule anywhere in this repository covers it.
| Binary or app | Version | Wayland mode | What was measured |
|---|---|---|---|
| steamwebhelper (the Steam client UI) | a self-updating CEF build under ~/.local/share/Steam/ubuntu12_64, dated 2026-07-22; the Arch steam 1.0.0.87-1 package ships only a shell wrapper and does not describe what runs (vendor-bundled) | unknown — the executable carries no Wayland path; the library that draws does. Only a run settles it | the executable’s own 36-soname readelf -dW closure carries libX11.so.6, libXi.so.6, libXrandr.so.2, libXcomposite.so.1 and no libwayland-*, and strings -a matches ozone-platform/wl_compositor/libwayland-client 0 times. That reads as settled and is not. Its direct NEEDED entries include libcef.so (219 444 168 bytes, 2026-07-09) and libSDL3.so.0, neither on ldconfig’s path, so the walk stops exactly where the answer is: strings -a on libcef.so matches — ozone-platform, ozone-platform-hint, wl_compositor, enable-wayland-ime, Failed to initialize Wayland platform — and SDL3 dlopens libwayland-client.so.0 (see its row). This is google-chrome’s case one library deeper. Steam has been run on this machine — ~/.local/share/Steam/steamapps holds 7 appmanifests, of which exactly one is a game and the rest are Proton and the Steam Linux Runtimes. No game binary was inspected and no game was run |
| google-chrome | 151.0.7922.71-1 (installed at inventory) | unknown — presumably --ozone-platform=wayland, unverified | the contradiction is the finding. The 71-soname closure of /opt/google/chrome/chrome carries libX11.so.6, libXi.so.6, libXrandr.so.2 and no libwayland-* at all — yet the same binary matches ozone-platform/wl_compositor/libwayland-client 6 times, and Arch’s chromium measures identically (89 sonames, zero Wayland linkage) while demonstrably having a working --ozone-platform=wayland. Linkage cannot settle this one. Only a run can, and nothing was launched |
Requested, and not emitted
These applications are on the list somebody wants covered, and the generator refused
to give them a row because no execution against vitrind is recorded for them. They
are named here rather than dropped silently, so the absence is legible.
- wofi — named in the recorded failing row, but not installed on the measured machine (
pacman -Q wofireports it absent), so no package version can be recorded and the cell cannot be regenerated here even by a live runbook. See the waybar and rofi rows for the same failure. - Discord — the recorded Electron row reads “VS Code — so also Discord, Slack, Obsidian”. That parenthetical is an inference from a shared runtime, not a measurement: no record anywhere shows it executed against
vitrind. See the Electron runtime row in the Wayland-mode table. - Slack — same inferred parenthetical as Discord; never executed against
vitrind. See the Electron runtime row in the Wayland-mode table. - Obsidian — same inferred parenthetical as Discord; never executed against
vitrind. See the Electron runtime row in the Wayland-mode table. - Steam (steamwebhelper) — the owner named Steam and games as a real dependency of this machine. Nothing in the Steam stack has ever been executed against
vitrind, and no schedule in this repository covers games. See its row in the inconclusive table: the client’s windowing path came out unknown, which is a measurement and not a commitment. - google-chrome — the packaged Google build, distinct from Arch’s chromium. Never executed against
vitrind, and its linkage contradicts itself. See the inconclusive table.
What this page does not measure
- Most of the seed rows are undated. The weak-bar rows carry no log, no recorder
dump and no screenshot; the only bound available is the commit that wrote the record
down, which bounds when the row was written, not when the application was run. The
Recorded, and wherecolumn saysundatedwhere that is the case rather than reusing a commit date as if it were a run date. - The verbatim
xtermfailure line was never captured. What this repository holds is the fragmentCan't open display. The real format string inlibXtisCan't open display: %s, so the emitted line has the shape<progname>: <error type>: Can't open display: <display>— but the bytesxtermactually wrote in that realm are gone, and they are not reconstructed here. Capturing them is a runbook step. - Desktop applications have run on bare metal exactly once, by hand. alacritty and
nautilus on 2026-08-11, in one operator-driven session — not a checklist run, not
repeatable by someone who was not there, and measured for nothing. The two runs the
bring-up runbook records both used
solid-client. That session also produced a defect (#268: closing a second window in a realm leaves the keyboard focused on nobody) and one application that did not open at all with no cause established. The named reason to expect further difference still stands, recorded by the second checklist run: the shim never emits dmabuf, so the zero-copy scanout path is dead code against every real application. - Every inventory row is linkage, not behaviour, and linkage has demonstrated false
positives in both directions on this machine. Chromium, Blender, scrcpy, OpenRGB and
rpi-imager all classify X11-only by
DT_NEEDEDand all five have Wayland paths (Chromium statically links its own libwayland; Blender and SDL3dlopentheirs; Qt loads its platform plugin by name). Alacritty and kitty classify as neither, because theydlopeneverything. Method, for reproducibility:readelf -dwalked recursively with sonames resolved fromldconfig -p, plusstrings -a. Neverldd—lddinvokes the dynamic loader and can execute the binary under inspection. - That walk does not follow
DT_RUNPATH, so a private library directory ends it. Measured, not theorised:/usr/bin/inkscapeis a 19-soname launcher stub (7 directNEEDEDentries) carrying neither family, because its entire GUI lives in/usr/lib/inkscape/libinkscape_base.so— a pathldconfig -pdoes not know — whose own closure is 147 sonames and carries all three Wayland libraries. Any row here whose closure looks implausibly small is this case, and the fix is to point the walk at the real library. - The installed set is not the used set. A bulk scan of
/usr/binon 2026-08-10 classified 494 ELF binaries as X11-only, owned by 114 packages, 37 of themxorg-*and 58 explicitly installed. (Method, so the number is reproducible: every non-symlink ELF file in/usr/bin, transitiveDT_NEEDEDclosure viareadelf -dwith sonames resolved fromldconfig -p, counted when the closure contains an X11 soname and nolibwayland-*. The dlopen false-positive above applies to this count too, so it is an upper bound on the X11-only set, not an exact one.) Which of those the owner actually uses is not measured here: no shell history was read, no access times, no launcher history. The requirement list handed to E3.2 comes from the owner naming what he needs, never from ranking a/usr/bin. xlsclientswas empty at inventory time, and that means less than it looks. XWayland is running under the host compositor and the connection genuinely worked (xprop -root _NET_SUPPORTING_WM_CHECKreturned a window id), yetxlsclientslisted zero X11 clients. That is one instant on one day in a session a few hours old. It is not evidence that the owner never runs X11 software — the installed set proves he can — and a truthful version needs sampling over days, which nobody has done.- Steam games themselves are entirely unmeasured. What was measured is
steamwebhelper, the client UI. No game binary was inspected: a Proton title’s windowing path runs through Wine inside a container, which no static scan here reaches. - Nothing was launched to produce this page. No
vitrind, no realm, no nested compositor, no application under test. Every machine row is an inference from bytes on disk, and every execution row is a transcription of a run somebody else recorded earlier. Widening the matrix means executing the runbook below.
Runbook: regenerate this page on the target machine
CI has no DRM device, no seat and no GPU, and structurally cannot run this. It can only assert that the checked-in page matches a regeneration. Everything below is done by a human on the target machine.
0. Safety, non-negotiable
Run every step of section 2 in a nested host window. Never against the DRM/TTY
backend from inside a live session: that takes DRM master and the seat, and kills the
session you are sitting in. Bare-metal runs happen from a spare TTY, with the escape
route rehearsed first — see docs/drm-bringup.md.
1. Re-take the read-only machine inventory
Nothing here launches anything. Record what these print; they are the header fields and the three inventory tables.
uname -srvmo # kernel -> header
pacman -Q mesa wlroots0.19 # mesa, wlroots -> header
git -C "$REPO" describe --tags --dirty # vitrind revision -> header
pacman -Q <app> # one per row, for the Version column
xlsclients -l # X11 clients live in the current session
# Linkage, per candidate binary. NEVER use ldd: it invokes the dynamic
# loader and can execute the binary under inspection.
readelf -dW /usr/bin/<app> | grep NEEDED
strings -a /usr/bin/<app> | grep -iE 'libwayland-client|wl_compositor|ozone-platform'
A binary that shows neither family in NEEDED is not evidence of anything: alacritty,
kitty, Blender, SDL3 and every Qt application load their backend at run time. Check
strings before concluding, and if the two disagree, the row belongs in the
inconclusive table, not in a guess.
2. Run an application in a realm, and record what you saw
Build first:
cargo build --workspace
meson compile -C shim/build
Write a one-realm file naming the application by absolute path:
cat > /tmp/matrix-realm.toml <<'EOF'
[[realm]]
id = "realm-0"
command = "/usr/bin/xterm"
args = []
env_allow = []
EOF
realm.toml refuses a relative command. Then run the core nested, capturing both
streams — the failure you are measuring is usually on the application’s stderr, not in
the core’s log:
./target/debug/vitrind --nested \
--realm /tmp/matrix-realm.toml \
--shim "$PWD/shim/build/vitrin-shim" \
2>&1 | tee /tmp/matrix-xterm.log
Record, for the row:
- The observable you checked. Not “it worked”. Either the specific thing a named
task asserts, or — if all you did was look at it — the weak bar,
mapped a window and repainted, and nothing more. - The verbatim failure line, copied out of the log, if it failed. A fragment is not a quote.
- The package version from step 1, and the date.
- The cause, if it failed: was an X server missing, or was it something else? A
failure with a non-X11 cause goes in
Cause::NotAnX11Gapwith its owner named, or the page will overstate the X11 gap.
3. Land the measurement, and regenerate
Edit crates/xtask/src/session_matrix.rs — never this page. Add the application to
REQUESTED if it is not there, then add an Execution (or a Linkage) carrying the
evidence. The generator refuses a cell whose observable is a bare pass/works/ok,
and refuses an Execution for an application that is not in REQUESTED.
cargo xtask session-matrix # rewrite this page in place
git diff -- docs/book/src/session-app-matrix.md # review what changed
cargo test -p xtask # the generator's own gates
cargo xtask session-matrix --check # what CI runs; must print "no drift"
4. If you want a row and cannot get evidence for it
Add it to REQUESTED with the reason, and leave it declined. It will appear under
Requested, and not emitted with your reason attached,
which is the honest outcome and the one this page is built to make cheap.
The Landlock ABI matrix
PRD §20 says Landlock coverage is kernel-dependent. This page is the table that sentence is checkable against: what this build requires of a kernel’s Landlock, and what each rung of the ABI buys the ruleset on the way.
This build’s two numbers, both read out of the source that declares them rather than typed here:
- floor —
build.landlock_min_abi= 6. A kernel reporting a lower Landlock ABI is refused at startup. It is not confined at a weaker rung. - ceiling —
build.landlock_max_rung= 9. A kernel reporting a higher ABI gets a rung-9 ruleset, journaled asisolation.landlock.clamped_by_build.
Both are printed by vitrind --print-floor.
The ladder below has 9 rung numbers naming 6 distinct enforced domains — that count is computed from the parsed ladder, not asserted, and the rungs that collapse into one domain are named in the domain table.
What this page is a fact about
This build, not your kernel. Nothing here probes anything. The generator runs on a laptop and on a CI runner and must emit the same bytes on both, so it reads the repository and never the machine — and the two machines this repository has actually run report different Landlock ABIs (the development box 9, the CI runner 7), which a probing generator could not have reconciled into one checked-in page.
The machine half is a command you run:
$ vitrind --print-isolation | grep landlock
$ vitrind --print-floor | grep landlock
The first prints what your kernel answers; the second prints the two build
numbers above. The next table says what this build does with each possible
answer. Which kernel releases produce which answer is not stated anywhere on
this page, because it was not measured here — that mapping is a fact about
mainline and about distributions, and this page probes neither. It is measured
on a page of its own: which kernels this build starts on,
from boot rows checked in under tests/kernel-matrix/rows/.
Read your own kernel against it
Every cell below is a property of this build’s own code — spawn::isolation’s
Report::mechanism for the verdict and landlock::apply_with for the second
refusal — with the floor at 6 and the ceiling at 9.
vitrind --print-isolation says | what this build does |
|---|---|
landlock.abi=N with N at or above build.landlock_min_abi and at or below build.landlock_max_rung | Starts. The helper asks for rung N and journals the rung it obtained, the rung it asked for, and the ABI the kernel reported. |
landlock.abi=N with N above build.landlock_max_rung | Starts, at the ceiling. The request is clamped down and the clamp is journaled as isolation.landlock.clamped_by_build. |
landlock.abi=N with N at or above 1 and below build.landlock_min_abi | Refuses to start at --isolation=default, reporting below-floor(abi=N,required=M). The remedy is a newer kernel, explicitly not a sysctl, an lsm= edit or a CONFIG_ change — those are already correct on such a machine. |
landlock.abi=absent(errno=E) | Refuses to start: the kernel does not implement the syscall. Check CONFIG_SECURITY_LANDLOCK and the kernel version. |
landlock.abi=restricted-by-policy(errno=E) | Refuses to start: the kernel has Landlock and something above it said no — most often landlock missing from the active lsm= list. |
any of the above, with --landlock=off on the command line | Starts with no ruleset at all, journaling namespaces-only. It is not a remedy for a kernel that could be upgraded, and no confinement claim on this page applies to such a session. |
any of the above, with --landlock=abi:N on the command line | Pins the request to rung N, including below the floor, because it is the instrument every per-rung measurement in this repository is taken with. A session pinned below the floor warns that no published confinement claim applies to its realms. |
The ladder, one row per ABI rung
what it buys is the right or facility the rung adds. axis is which field of
the request it moves, and it decides whether --landlock=abi:N can simulate a
kernel without the rung: the cap sets handled_access_fs and scoped, so it can;
it does not set the landlock_restrict_self flags word or handled_access_net,
so for those rungs there is nothing for a cap to take away.
| ABI | what it buys | axis | capping simulates it | this build asks for it | handled_access_fs | scoped | vs. this build’s floor | published claim |
|---|---|---|---|---|---|---|---|---|
| 1 | the base access-mask bits — EXECUTE, WRITE_FILE, READ_FILE, READ_DIR, the REMOVE_* pair and the seven MAKE_* bits | handled_access_fs | yes — --landlock=abi:N reproduces its absence | yes | 0x1fff | 0x0 | below the floor — a session refuses to start with below-floor(abi=1,required=6); reachable only through --landlock=abi:1, which warns that no published confinement claim applies | refer-makes-the-cap-a-dial, abi-floor-refuses-below-the-number, sub-floor-rungs-hold-the-dial-not-the-floor |
| 2 | LANDLOCK_ACCESS_FS_REFER | handled_access_fs | yes — --landlock=abi:N reproduces its absence | yes | 0x3fff | 0x0 | below the floor — a session refuses to start with below-floor(abi=2,required=6); reachable only through --landlock=abi:2, which warns that no published confinement claim applies | refer-makes-the-cap-a-dial, sub-floor-rungs-hold-the-dial-not-the-floor |
| 3 | LANDLOCK_ACCESS_FS_TRUNCATE | handled_access_fs | yes — --landlock=abi:N reproduces its absence | yes | 0x7fff | 0x0 | below the floor — a session refuses to start with below-floor(abi=3,required=6); reachable only through --landlock=abi:3, which warns that no published confinement claim applies | truncate-arrives-at-abi-3, sub-floor-rungs-hold-the-dial-not-the-floor |
| 4 | handled_access_net — TCP bind/connect scoping by port | handled_access_net | no — not an access-mask bit | no — the realm’s own network namespace carries that claim structurally and far more completely, since it covers UDP and raw sockets too | 0x7fff | 0x0 | below the floor — a session refuses to start with below-floor(abi=4,required=6); reachable only through --landlock=abi:4, which warns that no published confinement claim applies | net-scoping-is-carried-by-the-namespace, nine-rungs-are-six-domains, sub-floor-rungs-are-not-all-exercised |
| 5 | LANDLOCK_ACCESS_FS_IOCTL_DEV | handled_access_fs | yes — --landlock=abi:N reproduces its absence | yes | 0xffff | 0x0 | below the floor — a session refuses to start with below-floor(abi=5,required=6); reachable only through --landlock=abi:5, which warns that no published confinement claim applies | ioctl-dev-does-not-close-the-render-node, sub-floor-rungs-are-not-all-exercised |
| 6 | the scoped field — SCOPE_ABSTRACT_UNIX_SOCKET and SCOPE_SIGNAL | scoped | yes — --landlock=abi:N reproduces its absence | yes | 0xffff | 0x3 | at or above the floor — a shipped session runs here | scoped-is-defence-in-depth |
| 7 | landlock_restrict_self log flags — LOG_SAME_EXEC_OFF, LOG_NEW_EXEC_ON, LOG_SUBDOMAINS_OFF | landlock_restrict_self flags | no — not an access-mask bit | no — the log flags are observability, not confinement, and no published claim depends on them; the one that is reachable at all is reachable only through the VITRIN_LANDLOCK_AUDIT diagnostic in vitrind’s own environment | 0xffff | 0x3 | at or above the floor — a shipped session runs here | restrict-self-flags-are-not-mask-bits, nine-rungs-are-six-domains |
| 8 | landlock_restrict_self TSYNC — apply the domain to every thread of the caller | landlock_restrict_self flags | no — not an access-mask bit | no — the helper is single-threaded by design and enforces the domain on the one thread that then execves, so its shape already carries what TSYNC would buy | 0xffff | 0x3 | at or above the floor — a shipped session runs here | restrict-self-flags-are-not-mask-bits, nine-rungs-are-six-domains |
| 9 | LANDLOCK_ACCESS_FS_IOCTL_DEV’s ladder successor RESOLVE_UNIX — connect(2) and addressed sendmsg(2) restricted to pathname UNIX sockets | handled_access_fs | yes — --landlock=abi:N reproduces its absence | yes | 0x1ffff | 0x3 | at or above the floor — a shipped session runs here | the-ladder-stops-at-the-build-ceiling |
| 10 | not stated here — this build does not define ABI 10’s rights, and nothing in this repository has measured them | not known to this build | no — not an access-mask bit | no — a build must not name a constant its own headers do not define; a kernel reporting ABI 10 or above is clamped down to this build’s ceiling and the clamp is journaled | not requested by this build | not requested by this build | above this build’s ladder — clamped down to rung 9, journaled as clamped_by_build | the-ladder-stops-at-the-build-ceiling |
The handled_access_fs column is the cumulative mask this build asks a kernel at
that rung for. It is parsed out of handled_access_fs in
crates/vitrin-realm-init/src/landlock.rs and cross-checked against the measured
table pinned in that crate’s the_rung_masks_pin_a_measured_table; the two
readings disagreeing stops this page being emitted at all. The rights arrive in
this order: rung 2 → REFER, rung 3 → TRUNCATE, rung 5 → IOCTL_DEV, rung 6 → the scoped field, rung 9 → RESOLVE_UNIX.
Which rungs are exercised is counted from this table, not asserted. A rung is
counted here when a test in crates/vitrin-realm-init/src/main.rs enters a
Landlock domain at it and asserts the kernel’s own answer — a syscall’s outcome
inside the domain, or the kernel’s verdict on the request. Building a ruleset at a
rung and never entering it does not count.
- rung 1 —
a_realm_can_write_where_it_was_granted_and_nowhere_else,rung_one_forbids_reparenting_that_the_rung_above_permits - rung 2 —
rung_one_forbids_reparenting_that_the_rung_above_permits,the_truncate_rung_is_measured_and_its_absence_is_measured_with_it - rung 3 —
the_truncate_rung_is_measured_and_its_absence_is_measured_with_it - rung 7 —
the_audit_log_flag_is_off_unless_asked_for_and_the_kernel_takes_it
That is 4 of the 9 rungs this build can ask for. The one further row on
this page (ABI 10) is above this build’s ceiling of 9 — a clamp, not a rung it
requests — so it is not in that denominator. Below the floor the tally is the one
docs/book/src/limits.md has to carry word for word:
below the floor of 6, rungs 1, 2 and 3 are exercised and rungs 4 and 5 are not.
Every cell on an unexercised row is derived from this build’s own source and
measured against nothing — keeping the sub-floor tests that exist and adding none
for the rest is decision D-044, not an oversight. Neither the name nor the rung is
remembered. Each name above is resolved against BEHAVIOURAL_RUNGS in that same
file, which declares the rungs that test enters a domain at; a name listed on a rung
it does not enter refuses to render, a rung it does enter and this page omits refuses
to render, and the tests cannot enter a domain without declaring it: the function that
issues landlock_restrict_self demands a token only a recording ledger can mint, and
the mint itself refuses a rung the test’s row does not declare — before the token
exists, so that direction rests on no destructor. What the ledger still checks when it
drops is the converse — a row declaring a rung the run never entered — and Drop is
skippable in Rust by construction, which docs/book/src/limits.md publishes rather
than argues away. The
generator also refuses to emit when the limits page does not carry the tally above.
A test that asks the shipped helper for a rung enters a domain in another process,
where no Rust type can reach it. That route is held separately: the core’s own
confinement suite refuses a spawn reporting a rung this page publishes as entered by
nothing, and tests/integration/ is scanned for a literal abi:N naming one. Both
lists are computed from this corpus, so they move when it does rather than after it.
What each rung does not buy
The column this table exists for. A ladder printed without it reads as though
every rung is pure gain, and the rows below say otherwise on the kernel’s own
terms: rungs 4, 7 and 8 add nothing to the enforced domain of the rung beneath,
counted from the parsed ladder rather than typed here. Rung 1’s row is sharper
still — the absence of REFER makes its domain stricter, not weaker.
| ABI | what it buys | what it does not buy |
|---|---|---|
| 1 | the base access-mask bits — EXECUTE, WRITE_FILE, READ_FILE, READ_DIR, the REMOVE_* pair and the seven MAKE_* bits | REFER, and its absence makes a rung-1 domain stricter: it refuses rename(2) and link(2) across directories even inside the realm’s own writable storage. EXDEV at rung 1 and success at rung 2 are re-taken by a test on every run; rungs 3–9 succeeded in a hand run on 2026-08-14 that nothing since repeats. |
| 2 | LANDLOCK_ACCESS_FS_REFER | a tightening of any kind. Handling REFER is what permits cross-directory rename, which is how GTK and Firefox write files; a ladder read as “higher is tighter” has this rung backwards. |
| 3 | LANDLOCK_ACCESS_FS_TRUNCATE | protection for a path outside every granted write hierarchy, which was never truncatable at any rung. What it adds is that a path the domain grants only READ_FILE on can no longer be emptied by truncate(2), creat(2) or O_TRUNC. |
| 4 | handled_access_net — TCP bind/connect scoping by port | anything this build asks for. handled_access_net stays zero, so the enforced domain at rung 4 is byte-identical to rung 3 — and because the cap moves handled_access_fs, --landlock=abi:3 cannot simulate a kernel without rung 4. |
| 5 | LANDLOCK_ACCESS_FS_IOCTL_DEV | closure of the published render-node limit. The bit is all-or-nothing per hierarchy and the app needs the node’s ioctls, so the ruleset grants IOCTL_DEV on every bound render node and on /dev/pts. What the rung buys is denying ioctl on every other device node in the realm. |
| 6 | the scoped field — SCOPE_ABSTRACT_UNIX_SOCKET and SCOPE_SIGNAL | a claim that rests on it. Both halves are already carried structurally by the realm’s namespaces — abstract sockets are per-netns, and the pid namespace already denies signalling outward — so this rung is defence in depth, and no published sentence would become false without it. |
| 7 | landlock_restrict_self log flags — LOG_SAME_EXEC_OFF, LOG_NEW_EXEC_ON, LOG_SUBDOMAINS_OFF | any access right — and because it is a flag rather than a mask bit, --landlock=abi:6 cannot simulate a kernel without it. There is nothing for the cap to remove from a request that never asked. |
| 8 | landlock_restrict_self TSYNC — apply the domain to every thread of the caller | any access right, and — as at rung 7 — nothing a mask cap can take away. --landlock=abi:7 and --landlock=abi:8 request the same domain. |
| 9 | LANDLOCK_ACCESS_FS_IOCTL_DEV’s ladder successor RESOLVE_UNIX — connect(2) and addressed sendmsg(2) restricted to pathname UNIX sockets | a rung above it that this build knows how to ask for. It travels with every writable hierarchy, because a socket the realm creates for itself — the shim’s wayland-0 among them — must stay connectable to it. |
| 10 | not stated here — this build does not define ABI 10’s rights, and nothing in this repository has measured them | anything, for this build. The clamp is asserted against a constructed ABI value rather than against a machine that reports one — nothing here has run on such a kernel. |
The enforced domains
Two rungs enforce the same domain when this build’s request is byte-identical at
both — handled_access_fs, scoped and the landlock_restrict_self flags word
together. The grouping below is computed from the parsed ladder. applied_profile
still spells every rung differently, so read that string as which rung was
obtained, never as how much confinement.
Each statement is published verbatim on the limits page, so the two can be compared without anyone adjudicating a paraphrase.
| domain | rungs | handled_access_fs | scoped | restrict_self flags | what this domain is |
|---|---|---|---|---|---|
| 1 (T1) | 1 | 0x1fff | 0x0 | 0x0 | handled_access_fs=0x1fff, scoped=0x0: no REFER, so a realm capped at rung 1 cannot rename(2) across directories inside its own writable storage — the one rung that is stricter than the rung above it. |
| 2 (T2) | 2 | 0x3fff | 0x0 | 0x0 | handled_access_fs=0x3fff, scoped=0x0: REFER arrives, and handling it is what permits cross-directory rename inside the realm’s own storage. |
| 3 (T3) | 3, 4 | 0x7fff | 0x0 | 0x0 | handled_access_fs=0x7fff, scoped=0x0: TRUNCATE arrives at rung 3; rung 4 buys handled_access_net, which this build leaves zero, so rungs 3 and 4 are one domain. |
| 4 (T4) | 5 | 0xffff | 0x0 | 0x0 | handled_access_fs=0xffff, scoped=0x0: IOCTL_DEV arrives, and it does not close the render-node limit — the app needs the node’s ioctls, so the ruleset grants them there. |
| 5 (T5) | 6, 7, 8 | 0xffff | 0x3 | 0x0 | handled_access_fs=0xffff, scoped=0x3: rung 6 adds the scoped field; rungs 7 and 8 buy landlock_restrict_self flags rather than access-mask bits, so a mask cap cannot simulate their absence and rungs 6, 7 and 8 are one domain. |
| 6 (T6) | 9 | 0x1ffff | 0x3 | 0x0 | handled_access_fs=0x1ffff, scoped=0x3: RESOLVE_UNIX arrives, and this is the highest rung this build requests — a kernel reporting a higher ABI is clamped here. |
The flags column is zero at every rung because a shipped session passes zero.
The one thing that moves it is VITRIN_LANDLOCK_AUDIT=1 in vitrind’s own
environment, which sets rung 7’s LOG_NEW_EXEC_ON so the kernel keeps logging a
realm’s denials past the shim’s execve. It changes what the kernel writes down,
never what it permits, and it cannot be reached from realm.toml or a command
line — so under it rungs 6 and 7 stop being one domain in the log flags only.
What the ruleset denies that the realm’s mount table does not
A realm is confined by a mount table and a Landlock domain, and most of what the domain refuses the mount table refuses too. Publishing the overlap as though the ruleset earned it would be the flattering direction, so this table is only the difference — and it is short.
| the denial | why the mount table does not already carry it | what has been measured | published claim |
|---|---|---|---|
execve(2) anywhere under /etc | /etc is bound MS_RDONLY, MS_NOSUID, MS_NODEV and with no noexec, so the mount itself permits execution there. Everywhere else the two maps agree: the ruleset grants EXECUTE exactly where the mount table omits noexec. | Nothing measures it. No test in this repository exercises this denial, which makes it the one row here that is prose rather than measurement. Said plainly rather than left implied. | execute-under-etc-is-the-rulesets-own-denial |
Every claim this table carries, and where it is published
A row with a right and no claim, or a claim with no row, stops the generator. Each needle below is checked against the surface it names on every run, so a published sentence cannot be deleted or reworded while this table still cites it.
| claim | what it says | published at |
|---|---|---|
abi-floor-refuses-below-the-number | A kernel reporting a Landlock ABI below this build’s floor is refused at startup rather than confined at a weaker rung, and the number is printed as build.landlock_min_abi. | docs/book/src/limits.md — “build.landlock_min_abi”; README.md — “build.landlock_min_abi”; SECURITY.md — “build.landlock_min_abi” |
sub-floor-rungs-hold-the-dial-not-the-floor | Rungs below this build’s floor are unreachable in production – a kernel reporting one is REFUSED at startup rather than confined weakly – so a behavioural test taken at one of them holds the --landlock=abi:N DIAL honest and not the floor. This row is a rung such a test enters a domain at: it describes no state a stock session can reach, and those tests are the only evidence that this part of the table is not fiction (decision D-044, 2026-08-19). | docs/book/src/limits.md — “hold the dial honest, not the floor” |
sub-floor-rungs-are-not-all-exercised | This rung is below the floor AND no behavioural test enters a Landlock domain at it, so every cell on this row is derived from this build’s own source and measured against nothing – the sub-floor half of the ladder is exercised in part, not throughout. D-044 (2026-08-19) kept the sub-floor tests that exist and deliberately added none, so this row’s status is a decision rather than an oversight. | docs/book/src/limits.md — “exercised in part, not throughout” |
refer-makes-the-cap-a-dial | A domain denies cross-directory rename(2) unless its ruleset HANDLES REFER, so rung 1 is stricter about reparenting than rung 2 – the rung cap is a dial, not a one-way weakening. | docs/book/src/limits.md — “The cap is a dial, not a one-way weakening”; README.md — “dial, not a one-way tightening” |
truncate-arrives-at-abi-3 | Below ABI 3 there is no TRUNCATE right, so a payload that cannot write a file can still empty it – measured at rung 2 succeeding and rung 3 refusing. | docs/book/src/limits.md — “Below ABI 3 there is no TRUNCATE right” |
net-scoping-is-carried-by-the-namespace | ABI 4 buys network scoping, which this build leaves zero because the realm’s own network namespace carries that claim and covers UDP and raw sockets too. | docs/book/src/limits.md — “ABI 4 is network scoping” |
ioctl-dev-does-not-close-the-render-node | ABI 5’s IOCTL_DEV is one all-or-nothing bit per hierarchy and the app needs the render node’s ioctls, so the ruleset grants them there and the published render-node limit survives the rung intact. | docs/book/src/limits.md — “It does not close the render-node limit below.”; README.md — “the ruleset grants it there and this cost is unchanged”; SECURITY.md — “the app needs the node, so the ruleset grants it there” |
scoped-is-defence-in-depth | ABI 6’s scoped field is defence in depth rather than the mechanism behind any published claim: the realm’s network namespace already isolates abstract UNIX sockets and its pid namespace already denies signalling outward. | docs/book/src/limits.md — “ABI 6’s scoped field is defence in depth rather than the mechanism behind either published claim” |
restrict-self-flags-are-not-mask-bits | ABI 7 and ABI 8 buy landlock_restrict_self FLAGS rather than access-mask bits, so --landlock=abi:N cannot simulate their absence and those rungs are prose-backed rather than measurable here. | docs/book/src/limits.md — “landlock_restrict_self flags”; README.md — “byte-identical at rungs 3 and 4 and again at rungs 6, 7 and 8” |
nine-rungs-are-six-domains | Rung numbers and enforced domains are not the same count: rungs that buy nothing this build requests collapse into their predecessor’s domain, while applied_profile still spells every rung differently. | docs/book/src/limits.md — “Nine rung numbers name six different domains” |
the-ladder-stops-at-the-build-ceiling | This build’s ladder stops at its ceiling and a newer kernel is clamped down to it, journaled per realm as isolation.landlock.clamped_by_build; nothing here has run on such a kernel. | docs/book/src/limits.md — “This build’s ladder stops at rung 9” |
execute-under-etc-is-the-rulesets-own-denial | /etc is bound read-only with no noexec, so the mount permits execution there and only the Landlock ruleset refuses it – the one filesystem denial this layer contributes that the mount table does not already carry. | docs/book/src/limits.md — “only this ruleset refuses it, and no test in this repository measures that denial yet” |
What is NOT on this page
- A per-kernel measurement.
docs/plan/02-phase-2-semantic-epochs.md’s restated criteria for P2.6.3 ask for a table generated “fromvitrind --print-isolationoutput on each kernel in the CI matrix, one row per ABI actually reported”. That is not what this is, and it is not a step towards it that was left half-taken: a table carrying the ABI of the machine that generated it cannot be byte-stable across two machines, so it cannot be the thing CI holds. The plan carries that as Correction 5. - Which kernels clear the floor — measured elsewhere, not here. This page
still probes nothing. Since 2026-08-16 the per-kernel measurement exists as its
own artefact: the kernel page, generated from boot logs
checked in under
tests/kernel-matrix/rows/. Read the two together and do not confuse them — this page says what the build requires, that one says what five kernels answered and which of them the floor of 6 admits. Two live machines are also on record: this repository’s development box at Landlock ABI 9 on 2026-08-15, and the runner its CI uses at ABI 7 on 2026-08-14. The runner’s number was read out of a CI job log that archives nothing, and it is corroborated but not replaced by the kernel page: booting the runner’s own6.17.0-1020-azurein a bare initramfs answers ABI 7 too, which is a fact about that kernel and not about that runner. - Any statement that P2.6.3’s criteria were all met as written. The task
(issue #187) was ACCEPTED on 2026-08-19, on its corrected criteria and on
decision D-044 — not on the row the plan first wrote. That date is the plan’s
acceptance record and the owner’s decision; the issue’s own closure timestamp is
whatever GitHub writes when the merge lands, and this page asserts no value for
it. What landed with this page is a
generated ladder of what this build requires, held by CI. A per-kernel row set
landed separately on 2026-08-16 — five kernels, on the kernel
page — and it is a row per kernel, not the “one row per
ABI actually reported” the criteria ask for, a clause no byte-stable checked-in
page can satisfy (the plan carries that as Correction 5). Four things did not
become true on acceptance: five kernels answered five ABIs, and four of
the nine rungs are reported by none of them; every row on that page is a
kernel reading taken in a bare initramfs rather than a distribution; the
behavioural per-rung tests this page’s numbers rest on still live in
vitrin-realm-init’s own suite, running on this repository’s development box and on the CI runner and on no third machine, with the values they pin recorded on one box on one date; and the sub-floor half of those tests is evidence about the--landlock=abi:Ndial rather than about any state a stock session reaches. - The realm’s grant table. Which hierarchies get which rights is the limits page’s two-tier grant list, not a per-rung fact. The only grant-table row here is the one denial the mount table does not carry.
Runbook
To change what this page says, change the code or the published prose — never this file.
$ cargo xtask isolation-matrix # regenerate in place, then review `git diff`
$ cargo xtask isolation-matrix --check # what CI runs; reads only, writes nothing
The generator refuses to emit anything when:
- a pinned line of
crates/vitrin-realm-init/orcrates/vitrin-core/source is gone, so a cell here would describe code that no longer exists; LANDLOCK_MIN_ABIorLANDLOCK_BUILD_MAX_RUNGcannot be read, or the ladder inlandlock.rscannot be parsed — a shape the parser does not recognise is an error, never a rung silently dropped;- the parsed ladder disagrees with the measured mask table pinned in
the_rung_masks_pin_a_measured_table; - a rung row names no published claim, or a published claim is named by no row;
- a claim’s needle is no longer on the surface it cites;
- a domain has no tier statement, or a tier statement is not on the limits page verbatim.
Adding a rung is therefore not an edit to this page: move the right in
landlock.rs, publish what the rung is worth, add the row and its claim to
crates/xtask/src/isolation_matrix.rs, and regenerate.
Which kernels this build starts on, measured
Each row below is a distribution kernel — 5 of them — booted under QEMU with the
shipped vitrind in a minimal initramfs, and this page is their answers. Every cell below is a line that
binary printed on that kernel — not a lookup table of when a Landlock ABI landed in
mainline, and not a claim about the distributions those kernels come from.
The rows live in tests/kernel-matrix/rows/, one file per kernel, holding
vitrind --print-isolation and vitrind --print-floor verbatim plus the startup
line. This page is rendered from them; it measures nothing itself.
This build’s floor is Landlock ABI 6 — build.landlock_min_abi, printed by
vitrind --print-floor. A kernel below it is refused at startup rather than confined
at a weaker rung. What each rung of the ABI buys, and why the floor sits where it
does, is the isolation matrix — a build-static page that
probes no machine. Do not read the two as one document: that page says what this
build requires, this one says what these 5 kernels answered.
The rows’ build half is held to this build, and that is the durable part. cargo xtask kernel-matrix --check reads every row’s own floor.mechanism= and applies.* lines and holds them to the sets declared in crates/vitrin-core/src/spawn/isolation.rs, so the day this build’s floor moves out from under these rows, this page goes RED and names the mechanism that moved. It cannot quietly go on describing an older binary, which is the failure it was built after: the floor grew by two mechanisms and every gate stayed green because the page and the rows were stale together. As rendered, that delta is empty — each row below was collected (2026-08-16) against a build whose startup floor was the same set this tree declares, namespaces, landlock, seccomp, no-new-privs.
Read the scope of that narrowly. This check is cheap and re-boots nothing, so it says the rows describe THIS build and says nothing whatever about whether these kernels still answer this way; only tests/kernel-matrix/collect.sh --check re-takes that half, and every row carries the date it was last taken on.
The measured table
kernel release | shipped by | landlock.abi | ns.all | mount.in_userns | tier | --isolation=default |
|---|---|---|---|---|---|---|
5.15.0-191-generic | Ubuntu 22.04 LTS | 1 | available | available | intra-user | refused — below-floor(abi=1,required=6) |
6.1.0-50-amd64 | Debian 12 (bookworm) | 2 | available | available | intra-user | refused — below-floor(abi=2,required=6) |
6.8.0-139-generic | Ubuntu 24.04 LTS (GA kernel) | 4 | available | available | intra-user | refused — below-floor(abi=4,required=6) |
6.12.101+deb13-amd64 | Debian 13 (trixie), current stable | 6 | available | available | intra-user | starts |
6.17.0-1020-azure | Ubuntu (azure kernel) — what this repository’s CI runners boot | 7 | available | available | intra-user | starts |
So of the five: 2 start and 3 are refused, and the boundary is exactly the floor.
Admitted:
6.12.101+deb13-amd64(Debian 13 (trixie), current stable) —landlock.abi=6, at or above the floor of 6.6.17.0-1020-azure(Ubuntu (azure kernel) — what this repository’s CI runners boot) —landlock.abi=7, at or above the floor of 6.
Refused:
5.15.0-191-generic(Ubuntu 22.04 LTS) —landlock.abi=1, below the floor of 6. The remedy is a newer kernel, and specifically not a sysctl, anlsm=edit or aCONFIG_change: this kernel’s Landlock is present, enabled and answering.6.1.0-50-amd64(Debian 12 (bookworm)) —landlock.abi=2, below the floor of 6. The remedy is a newer kernel, and specifically not a sysctl, anlsm=edit or aCONFIG_change: this kernel’s Landlock is present, enabled and answering.6.8.0-139-generic(Ubuntu 24.04 LTS (GA kernel)) —landlock.abi=4, below the floor of 6. The remedy is a newer kernel, and specifically not a sysctl, anlsm=edit or aCONFIG_change: this kernel’s Landlock is present, enabled and answering.
All 5 rows report ns.all=available and mount.in_userns=available, so the
Landlock floor is the only thing separating the two groups. 4 of this
build’s 9 rungs are reported by none of these kernels — these are 5 machines
somebody might be refused on, not a sweep of the ABI ladder, and no row here says
anything about a kernel that is not in the table.
These are KERNEL rows. They are not distribution rows
Each boot loads a distribution’s unmodified vmlinuz and then runs a minimal
initramfs of this repository’s own making: a static PID 1 (tests/kernel-matrix/init.c),
/vitrind and its library closure, /proc /sys /dev /run mounted, uid 0 with a
full capability set — and no distribution userspace at all. No AppArmor or SELinux
policy is loaded, no /etc/subuid exists, no sysctl file from /etc/sysctl.d has been
applied, no container runtime has adjusted anything.
That is a deliberate choice and it decides what the rows mean. A kernel is the same
kernel wherever it boots, so landlock.abi and the namespace rows are properties of
these bytes. The policy rows are not: they are properties of a running system, and
this one is bare.
The cross-validation that settles it. The last row is the same kernel release the runners this repository’s CI uses report. Booted here it agrees with that runner on the kernel facts and disagrees with it on the policy facts. The left column is read out of the checked-in row; the right column is transcribed from a CI job log:
| cell | this harness, bare initramfs | the CI runner, Ubuntu userspace |
|---|---|---|
landlock.abi | 7 | 7 |
ns.all | available | available |
policy.apparmor_restrict_unprivileged_userns | 0 | 1 |
mount.in_userns | available | restricted-by-policy(errno=13) |
tier | intra-user | none |
provisioning.subuid | absent — an initramfs has no /etc/subuid | whatever the image ships |
Same kernel, same binary, two different answers — and the ones that moved are exactly
the policy cells. So this method can produce a kernel row and can never produce a
distribution row. A distribution row has to come from that distribution, which for
this repository means the runner’s own --print-isolation output, printed by the
What confinement this runner actually grants step in
.github/workflows/ci.yml.
The runner column above is that step’s reading, transcribed from a job log GitHub
expires; it is not an artefact in this tree, and the limits page
publishes that bound.
This is also why tests/kernel-matrix/kernels.manifest has no policy-variant record.
Booting one of these kernels with an AppArmor sysctl flipped on the command line would
produce a row that looks like a distribution row while still being a kernel row with
one knob moved, which is the confusion this whole section exists to prevent.
Ubuntu 24.04 needs the AppArmor profile and is refused anyway
This one is not obvious and it undercuts something this repository shipped, so it is stated plainly rather than left to be inferred from the table.
PR #290 added an AppArmor profile for vitrind (packaging/apparmor/vitrind), because
Ubuntu 24.04 denies the capabilities vitrind needs inside a user namespace it has
already granted — issue #286. That profile is measured working on one kernel on one
CI image — the limits page carries what the apparmor-profile job
reported, and the bound it did not clear. Nothing below depends on which way that
went.
What this page adds is that the profile cannot be sufficient on Ubuntu 24.04’s own
GA kernel, whatever the job reports. That kernel is 6.8.0-139-generic, measured
above at landlock.abi=4 — below this build’s floor of 6. So on a stock 24.04,
even granting the profile everything it is meant to grant:
- the profile is aimed at the namespace refusal, and then
- the isolation preflight refuses the session at the next gate anyway, on the
Landlock floor, with
below-floor(abi=4,required=6).
The two refusals are independent and their remedies are disjoint — no AppArmor policy
changes the number a kernel reports for its Landlock ABI. So on a stock 24.04 a working
profile would change which refusal you get, not whether you get one; the remedy for
the second is a newer kernel. Only a 24.04 running a newer HWE or cloud kernel —
the 6.17.0-1020-azure row above is one, at ABI 7 — is a machine where the profile is
the only thing standing between it and a session.
That is a real qualification on what PR #290 bought, and it belongs beside every
description of the profile rather than only here. The limits page carries
it in the host-must-have-landlock entry.
Why each kernel is in the set
5.15.0-191-generic— Ubuntu 22.04 LTS (5.15-lts). The oldest kernel with Landlock at all that a reader might still be running. It is the bottom of the range: if this build refused nothing else, it would refuse this.6.1.0-50-amd64— Debian 12 (bookworm) (6.1-lts). The previous Debian stable, and the row that shows the floor is not satisfied by “a recent LTS” — 6.1 is a long-term kernel and it is still four rungs short.6.8.0-139-generic— Ubuntu 24.04 LTS (GA kernel) (6.8-lts). The kernel PR #290’s AppArmor profile was written for, and the reason that profile cannot be sufficient on its own: the profile is aimed at the namespace refusal, and this kernel is refused at the next gate anyway, on the Landlock floor. The page’s “AppArmor profile” section is about this row.6.12.101+deb13-amd64— Debian 13 (trixie), current stable (6.12-stable). The row the floor was lowered for (owner’s decision, 2026-08-16). Under the previous floor of 7 this kernel was refused; it reports ABI 6, and the domain this build enforces at rung 6 is identical to the one it enforces at rung 7.6.17.0-1020-azure— Ubuntu (azure kernel) — what this repository’s CI runners boot (ci-runner-kernel). The cross-validation row. It is the kernel release the CI runner reported ON THE COLLECTION DATE, so booting it here says whether this harness reproduces that machine — and, on the policy cells, whether it does not. The runner’s kernel moves: it reported6.17.0-1020-azureon 2026-08-14 and6.17.0-1022-azureby 2026-08-16. Both are ABI 7, so the rung this row cross-validates is unaffected, but do not read the row as naming whatever the runner boots today. GitHub bumps that image with no commit here, which is why this table is dated rather than presented as current.
Provenance, per row
A row is only worth as much as its ability to be re-taken. Each block below carries the bytes that were booted, where they came from, and the command that booted them. The sha256 is the identity and the URL is only where those bytes were found on the collection date: distribution pools prune superseded kernels, so a dead URL is expected eventually and the remedy is another mirror serving the same checksum — never a re-measurement against whatever the pool holds now.
5.15.0-191-generic — Ubuntu 22.04 LTS
| row | tests/kernel-matrix/rows/ubuntu-22.04-5.15.row |
| collected | 2026-08-16 (UTC) |
vitrind version | 0.1.0 |
--print-isolation schema | vitrin-isolation 3 |
| package | http://archive.ubuntu.com/ubuntu/pool/main/l/linux/linux-image-unsigned-5.15.0-191-generic_5.15.0-191.201_amd64.deb |
| package sha256 | 240e407cd863d86dc2eefcf3a085b232849afae7a8c911461369e8c0a02e3f67 |
| vmlinuz | ./boot/vmlinuz-5.15.0-191-generic |
| vmlinuz sha256 | e14e87b3c53124b655207647f695908cafc942cd28871c913ffa4aab712eba93 |
| boot | qemu-system-x86_64 -accel <accel> -m 512 -smp 1 -nographic -no-reboot -kernel <vmlinuz> -initrd <initramfs> -append "console=ttyS0 panic=1 quiet" (<accel> was tcg) |
| userspace | minimal initramfs from tests/kernel-matrix/init.c – static PID 1, /vitrind and its library closure, /proc /sys /dev /run mounted, NO distribution policy loaded, NO /etc/subuid, uid 0 with a full capability set. This is a KERNEL row and never a distribution row. |
The startup line this kernel produced, verbatim:
ERROR vitrind: fatal: this build's isolation floor requires `landlock` and this machine reports `below-floor(abi=1,required=6)`. This kernel has Landlock and reports ABI 1; this build's floor is ABI 6 (owner's decisions of 2026-08-15 and 2026-08-16: declare a floor rather than publish a multi-rung ladder nothing measures, and set it at the lowest rung that gives up no enforcement). Nothing is misconfigured here and no sysctl, LSM list or boot parameter will change the number -- the remedy is a newer kernel. `uname -r` says 5.15.0-191-generic, and `vitrind --print-floor` prints the required number as `build.landlock_min_abi`. This build will not fall back to a lower rung: a realm confined by a weaker domain than the session's own journal names is the silent degradation D-020(6) exists to forbid. `--landlock=off` starts a session whose realms get NO ruleset at all -- it is the positive control this repository's confinement gates run against, not a way to run on an older kernel. Pass `--isolation=off` to start an UNCONFINED session anyway, or -- for a Landlock refusal specifically -- `--landlock=off` to start a session whose realms have namespaces and no ruleset. Both are weaker than what was asked for and both say so in every journal entry. `vitrind --print-isolation` shows every row behind this answer and `vitrind --print-floor` what this build requires.
6.1.0-50-amd64 — Debian 12 (bookworm)
| row | tests/kernel-matrix/rows/debian-12-6.1.row |
| collected | 2026-08-16 (UTC) |
vitrind version | 0.1.0 |
--print-isolation schema | vitrin-isolation 3 |
| package | https://deb.debian.org/debian/pool/main/l/linux/linux-image-6.1.0-50-amd64-unsigned_6.1.176-1_amd64.deb |
| package sha256 | 439422e41d2dbb840b60b81e3bf5e5955bfa56b7a8c268aec83c9ff882d421e6 |
| vmlinuz | ./boot/vmlinuz-6.1.0-50-amd64 |
| vmlinuz sha256 | 653421d9774c0de27502ca010d572323b52a5d7141d067b9b04214bd24baca3a |
| boot | qemu-system-x86_64 -accel <accel> -m 512 -smp 1 -nographic -no-reboot -kernel <vmlinuz> -initrd <initramfs> -append "console=ttyS0 panic=1 quiet" (<accel> was tcg) |
| userspace | minimal initramfs from tests/kernel-matrix/init.c – static PID 1, /vitrind and its library closure, /proc /sys /dev /run mounted, NO distribution policy loaded, NO /etc/subuid, uid 0 with a full capability set. This is a KERNEL row and never a distribution row. |
The startup line this kernel produced, verbatim:
ERROR vitrind: fatal: this build's isolation floor requires `landlock` and this machine reports `below-floor(abi=2,required=6)`. This kernel has Landlock and reports ABI 2; this build's floor is ABI 6 (owner's decisions of 2026-08-15 and 2026-08-16: declare a floor rather than publish a multi-rung ladder nothing measures, and set it at the lowest rung that gives up no enforcement). Nothing is misconfigured here and no sysctl, LSM list or boot parameter will change the number -- the remedy is a newer kernel. `uname -r` says 6.1.0-50-amd64, and `vitrind --print-floor` prints the required number as `build.landlock_min_abi`. This build will not fall back to a lower rung: a realm confined by a weaker domain than the session's own journal names is the silent degradation D-020(6) exists to forbid. `--landlock=off` starts a session whose realms get NO ruleset at all -- it is the positive control this repository's confinement gates run against, not a way to run on an older kernel. Pass `--isolation=off` to start an UNCONFINED session anyway, or -- for a Landlock refusal specifically -- `--landlock=off` to start a session whose realms have namespaces and no ruleset. Both are weaker than what was asked for and both say so in every journal entry. `vitrind --print-isolation` shows every row behind this answer and `vitrind --print-floor` what this build requires.
6.8.0-139-generic — Ubuntu 24.04 LTS (GA kernel)
| row | tests/kernel-matrix/rows/ubuntu-24.04-6.8.row |
| collected | 2026-08-16 (UTC) |
vitrind version | 0.1.0 |
--print-isolation schema | vitrin-isolation 3 |
| package | http://archive.ubuntu.com/ubuntu/pool/main/l/linux/linux-image-unsigned-6.8.0-139-generic_6.8.0-139.139_amd64.deb |
| package sha256 | 6c5e6049c195ac7f8b4cbd2dd96dca4f8cfdd2c0627250c5688fe1ba9caf930f |
| vmlinuz | ./boot/vmlinuz-6.8.0-139-generic |
| vmlinuz sha256 | b500ae87509c77cde64aa9867804e901b410efa3eea41f62d3c766f8c0ee9ab6 |
| boot | qemu-system-x86_64 -accel <accel> -m 512 -smp 1 -nographic -no-reboot -kernel <vmlinuz> -initrd <initramfs> -append "console=ttyS0 panic=1 quiet" (<accel> was tcg) |
| userspace | minimal initramfs from tests/kernel-matrix/init.c – static PID 1, /vitrind and its library closure, /proc /sys /dev /run mounted, NO distribution policy loaded, NO /etc/subuid, uid 0 with a full capability set. This is a KERNEL row and never a distribution row. |
The startup line this kernel produced, verbatim:
ERROR vitrind: fatal: this build's isolation floor requires `landlock` and this machine reports `below-floor(abi=4,required=6)`. This kernel has Landlock and reports ABI 4; this build's floor is ABI 6 (owner's decisions of 2026-08-15 and 2026-08-16: declare a floor rather than publish a multi-rung ladder nothing measures, and set it at the lowest rung that gives up no enforcement). Nothing is misconfigured here and no sysctl, LSM list or boot parameter will change the number -- the remedy is a newer kernel. `uname -r` says 6.8.0-139-generic, and `vitrind --print-floor` prints the required number as `build.landlock_min_abi`. This build will not fall back to a lower rung: a realm confined by a weaker domain than the session's own journal names is the silent degradation D-020(6) exists to forbid. `--landlock=off` starts a session whose realms get NO ruleset at all -- it is the positive control this repository's confinement gates run against, not a way to run on an older kernel. Pass `--isolation=off` to start an UNCONFINED session anyway, or -- for a Landlock refusal specifically -- `--landlock=off` to start a session whose realms have namespaces and no ruleset. Both are weaker than what was asked for and both say so in every journal entry. `vitrind --print-isolation` shows every row behind this answer and `vitrind --print-floor` what this build requires.
6.12.101+deb13-amd64 — Debian 13 (trixie), current stable
| row | tests/kernel-matrix/rows/debian-13-6.12.row |
| collected | 2026-08-16 (UTC) |
vitrind version | 0.1.0 |
--print-isolation schema | vitrin-isolation 3 |
| package | https://deb.debian.org/debian/pool/main/l/linux/linux-image-6.12.101+deb13-amd64-unsigned_6.12.101-1_amd64.deb |
| package sha256 | 56ae4e2dbf5f07214ef5d07f515698f36c62fb63eee97831333f32b659afab66 |
| vmlinuz | ./boot/vmlinuz-6.12.101+deb13-amd64 |
| vmlinuz sha256 | 8f6144580ef6e34459ea0c0819919c292dc605acbd6eac074d8bfef84505a055 |
| boot | qemu-system-x86_64 -accel <accel> -m 512 -smp 1 -nographic -no-reboot -kernel <vmlinuz> -initrd <initramfs> -append "console=ttyS0 panic=1 quiet" (<accel> was tcg) |
| userspace | minimal initramfs from tests/kernel-matrix/init.c – static PID 1, /vitrind and its library closure, /proc /sys /dev /run mounted, NO distribution policy loaded, NO /etc/subuid, uid 0 with a full capability set. This is a KERNEL row and never a distribution row. |
The startup line this kernel produced, verbatim:
INFO vitrind: realms will be confined: each gets its own user, mount, PID, IPC, UTS and network namespace, an identity uid/gid map, zero capabilities, a Landlock ruleset enforced before the shim's execve, and a seccomp-bpf DENY-LIST installed immediately after it. The deny-list is a named-class claim and not a completeness one: `vitrind --print-seccomp` prints every row it closes, and the rest of the kernel's syscall surface is unenumerated -- a realm is filtered against a named list, NOT syscall-confined. No `applied_profile` is printed here on purpose: it names the rung a realm OBTAINED, and no realm exists yet -- the ladder's landing is per-spawn. Whatever it says, it is not a tier name either, because `intra-user` means namespaces PLUS Landlock PLUS seccomp and a profile string names only the Landlock rung isolation=default landlock=highest kernel=6.12.101+deb13-amd64
6.17.0-1020-azure — Ubuntu (azure kernel) — what this repository’s CI runners boot
| row | tests/kernel-matrix/rows/ubuntu-azure-6.17.row |
| collected | 2026-08-16 (UTC) |
vitrind version | 0.1.0 |
--print-isolation schema | vitrin-isolation 3 |
| package | http://archive.ubuntu.com/ubuntu/pool/main/l/linux-azure/linux-image-unsigned-6.17.0-1020-azure_6.17.0-1020.20_amd64.deb |
| package sha256 | 0b6fc9fd94bf375fad1f83fe0672ab72152def473d1fd93c50b07b7452942faa |
| vmlinuz | ./boot/vmlinuz-6.17.0-1020-azure |
| vmlinuz sha256 | 04d18f600df726d196e6dffd22338657f6702f25a4b3512b400b77640d148c59 |
| boot | qemu-system-x86_64 -accel <accel> -m 512 -smp 1 -nographic -no-reboot -kernel <vmlinuz> -initrd <initramfs> -append "console=ttyS0 panic=1 quiet" (<accel> was tcg) |
| userspace | minimal initramfs from tests/kernel-matrix/init.c – static PID 1, /vitrind and its library closure, /proc /sys /dev /run mounted, NO distribution policy loaded, NO /etc/subuid, uid 0 with a full capability set. This is a KERNEL row and never a distribution row. |
The startup line this kernel produced, verbatim:
INFO vitrind: realms will be confined: each gets its own user, mount, PID, IPC, UTS and network namespace, an identity uid/gid map, zero capabilities, a Landlock ruleset enforced before the shim's execve, and a seccomp-bpf DENY-LIST installed immediately after it. The deny-list is a named-class claim and not a completeness one: `vitrind --print-seccomp` prints every row it closes, and the rest of the kernel's syscall surface is unenumerated -- a realm is filtered against a named list, NOT syscall-confined. No `applied_profile` is printed here on purpose: it names the rung a realm OBTAINED, and no realm exists yet -- the ladder's landing is per-spawn. Whatever it says, it is not a tier name either, because `intra-user` means namespaces PLUS Landlock PLUS seccomp and a profile string names only the Landlock rung isolation=default landlock=highest kernel=6.17.0-1020-azure
One cell is normalized, and it is named
policy.max_user_namespaces is derived from guest memory and tracks the size of the
compressed initramfs, so it moves whenever vitrind’s binary changes size — which is
most commits, including ones that touch nothing here. Publishing it raw would make
every unrelated change look like a kernel behaving differently. The rows therefore
replace that one value with a placeholder in the compared body and keep the raw
reading in the row’s own header, where --check ignores it. It is the only cell that
gets this treatment, and every other line of every row is compared byte-for-byte.
Runbook
No pull request boots a kernel. Collecting the rows needs QEMU, roughly 220 MiB of downloaded kernel packages and about fifteen seconds of emulation, and wiring that into per-PR CI would buy a check on something that changes when a distribution ships a kernel — not when this repository changes. It is a command a person runs, and the rows carry a collection date so a reader can see how stale the answer is.
# Re-measure every kernel and rewrite tests/kernel-matrix/rows/. Needs qemu.
$ cargo build --release --bin vitrind
$ tests/kernel-matrix/collect.sh
# Re-measure and DIFF against what is checked in; writes nothing. Red if a kernel
# now answers differently, or if a row is older than 180 days.
$ tests/kernel-matrix/collect.sh --check
# One kernel only. Says loudly that it is a partial run.
$ tests/kernel-matrix/collect.sh --only debian-13-6.12
# Re-render THIS PAGE from the checked-in rows. No qemu, no network.
$ cargo xtask kernel-matrix
$ cargo xtask kernel-matrix --check # what CI runs on every pull request
The two --checks prove different things and neither substitutes for the other:
cargo xtask kernel-matrix --check holds this page to the rows, and
collect.sh --check holds the rows to the kernels. A green pull request means
the first one passed. It says nothing about whether these kernels still answer this
way.
A failed boot never produces a row. The collector requires its init’s sentinels, a
zero exit from each probe, a schema-tagged and complete reading, exactly one startup
verdict, and a kernel.release equal to the one the manifest pins — and it prints
FAIL: and exits nonzero on any of them. There is no branch in it that emits an empty
cell, a default, or an “unmeasured” that reads like an answer.
What is NOT on this page
- A distribution matrix. See the section above: every row here ran with no
distribution policy loaded. “Does vitrind run on Ubuntu 24.04” is not answered by
the
6.8.0-139-genericrow alone — that row answers “does this build’s floor admit Ubuntu 24.04’s GA kernel”, and the answer is no. - One row per ABI rung. Five kernels reported five distinct ABIs; four of this build’s nine rungs are reported by none of them. The per-rung behaviour table is the isolation matrix, which is generated from source and measures no machine.
- A claim that these rows are current. Each carries a collection date. Nothing in
a pull request re-boots them, which is a deliberate scope decision and not an
oversight;
collect.sh --checkis how the claim gets re-taken, and it goes red on a row older than 180 days when somebody runs it. - Anything about non-x86-64, or about kernels not in the table. No row here says where between ABI 5 and ABI 6 a given mainline release sits. That mapping is a fact about mainline, and this page publishes measurements rather than lookups.
Where this is honest about its limits
Phase 1 is complete. That is a statement about a defined slice closing on named, mock-free gates — not a statement that this is ready for anything real. This page is the whole list, in one place, so you never have to discover an item on it yourself.
Do not deploy this yet
The sandbox is half-built, and which half you have depends on a flag. Decision D9, then D-020 and D-036. Since P2.6.2 a realm is spawned into six namespaces — user, mount, PID, IPC, UTS and network — with an identity uid/gid map, zero capabilities, and a private mount table the app cannot reshape. The core verifies all of that from outside, by reading the kernel’s answer about the child, and refuses the spawn when it cannot.
Since P2.6.3 the realm gets a Landlock ruleset, enforced immediately before
the shim’s execve and inherited by every process the realm ever runs. The
task’s second deliverable — a generated ladder table with a CI staleness gate
— now exists too: the Landlock ABI matrix, emitted by
cargo xtask isolation-matrix and held byte-for-byte by CI. It is not the
per-kernel table the task’s restated criteria describe, and the difference is
not a detail: it publishes what this build requires of a kernel’s Landlock,
one row per ABI rung, and it probes nothing. The row-per-ABI-actually-reported
half now exists separately, and is measured: which kernels this build
starts on carries five distribution kernels booted under
QEMU with the shipped vitrind, reporting ABI 1, 2, 4, 6 and 7 — three refused
below the floor, two admitted. Read the two pages as what they each are: one
says what this build requires of a kernel, the other says what five kernels
answered.
What has not grown is the number of machines on which the suite itself has
ever run, which is still exactly two. A kernel row is one binary printing its
isolation report in a minimal initramfs; it is not a run of run.sh, and it is
not a statement about the distribution that ships that kernel. The remaining
gap is narrower than it was and is its own entry below.
The grant set is two tiers, and one sentence about “the write set” gets it
wrong in the flattering direction — an earlier draft of this page said “its
write set is exactly the four hierarchies the mount table already publishes”,
and that was false. Read from crates/vitrin-realm-init/src/landlock.rs’s
grants, which is the only authority:
- Full write authority — create, delete, rename, truncate — on exactly the
four hierarchies the mount table publishes as writable:
/run/vitrin,/vitrin/home,/tmpand/dev/shm. WRITE_FILEand nothing else on four more:/proc(real software writes its own/proc/self/*),/devand/dev/pts(writing through a device node, never creating one —/devis a read-only mount by the time the ruleset is built), and every bound render node. So the ruleset requestsWRITE_FILEon eight hierarchies, not four — and eight is the count on a host with one render node, sincerender_nodesis a list and each entry is its own rule, so a two-GPU host is nine. None of those four carriesTRUNCATE, anyMAKE_*, anyREMOVE_*orREFER— nothing there can be created, deleted, renamed or emptied — but “the write set is the four writable mounts” is still the wrong sentence, and the difference is exactly the kind a reader is entitled to have stated rather than to find in the source.- The read set is enumerated rather than granted at the realm root, and the
execute half is narrower than the read half. Read+execute:
/usr, the/bin-class compatibility names, the shim binary, the app’s own directory, everybindsentry, and/tmp(whose mount is deliberately notnoexec). Read only, no execute:/etcand/sys. Read+write, no execute:/procand/dev./dev/ptsadditionally carriesIOCTL_DEV, and each bound render node carries read+write+IOCTL_DEV(see below). Nothing else is granted at all — a read of a path the mount table happened to leave reachable but nothing granted now failsEACCESrather than succeeding. Three of those rows are a file rather than a hierarchy — the shim binary, anybindsentry naming a file, and every render node (a character device) — and they are granted without the directory-list right, because the kernel refuses a rule that namesLANDLOCK_ACCESS_FS_READ_DIRon anything that is not a directory. Refuses the rule, not merely the right: measuredEINVALhere (ABI 9, 2026-08-14 for the regular file, 2026-08-15 for the render node), and since every one of those rules is required and the helper fails closed, granting any of the three the wrong rights takes every realm on the box down at the shipped default — which is how both were found. /etcis the one place the ruleset denies something the mount table does not. Everywhere else the two maps agree on execution: the ruleset grantsEXECUTEexactly where the mount table omitsnoexec./etcis boundMS_RDONLY|MS_NOSUID|MS_NODEVwith nonoexec, so the mount permitsexecve(2)there and only this ruleset refuses it, and no test in this repository measures that denial yet. It is the only row of the ABI matrix’s “what the ruleset denies that the mount table does not” table, and that page prints the same unmeasured status beside it — an unmeasured denial is prose, and is published as prose.
And “nothing else” is a small set — which is the honest half of that
sentence, and it is published because the enumeration is the stronger-sounding
claim. Re-collected on this repo’s box (Arch, kernel 7.1.9-arch1-2, Landlock
ABI 9, 2026-08-23) with solid-client --probe over 40 distinct in-realm
paths, in batches of eight, each batch run twice — shipped default and
--landlock=off, byte-identical realm.toml asserted between the pair, and at
least one path per batch reachable in both runs so a report from an app that
could open nothing cannot satisfy a denial. With the app relocated out of the
build tree, into a /tmp directory that the mount table and the ruleset both
already carry, three paths were refused EACCES at the default and opened
at --landlock=off:
denied at the default, reachable at --landlock=off | what it is |
|---|---|
/ | the realm root |
/run | the parent of /run/vitrin |
/vitrin | the parent of /vitrin/home and of the shim binary /vitrin/vitrin-shim |
All three are directories the realm’s own mount table created on its root
tmpfs purely to hold a bind target beneath it, and each holds nothing but the
next component of that path. Every other probed path answered identically at
both settings: /usr, /usr/bin, /usr/lib, /usr/lib64, /etc, /proc,
/proc/self, /sys, /tmp, /dev and its nodes (/dev/null, /dev/zero,
/dev/urandom, /dev/ptmx), /dev/shm, /dev/pts, /dev/dri,
/vitrin/home, /vitrin/vitrin-shim, /run/vitrin, the /bin-class names
(/bin, /sbin, /lib, /lib64), files inside them (/bin/sh,
/usr/bin/env), and the app’s own directory and binary all opened in both;
/dev/tty answered ENXIO in both, which is a realm with no controlling
terminal and not a ruleset denial, and /dev/input answered ENOENT in both,
which is the mount table and not the ruleset.
The /home chain this page used to publish is gone, and it was never a
denial the design produced. The eight-row table carried here until 2026-08-23
was collected with the app running from a build tree, and five of its rows were
that tree’s own ancestors — minted on the realm’s root tmpfs solely to hold the
app-directory and shim-library bind targets beneath them. With the app
relocated, /home and every component under it answer ENOENT at both
settings: they do not exist inside the realm at all. The same batches were
run again in the same session with the app left in the build tree (39 distinct
paths there rather than 40, because two slots name the app’s own directory and
collide with the build tree’s own), and that run still denies /, /run,
/vitrin, /home, /home/<user> and every
further ancestor of the app’s own directory — a count that is a function of how
deep the checkout is and of nothing else, which is why the relocated run is the
one published above and why the old table’s “eight” was a fact about a
pathname.
That also settles a prediction this page carried and labelled as one.
#283 renamed the shim’s
bind target /vitrin/shim → /vitrin/vitrin-shim and removed the
shim-library bind by linking anything the shim vendors statically, and the page
predicted four denials afterwards — /, /run, /vitrin and /home. The
measurement says three: /home is not denied, it is absent. The prediction
was wrong, in the direction that overstated the boundary, and it is deleted
rather than left standing beside the run that replaced it.
Nothing in CI re-collects this and nothing will notice when it has aged again. It is a measurement session someone sits through — twenty core starts across the two app locations, two isolation settings each — not an edit, and what a reader is checking this page against is a run rather than a gate. It belongs to #187, the issue this ruleset and this table come from.
So what the enumeration buys over the realm-root grant #187 declined is, on this
host, that the realm cannot list its own root and cannot list the handful of
empty directories its mount table had to mint. It denies no path that
carries data, because the mount table already puts host content nowhere but at
a bind target and every bind target is granted. That is a narrower thing than
“the read set is enumerated” sounds like, and it is the measured one. It is not
nothing — / is exactly the directory a nested sandbox enumerates before
binding, and test_real_confinement.py holds that denial as a gate — but a
reader sizing residual risk should size it at a handful of empty directories,
not at a filesystem.
Since P2.6.4 (#188)
there is a seccomp filter, and it is a DENY-LIST — a
named-class claim, never a completeness claim. vitrin-realm-init installs
a classic-BPF program immediately before the shim’s execve, so the shim and
every process it forks inherit it and cannot remove it. What it closes is the
list vitrind --print-seccomp prints: 13 denied syscall rows today, each
naming the PRD Doc 2 §15 escape class it answers and the errno it returns. What it leaves
open is everything else, unenumerated — this build does not know Firefox’s
syscall surface, and an allow-list built without a measured trace would fail
closed against the project’s own acceptance app. So a realm is now
syscall-filtered against a named list and is not “syscall-confined”: the
residual surface is the kernel’s whole surface minus 13 denied syscall rows,
and nobody here has counted what that leaves.
Read that beside the Landlock sentence above, because the two are the same shape and P2.9.4’s cross-check compares them: Landlock’s read set is enumerated and denies a handful of empty directories; seccomp’s deny set is enumerated and denies thirteen syscalls. Neither is a boundary around “everything an app might do”. Both are lists, and a list is exactly as large as it is.
Four things about that filter that a reader must not have to infer:
- It answers two of §15’s eight actor rows, and part of a third. The two
are Compromised shim — the only §15 row that names seccomp at all — and
Malicious app in a shim, at its kernel-attack-surface half. The third is
Reachable-service lateral escape, and it is answered at two services PRD
Doc 2 §4.5’s own “there is simply nothing to reach” sentence does not
cover: the operator’s kernel keyring, and
AF_VSOCK. The remaining five rows — ransomware, hijacked agent, malicious agent client, malicious relying app, impersonating publisher — are answered by mechanisms that are not seccomp, and by nothing in this filter. - 11 of the 13 denied syscall rows are DEMONSTRATED on the kernel this was
measured on; two are not.
tests/integration/test_real_seccomp.pyruns the same probe binary inside a realm and at--isolation=off, and a row whose syscall already fails outside a realm is reported not demonstrated rather than counted as confinement.bpfanduserfaultfdland there on a box withkernel.unprivileged_bpf_disabledorvm.unprivileged_userfaultfdset — the denial is real, and on that machine it is not confinement this filter adds. Which rows are demonstrated is a property of the kernel, so it is measured per run and printed, never declared. - A realm cannot execute a foreign-ABI binary. Syscall numbers are
per-ABI, so a process running under i386 or x32 on an x86-64 kernel meets a
table whose numbers mean other syscalls. The filter kills it
(
SECCOMP_RET_KILL_PROCESS) rather than passing it unfiltered. A 32-bit app in a realm dies withSIGSYSon its first syscall. - The crash reporter is the acceptance app’s casualty, and the gate does not
cover it. The
ptracerow denies the pinned Firefox’s minidump writer.test_real_firefox.pysetsMOZ_CRASHREPORTER_DISABLE=1, so that gate goes green without exercising the path this row breaks. The green tick is not evidence for that row, and this bullet exists so nobody reads it as one.
Timing, measured rather than asserted (R2.8). Three acceptance gates, 7
runs each, on Arch 7.1.8-arch1-3, 2026-08-16, against a control build
identical except that it does not install the filter: test_real_app.py
2.139 s vs 2.154 s, test_real_gtk.py 1.722 s vs 1.717 s,
test_real_firefox.py 2.943 s vs 2.947 s (medians). Every difference is
smaller than the run-to-run spread of either arm, so the honest statement is
no change measurable at this resolution — not “negligible”, which is a
claim about magnitude this measurement cannot make. Note also what is not
measured here: installing a seccomp filter enables the kernel’s speculative
store bypass mitigation for the process unless SECCOMP_FILTER_FLAG_SPEC_ALLOW
is passed, which this build does not pass. On hardware where that mitigation
costs, the cost is real and no gate here would see it.
What that ruleset is, and what it is not, stated rather than left to be inferred from the word “Landlock”:
-
There is a declared ABI floor, and it is not a ladder (owner’s decision, 2026-08-15; the number lowered a rung on 2026-08-16). This build targets recent kernels: a kernel reporting a Landlock ABI below
build.landlock_min_abi— printed byvitrind --print-floor, and 6 in this build — is refused at startup, with a refusal that names the number it found, the number it needed, and the fact that no sysctl, LSM list or boot parameter changes either. It does not fall back to a lower rung: a realm confined by a weaker domain than the session’s own journal names is the silent degradation D-020(6) exists to forbid. That is the fourth host requirement in thehost-must-have-landlockentry below. Why 6, and why moving it down from 7 gave up no enforcement. The floor was 7 for one day. It is 6 because 6 is the lowest rung at which the domain this build actually enforces is unchanged: the enforced triple —handled_access_fs,scoped, and thelandlock_restrict_selfflags word — is identical at rungs 6, 7 and 8, because the only thing rungs 7 and 8 buy is flags (audit logging,TSYNC) and every shipped run passes flags = 0. Rung 5 differs (it is below wherescopedarrives), which is why the floor cannot go lower without giving something up, and rung 9 differs too (it addsRESOLVE_UNIX) — so no page here says the domain is identical from 6 to 9. The floor decides admission, never which rung is applied: the rung a realm gets is stillmin(kernel ABI, build ceiling), so a machine that supplied rung 9 before supplies rung 9 now. All three facts are asserted, not narrated, bythe_floor_costs_nothing_because_the_domain_is_flat_from_six_to_eightincrates/vitrin-realm-init/src/main.rs. Which kernel releases the floor excludes IS measured now, and it is measured on kernels rather than inferred from mainline changelogs — five distribution kernels were booted under QEMU with the shipped binary and their answers are checked in: Ubuntu 22.04’s5.15.0-191-genericat ABI 1, Debian 12’s6.1.0-50-amd64at ABI 2 and Ubuntu 24.04’s GA6.8.0-139-genericat ABI 4 are refused; Debian 13’s6.12.101+deb13-amd64at ABI 6 and the azure kernel this repository’s CI runners boot at ABI 7 start. See the kernel page for the rows, their provenance, and why they are kernel rows and not distribution rows. Two live machines are also on record and are a different kind of evidence: this repository’s development box (Arch,7.1.8-arch1-3) answerslandlock.abi=9, and the GitHub runner its CI uses answeredlandlock.abi=7on 2026-08-14 — that second number lives only in a job log, read out of CI’s own diagnostic step, and no checked-in artefact carries that runner measurement; thehost-must-have-landlockentry below states the bound. (The kernel page boots the same kernel release and also reads 7, which corroborates the number without making it a fact about that runner — the policy cells differ.) What a checked-in file does carry, since issue #288, is the claim:.github/workflows/ci.yml’srustjob setsVITRIN_REQUIRE_LANDLOCK_ABI: "7", which turns every Landlock rung measurement at or below 7 from a test that may skip into a test that must run. That does not re-take the measurement and must not be read as one — it asserts it, so a runner image that dropped below 7 would turn the job red instead of skipping five measurements quietly, which is what those five did before. Note the two numbers are now deliberately different: the build floor is 6 and the CI require-variable is 7, because the second is a statement about the runner’s kernel and not about what this build needs. The floor narrowed P2.6.3 rather than completing it, and what completed it was other work plus a decision — not this. PRD §20’s “coverage is kernel-dependent” caveat is answered for those five kernels and for no others: five kernels reported five ABIs and four of this build’s nine rungs are reported by none of them, so the per-rung table is still generated from source rather than measured on machines. The plan document carries that correction in as many words. -
The rung matters, and the rung obtained is what is published. A Landlock ABI rung is which access rights the kernel will police at all. The helper asks for the highest rung this build knows that the kernel accepts, and journals the rung it got, the rung the session asked for, and the ABI the kernel reported. The one-rung-at-a-time descent still exists for a kernel that reports ABI N and then refuses rung N’s mask, and it bottoms out at the floor rather than walking to rung 1.
applied_profilenames the rung obtained —namespaces+landlock-abi9on this repo’s own box — so a session that landed a rung below what it asked for cannot render like one that did not, and the core logs a WARN per spawn whenever the obtained rung is below the request or below the kernel’s own ABI, naming both numbers and the rights that moved between them. Below ABI 3 there is noTRUNCATEright, so a payload that cannot write a file can still destroy it — measured, not asserted: at rung 2 atruncate(2)on a read-granted file succeeds and the file goes to zero; at rung 3 the same call failsEACCESand the file survives.--landlock=abi:Npins a session to rung N so each rung’s absence can be measured on a modern kernel — for the rungs that move the mask, which is not all of them; see the rung-4/7/8 bullet below — and warns at startup. The cap may still be set below the floor, and that is deliberate: it is the instrument every per-rung measurement on this page is taken with, and a kernel that reported the same rung would be refused. A session pinned there warns in as many words that no confinement claim this build publishes applies to its realms.--landlock=offbuilds no ruleset at all and journalsnamespaces-only. -
The cap is a dial, not a one-way weakening, and rung 1 is stricter about reparenting.
--landlock=abi:Nreproduces exactly what this build asks for on an ABI-N kernel, which includes reproducing that kernel’s strictness — but read that sentence with the exception two bullets down, because it is not the same as reproducing an ABI-N kernel. A Landlock domain denies reparenting —rename(2)andlink(2)across directories — whenever its ruleset does not handleREFER, and no ruleset below ABI 2 can. So a realm capped at rung 1 cannot move a file between two directories inside its own writable storage, and every rung above it can. Measured on this repo’s box (kernel7.1.8-arch1-3, Landlock ABI 9, 2026-08-14) with the realm’s whole writable set granted on one hierarchy: rung 1 answersEXDEV, rungs 2–9 succeed, and a same-directory rename succeeds at every rung. Two of those rungs are re-taken on every run and the rest are not, which matters because the next bullet says no test in this repository enters a domain at rung 4 or rung 5.rung_one_forbids_reparenting_that_the_rung_above_permitsenters a Landlock domain at rung 1 and at rung 2 and at no other — that is declared inBEHAVIOURAL_RUNGSand asserted by the test itself against the rungs it actually entered — so rungs 3–9 above, and the same-directory result anywhere but rung 1, are a hand run on that one date: checked by a reader, and by nothing in CI. Practically,--landlock=abi:1breaks every app that writes by rename-into-place (GTK, Firefox). Do not read the ladder as “higher is always tighter”; read it as “rung N is ABI N”. -
Some of the rungs below the floor are exercised, on purpose: those tests hold the dial honest, not the floor. The rest are exercised by nothing, and this page says which is which rather than leaving it to be inferred. Three behavioural measurements in
crates/vitrin-realm-init/src/main.rsenforce a domain at rung 1, 2 or 3 —rung_one_forbids_reparenting_that_the_rung_above_permits,the_truncate_rung_is_measured_and_its_absence_is_measured_with_itanda_realm_can_write_where_it_was_granted_and_nowhere_else. Every one of those rungs is belowbuild.landlock_min_abi, so a kernel reporting one is refused at startup rather than confined weakly, and no shipped session has ever run at any of them. They are kept deliberately — decision D-044 (2026-08-19), taken as a dated decision precisely because this task’s previous narrowing was settled by attrition and reached these pages as a deferral nobody could date. The reasons are two, and neither is “deleting tests feels wrong”: they are the only evidence that any part of the ABI matrix’s lower half is not fiction, that page being derived from this build’s own source and observing no kernel answering anything; and theREFERresult in the bullet above — rung 1 being stricter than rung 2 — cannot be read off the mask column at all, which is why two tests asserting the opposite invariant were replaced when it was measured. What they are not evidence about is said in the same breath: not the floor, not any confinement claim this build publishes, and not any state an operator running a stock build can reach. Nor are they evidence about a kernel: which kernels report which ABI is the kernel page and its checked-in boot rows, measured elsewhere and by other means.And the sub-floor rungs the three do not reach are exercised by nothing at all. Counted from the same list the generator checks:
below the floor of 6, rungs 1, 2 and 3 are exercised and rungs 4 and 5 are not.
So the sub-floor half of the ladder is exercised in part, not throughout. No test in this repository enters a Landlock domain at rung 4 or rung 5, so every cell on those two rows of the matrix is derived from this build’s own source and measured against nothing. (The 2026-08-14 hand run in the
REFERbullet above did pass through those rungs once and left nothing behind that re-takes it; a measurement nobody can re-run is not coverage.) That is a decision rather than an oversight: D-044 was offered the option of adding the missing rungs and did not take it, rung 4 buyinghandled_access_netwhich this build leaves zero. The tally above is held, not remembered —cargo xtask isolation-matrixcomputes it from the corpus, prints it on the matrix page, and refuses to emit at all unless this page carries the same sentence. So is the rung each named test belongs to: the generator resolves every name againstBEHAVIOURAL_RUNGSincrates/vitrin-realm-init/src/main.rs, which declares the rungs that test enters a domain at, so a test cannot be published on a rung it never enters and a rung it does enter cannot be left off.The absolute in bold above is a different kind of claim from the tally, and it is worth saying what holds it — because until 2026-08-23 nothing did. A tally is about tests that exist; “no test in this repository” is about tests nobody has written yet, and the three cross-checks in the paragraph above — declared name to a real
fn, declared rung to a published row, row back to declared rung — are all comparisons against a declaration. A test that entered a domain and declared nothing appeared in none of them, so adding one at rung 4 would have left this sentence false on a fully green build. There are exactly two ways into a Landlock domain in this tree and each now has its own mechanism:-
A test that issues the syscall itself —
vitrin-realm-init’s forked measurement bodies, the only code in this workspace that callslandlock_restrict_self. Held by the type system: that function demands a token whose only mint in a test build is a ledger, and the token carries the ruleset, not a rung number beside it — so what the ledger records is the rung the kernel created that ruleset at, and the ruleset it recorded is the only descriptor the syscall can be given. The rung is compared againstBEHAVIOURAL_RUNGSinside the mint, before the token exists, and the ledger refuses to open at all for a test name that table does not declare. Declaring is therefore not something a test can forget, and the production path (landlock::apply_with) has its own mint compiled out of test builds so it is not a way round.Two shapes of that token were found broken by compiling the counterexample rather than by reading the code, both on 2026-08-23:
-
It carried a rung number. A test could declare rung 1, build its ruleset at rung 4, enter a rung-4 domain and leave every mechanism here green. Closed by welding the two:
entering(4)no longer type-checks, and a ruleset the ledger never saw cannot be named to the syscall at all. -
The comparison lived only in the ledger’s destructor, and the token borrowed the ruleset without borrowing the ledger — so the ledger could be moved out from under a live token. This compiled, ran, entered a rung-4 domain and skipped the check, with every gate green:
let entered = RungsEntered::for_test("a_realm_can_write_where_it_was_granted_and_nowhere_else"); let ruleset = create_ruleset(4).expect("a rung-4 ruleset"); let entering = entered.entering(&ruleset); std::mem::forget(entered); // ... enters a rung-4 domain; nothing records itClosed twice over, because one of the two fixes would have been a fix that still rested on a destructor. The mint now takes
&'a self, so the ledger cannot be moved while a token minted from it is alive. Pasted from a probe compiled into the test module and then reverted, so the line numbers are the probe’s and not a line in the shipped tree:error[E0505]: cannot move out of `entered` because it is borrowed --> crates/vitrin-realm-init/src/main.rs:1995:26 | 1994 | let entering = entered.entering(&ruleset); | ------- borrow of `entered` occurs here 1995 | std::mem::forget(entered); | ^^^^^^^ move out of `entered` occurs here 1996 | let _ = landlock::restrict_self(entering, 0); | -------- borrow later used hereAnd the comparison the sentence in bold depends on — this rung is one the row declares — moved out of the destructor into the mint, where no disposal of the ledger afterwards can skip it.
-
-
A test that asks the shipped helper for a rung, entering the domain in another process where no Rust type can reach it. The core’s own confinement suite is held at its single realm-spawn point, which refuses a spawn that reports one of these rungs;
tests/integration/’s Python and shell files are held by a scan forabi:Nnaming one of them. Both lists are computed from the ladder corpus rather than typed, so a test added at rung 4 changes what the generator demands of this page instead of leaving it behind.
What that does not cover, stated rather than implied.
Dropis not guaranteed to run in Rust —mem::forget,ManuallyDrop,Box::leak,std::process::exitand apanic = "abort"profile each skip it — so nothing on this page publishes an absolute that rests on one. The half of the ledger’s comparison that is still in its destructor is the converse of the sentence in bold: a row declaring a rung the run never entered, which is a staleness check on the table and not a claim about which rungs are reachable. That half is skippable and is stated here as skippable. The scan reads literals in the suite’s files, so a rung composed at runtime from pieces would pass it.VITRIN_LANDLOCK=abi:4set in the environment by whoever runs the suite is invisible to all of it — that is an operator pinning a session, which the bullets above describe as the instrument these measurements are taken with, and it is not a test in this repository. And the first bullet holds one function, not the kernel’s ABI:landlock_restrict_selfis syscall 446, a test could issue it by hand throughlibc::syscall, and nothing checks thatlandlock::restrict_selfremains its only caller — thatrestrict_selfis the only such call today is a fact about the tree as it stands, not a mechanism. The first and the third would each make the sentence in bold silently false and neither is held; the second is an operator rather than a test, and is out of its scope by construction. The sentence in bold is about the files, and it is the files that hold it — as far as a literal can be read. -
-
This build’s ladder stops at rung 9, and a newer kernel is clamped to it. ABI 10 exists in mainline and this build does not request it. A kernel reporting more than 9 gets a rung-9 ruleset, and that is journaled per realm as
isolation.landlock.clamped_by_build; the constant it is measured against is printed byvitrind --print-floorasbuild.landlock_max_rung. Nothing here has been run on such a kernel — the clamp is asserted against a constructed ABI value, not against a machine that reports one. -
Nine rung numbers name six different domains, and the profile string does not say so. Three rungs buy facilities this build never requests: ABI 4 is network scoping (
handled_access_net, deliberately zero — the realm’s own network namespace carries that claim), and ABI 7 (audit-log control) and ABI 8 (TSYNC) arelandlock_restrict_selfflags, which this build passes as zero in every shipped run. (The one thing that moves the flags word is a diagnostic,VITRIN_LANDLOCK_AUDIT=1in vitrind’s own environment, which sets ABI 7’sLOG_NEW_EXEC_ONso the kernel keeps logging a realm’s denials past the shim’sexecve. It changes what the kernel writes down and nothing about what it permits, it cannot be reached fromrealm.tomlor from a command line, and under it rungs 6 and 7 stop being byte-identical — in the log flags only.) Since none of the three moves anything the helper asks for, the enforced domain —handled_access_fs,scopedand the flags word together — is byte-identical at rungs 3 and 4, and byte-identical at rungs 6, 7 and 8.--landlock=abi:4and--landlock=abi:7are nevertheless accepted and journalnamespaces+landlock-abi4andnamespaces+landlock-abi7: distinct strings for domains that are not distinct. Read a profile as which rung was requested and obtained, never as how much confinement, and read those five rung numbers as two rows of the ladder rather than five. Two consequences follow, and both cut against this page: capping the mask cannot simulate the absence of a facility the build never asked for, so rungs 4, 7 and 8 are prose-backed rather than measurable here; and the per-rung measurements quoted on this page are for the rungs that do move the mask (1, 2, 3, 5, 6, 9). Why neither flag is requested by a shipped session is incrates/vitrin-realm-init/src/landlock.rs: the helper is single-threaded, so its shape already carries whatTSYNCwould buy, and no published claim here depends on the log flags — they are pure observability, which is why the one of them that is reachable at all is reachable only as a diagnostic. -
It does not close the render-node limit below.
IOCTL_DEV(ABI 5) is one all-or-nothing bit per granted hierarchy, and an app that cannotioctlits render node cannot render, so the ruleset grants it there. What the rung buys is denyingioctlon every other device node in the realm — the read-write render node, and everything the next bullet says about it, is unchanged. -
One rung is requested and carries no claim of its own. ABI 6’s
scopedfield is defence in depth rather than the mechanism behind either published claim it touches: a realm’s abstract UNIX sockets are already isolated by its own network namespace, and its pid namespace already denies signalling outward. The ruleset asks for it anyway, because asking costs nothing and the namespaces could one day be relaxed; but no sentence on this page would become false if the kernel refused it, and the ABI matrix says so in that rung’s row rather than implying the rung is load-bearing. -
A realm’s app can no longer mount anything, and that breaks nested sandboxes. A Landlock domain denies every mount-topology change to the process and its descendants, unconditionally — it is not an access right, so no rule grants it and widening the ruleset cannot restore it. Measured on this box (2026-08-15) with the granted rights held constant at everything on
/and only the handled mask varied: withhandled_access_fs = 0themount(NULL, "/", NULL, MS_REC|MS_SLAVE, NULL)returns 0; withEXECUTEalone handled it returnsEPERM; with the full rung-9 mask handled and every one of those rights granted on/it still returnsEPERM. So a realm’s app is confined by this system’s boundary and cannot build a second one inside it — the practical casualty is bubblewrap, which GTK’sglycinimage loaders spawn to decode an SVG. -
So the realm refuses nested user namespaces outright, and that is a hardening rather than a workaround. Since a domain forbids
mount(2)unconditionally, a user namespace created inside a realm can build no mount and was already useless;vitrin-realm-initwrites0to the realm’s own/proc/sys/user/max_user_namespaces(step K9b) so that the refusal arrives atunshare(CLONE_NEWUSER)instead of much later, at the firstmount(2). Nothing an app could do becomes impossible. What changes is which error it receives: the conventional “this host does not allow unprivileged user namespaces” answer every sandbox library already has a branch for, rather than an opaqueEPERMfrom deep inside its own setup. It also removes real attack surface, nested user-namespace creation being a recurring source of kernel CVEs and a realm having no legitimate use for one. Measured mock-free from inside a real realm bytests/integration/test_real_confinement.py(RealConfinementNestedUserns): the app’s forkedunshare(CLONE_NEWUSER)failsENOSPCat the shipped default and at--landlock=off— so the refusal is the realm’s ucount limit, not the ruleset — and succeeds at--isolation=off, which is the positive control that makes the two negatives mean anything. -
Nested image sandboxes still do not work inside a realm; apps that want one now degrade instead of aborting. That distinction is the entry below, and it is a narrower claim than “fixed” in both directions.
-
The rung number is child-asserted; the denial is not. The namespace inodes, the realm’s root device and the canary set are read by the core from
/proc. The Landlock rung cannot be: no/procfile names a process’s Landlock domain, so the number in the journal is one the helper reported and a substituted helper could report anything. What such a helper cannot forge is the realm’s behaviour, and that is measured — mock-free, from inside a real realm — bytests/integration/test_real_confinement.py, which opens a path the mount table leaves reachable and the ruleset does not grant (/vitrin, the directory holding the realm’s own shim and storage). Under the default it failsEACCES; under--landlock=off, same core, same mount table, same argv, it succeeds. Neither half is evidence without the other. What is still not measured that way is any particular rung’s rights inside a real realm — those are measured invitrin-realm-init’s own suite, where a forked child can enforce a capped domain and try the syscall. Nor is the verb the same: that gate’s probe opens read-only (O_RDONLY), so what it measures mock-free is a read denial. P2.6.3’s criterion about a write to a path outside the granted set — with its positive control in the same run — is measured only bya_realm_can_write_where_it_was_granted_and_nowhere_elseinvitrin-realm-init’s own suite, at rung 1, in a forked child. That is a component test and this page will not cite it as anything else. Where the write half is scheduled to be measured mock-free is #193 (P2.6.9, the ransomware gate), whose payload reports every write it attempted with the errno each got; nothing before that gate lands closes this, and givingtest_real_confinement.py’s own probe a write verb was considered here and deliberately left to it rather than done in a review-fix branch. -
There is a ladder table now, and it is a table about this build — not about kernels. P2.6.3 was accepted on 2026-08-19, on its corrected criteria and not on the ones its plan row first wrote, and this page will not round that up. The task’s own acceptance criteria (
docs/plan/02-phase-2-semantic-epochs.md, P2.6.3) ask for two deliverables: the ruleset, which landed, and a per-ABI ladder table generated on each kernel in the CI matrix with CI going red when the checked-in copy is stale. What now exists iscargo xtask isolation-matrix, which emits the Landlock ABI matrix, and a--checkstep in.github/workflows/ci.ymlthat goes red when the checked-in page is stale. The per-kernel half has since been delivered by #281, and it is no longer correct to call it deferred: which kernels this build starts on is rendered from five checked-in boot rows undertests/kernel-matrix/rows/, each holdingvitrind --print-isolationand--print-floorverbatim from a QEMU boot of that kernel, withcargo xtask kernel-matrix --checkgoing red when the page and the rows disagree. It stayed a separate page rather than becoming a column on the ladder, for the reason that generator probes nothing: it parses the rung ladder out of the helper’s own source and the ABI floor out of the crate that declares it, because a page carrying the ABI of the machine that produced it could not be byte-identical on this repository’s two machines (development box:landlock.abi=9; CI runner:landlock.abi=7) and so could not be the thing CI holds. Which kernel releases clear the floor is now stated, and measured — 6.12 and 6.17 start; 5.15, 6.1 and 6.8 are refusedbelow-floor. Three things about it are still true and still limits: every one of those rows is a kernel reading taken in a bare initramfs, so the number of distributions measured as such is still one; nobody other than the author has re-run the collector’s own failure levers, which needs QEMU on a second machine; and five kernels is five kernels, not a spectrum. What holds the build half of those rows is a gate rather than anybody’s memory:cargo xtask kernel-matrix --checkreads each row’s own recordedfloor.mechanism=andapplies.*lines and holds them to the setscrates/vitrin-core/src/spawn/isolation.rsdeclares, so the page goes red the day the floor moves out from under them and names the mechanism that moved. That is worth stating because it has already failed once: P2.6.4 grew the floor by two mechanisms and every gate stayed green, because the check compared the page against the rows and both were stale together. Read its scope narrowly, though — it re-boots nothing, so a green pull request says the rows describe this build and says nothing whatever about whether these kernels still answer this way. Onlytests/kernel-matrix/collect.sh --checkre-takes that half; it needs QEMU, no pull request runs it, and every row carries the date it was last taken on. PRD §20’s “coverage is kernel-dependent” caveat is answered for those five and for no others. The per-rung behavioural statements quoted above (theTRUNCATEpair, theREFERpair) are held byvitrin-realm-init’s own tests, which run on this repository’s development box and on the CI runner — whose job declaresVITRIN_REQUIRE_LANDLOCK_ABI=7, so a skip there is a panic and not a quiet pass — and on no third machine; the values they pin were recorded on one box on one date. Everything else about a rung is now generated and gated, which is a narrower promise than “measured”. What P2.6.3’s acceptance does and does not mean. It was accepted on the corrected criteria plus decision D-044 (the sub-floor rung tests, above), and two of the criteria written in the plan were wrong on the kernel’s own terms while a third — “one row per ABI actually reported, on each kernel in the CI matrix” — cannot be satisfied by any byte-stable checked-in page and was replaced rather than met; the plan document restates all three with the correction visible rather than deleting them. Four things did not become true on acceptance, and each is stated above in its own words: five kernels answered five ABIs and four of the nine rungs are reported by none of them; every one of those rows is a kernel reading in a bare initramfs, so the number of distributions measured is still one; the suite itself has still only ever run on two machines and nobody but the collector’s author has re-run its levers; and the per-rung behavioural statements are one box, on one date. Read an accepted task as an accepted task and this page for what is actually measured.
The six enforced domains, stated once so a generated table can be compared
crates/xtask/src/isolation_matrix.rs emits one row per distinct enforced
domain and prints the statement below in that row, byte for byte. The
generator refuses to emit the page at all when a statement here and the one it
would print differ, so the two cannot drift and nobody has to decide whether a
paraphrase still means the same thing. The domain count is derived from the
parsed ladder rather than typed, which is why “nine rung numbers, six domains”
above is a computed sentence rather than a remembered one.
- T1 — rung 1.
handled_access_fs=0x1fff,scoped=0x0: noREFER, so a realm capped at rung 1 cannotrename(2)across directories inside its own writable storage — the one rung that is stricter than the rung above it. - T2 — rung 2.
handled_access_fs=0x3fff,scoped=0x0:REFERarrives, and handling it is what permits cross-directory rename inside the realm’s own storage. - T3 — rungs 3 and 4.
handled_access_fs=0x7fff,scoped=0x0:TRUNCATEarrives at rung 3; rung 4 buyshandled_access_net, which this build leaves zero, so rungs 3 and 4 are one domain. - T4 — rung 5.
handled_access_fs=0xffff,scoped=0x0:IOCTL_DEVarrives, and it does not close the render-node limit — the app needs the node’s ioctls, so the ruleset grants them there. - T5 — rungs 6, 7 and 8.
handled_access_fs=0xffff,scoped=0x3: rung 6 adds thescopedfield; rungs 7 and 8 buylandlock_restrict_selfflags rather than access-mask bits, so a mask cap cannot simulate their absence and rungs 6, 7 and 8 are one domain. - T6 — rung 9.
handled_access_fs=0x1ffff,scoped=0x3:RESOLVE_UNIXarrives, and this is the highest rung this build requests — a kernel reporting a higher ABI is clamped here.
Every hexadecimal number above is the mask this build asks a kernel at that
rung for, parsed out of crates/vitrin-realm-init/src/landlock.rs and
cross-checked against the measured table pinned in that crate’s
the_rung_masks_pin_a_measured_table. They are not statements that a kernel at
that ABI enforces nothing else — they are statements about the request.
Inside a realm, a nested sandbox cannot be built, so an app that decodes images in one decodes them UNSANDBOXED. That is the whole of what this entry now claims, and the wording matters in both directions: nothing here says the nested sandbox works, and nothing here says an app dies for wanting one.
What this entry said until 2026-08-15, and why it no longer says it. It
published that the shipped default took three of this repository’s own real-app
gates red — test_real_actuation.py’s typing rung (M1.4’s actuation half,
#108), test_real_gtk.py and test_real_firefox.py — with a two-column table
of shipped-default failures against --landlock=off passes. That is no
longer true, and a false published limit is as damaging as a missing one. The
realm now refuses nested user namespaces (vitrin-realm-init’s K9b, the bullet
above), so bwrap fails at unshare(CLONE_NEWUSER) rather than at its first
mount(2), and glycin — which decides bwrap’s availability by matching its
stderr against a fixed list of namespace-refusal strings — takes the graceful
fallback it already ships. Re-measured on this repo’s box (Arch, kernel
7.1.8-arch1-3, Landlock ABI 9, 2026-08-15), each gate run at the shipped
default with no flag changed and no app exempted:
| gate | shipped default, 2026-08-14 | shipped default, 2026-08-15 |
|---|---|---|
test_real_actuation.py — typing rung (M1.4, actuation half, #108) | FAIL | pass |
test_real_gtk.py (supporting — M1.2 render half) | FAIL | pass |
test_real_firefox.py (supporting — M1.2 render half) | FAIL | pass |
The right-hand column is not an assertion; it is what a whole
bash tests/integration/run.sh on that box reported — Ran 118 tests, OK,
0 failures and 0 skips, ending on full suite: no skips, every named gate ran.
One qualification on that sentence, because a page about honesty may not cite
a green suite as if it were a reproducible constant. When it was written the
suite carried a flake in tests/integration/test_multi_realm.py — unrelated
to Landlock, reproduced on main in a clean worktree — which took the whole run
red often enough that “0 failures” was a run that happened rather than a state
the suite returned to.
That flake was root-caused and fixed under #292, and the numbers are worth stating because they are the only thing that distinguishes a fix from a re-run. It was two independent races, both in the test’s observation and neither in the core: one test asserted on the runtime tree the instant the core’s socket appeared, which is a median 8.4 ms before the last of three realms is forked; the other pinned a death cause the core’s own module documentation calls nondeterministic in as many words. Measured on this box, that module run back-to-back as its own process: 19 red out of 60 before, 0 red out of 100 after. Each fix was reverted separately to confirm it was load-bearing — the first brought the failure back at 15/60, the second at 8/100 — and the second race’s member ran 100 more times green with its fix restored.
What that does not license is reading a green suite as a constant. Since the
fix the whole suite has run 13 consecutive times green on this one box, each
reporting Ran 118 tests, OK, 0 skips. Thirteen runs on one machine is
thirteen runs on one machine: it is enough to say the known flake is gone —
the pre-fix rate would have reddened roughly four of them — and it is not enough
to say the suite has no others. The three-gate claim
above does not rest on the whole-suite line either way: each gate was also run
individually at the shipped default, and the per-gate lines below are what
actually carry the column.
Each gate’s own line: test_real_gtk.py captured a 640×480 frame
(196 distinct colour values in that run, 192 in a separate one the same day —
the count is not a fixture and is quoted only to show a real frame arrived),
test_real_actuation.py’s typing rung received héllo→世界 intact with 4324
pixels changed, and test_real_firefox.py painted #0000ff over 78% of a
1024×768 frame. The --landlock=off column that used to sit beside these has
been deleted rather than carried: it was measured on 2026-08-14, it was
never re-run, and with the left-hand column no longer failing it compared
nothing.
The mechanism, read from a realm’s own log rather than inferred. Run
bwrap itself as a realm’s app (command = "/usr/bin/bwrap", args = ["--unshare-all", "--die-with-parent", "--ro-bind", "/usr", "/usr", "--dev", "/dev", "--tmpfs", "/tmp", "--", "/usr/bin/true"]) and read
<runtime>/vitrin-0/realm-0/realm.log. Both halves were measured on this box
on 2026-08-15, one code change apart and nothing else:
before K9b: bwrap: Failed to make / slave: Operation not permitted
after K9b: bwrap: Creating new namespace failed: nesting depth or
/proc/sys/user/max_*_namespaces exceeded (ENOSPC)
Creating new namespace failed is on glycin’s known-string list (read out of
strings /usr/lib/libglycin-2.so.0: Creating new namespace failed, No permissions to create a new namespace, bwrap: setting up uid map: Permission denied, …); Failed to make / slave is not. That single string is the whole
difference between bwrap sandboxing available: true — followed by a loader
that dies sandboxed, and a GTK 3.24 gtkiconhelper.c:495 g_error that turns
a failed icon load into SIGABRT — and bwrap syscalls not available: STDERR contains known string → WARNING: Glycin running without sandbox.
What was NOT the cause, each measured and each worth keeping so nobody spends
the day again. The enumerated read set is not missing a grant: a domain
handling the whole rung-9 mask and granting every one of those rights on /
— strictly more than the enumeration, and more than the realm-root grant #187
declined — failed the identical decode. gdk-pixbuf’s loader cache and the
mime database are not corrupted or shadowed: inside a confined realm at the
shipped default, sha256sum matches the host byte-for-byte for
loaders.cache, mime.cache, image-missing.svg and the loader binary, and
the loader binary executes — GTK’s “pixbuf loaders or the mime database could
not be found” is a generic message and a red herring.
MOZ_DISABLE_CONTENT_SANDBOX=1 does not help Firefox: it died at the same GTK
abort while drawing its own chrome, before any content process was spawned.
What is still true, and is what this entry publishes. A realm’s app cannot
build a sandbox inside the realm — mount(2) is denied to any process in a
Landlock domain and no rule reaches it — so on a host whose image decoding is a
nested-sandbox spawn, that decoding happens with no sandbox around it.
glycin prints WARNING: Glycin running without sandbox. for a reason, and
trading a nested sandbox away is a real loss, even though it is the loss every
host without bwrap already takes. An image decoder is a large attack surface
fed untrusted bytes; inside a realm it is contained by the realm’s own
boundary and by nothing else.
Which hosts this bites is a property of the host’s image decoders, and one
point on each side was measured. On this box gdk-pixbuf 2.44.7 carries a
single in-process loader (io-wmf.so) with glycin 2.1.5 and bwrap both
installed, so every other decode is a nested-sandbox spawn. In an
ubuntu:24.04 container holding exactly what shim/ci/install-deps.sh
installs, gdk-pixbuf 2.42.10 ships libpixbufloader-svg.so in process,
carries no libglycin at all, and has no bwrap on PATH — so the
nested-sandbox path is not reachable there. What was not measured is any gate
on that image. Two decoder inventories were measured; no gate was run inside
a container, and nothing here says these three gates pass on CI.
Nothing routes around this. No gate is skipped for it, no app is exempted
from the ruleset, and the ruleset was not widened to the realm root — per the
measurement above, that would not have worked either. The one thing that
changed is when a nested sandbox is refused, and that change is applied to
every realm at the shipped default rather than to the apps whose gates were
red. VITRIN_LANDLOCK=off bash tests/integration/run.sh still exists as the
no-ruleset control; that run announces itself as a control, is not the shipped
default, and is evidence for no milestone.
Three things survive the namespaces, and each is published rather than left to be found:
- The realm keeps your supplementary groups.
video,render,input,docker— whatever the invoking user has. This is not an oversight: an unprivilegedsetgroups(0, NULL)and an unprivileged single-idgid_maprequire disjoint windows, so there is no moment at which the groups can be dropped. Measured, both orderings returnEPERM. The mount table is therefore the only thing standing between a realm and any device those groups would open, and each spawn journals the count assupplementary_groups_retained. - The GPU render node is bound read-write. Never
card*orcontrolD*, but the render node’s ioctl surface and the cross-realm GPU-memory side channels it carries are real and unaddressed. Read-only was tried and rejected: it disables the node rather than restricting it, which would silently break every accelerated app in every realm. P2.6.3’s Landlock rung 5 does not change this, for the same reason in a different mechanism: itsIOCTL_DEVright is one bit per hierarchy, not a per-command filter, so the ruleset grants it on the node. --isolation=offis exactly the old, unconfined path, and it exists so the confinement gates can run their positive controls. It has to be named on the command line; nothing selects it implicitly, and a session running that way says so on the panel.
Environment hygiene confines the well-behaved; it does not contain the hostile. Do not run untrusted applications, or untrusted agents, against this yet — a realm that can issue any syscall it likes is not a boundary you should stake anything on, whatever its filesystem view and whatever its Landlock ruleset denies.
And on some hosts you do not get that far: vitrind --isolation=default
refuses to start unless the host lets an unprivileged user namespace actually
carry its capabilities. Everything above is built out of an unprivileged
CLONE_NEWUSER and a mount namespace inside it. A host can permit the
unshare and still strip the capabilities that new namespace is supposed to
confer — which the startup preflight now finds out by trying it, because
creating the namespace and mounting inside it are two different answers on such
a host. Where the probe’s mount(NULL, "/", NULL, MS_REC|MS_PRIVATE, NULL)
fails, vitrind stops, before a realm is ever spawned. It does not quietly
start a weaker session: silent degradation is the one outcome D-020(6) forbids,
so a machine that cannot confine is told so up front, with the knobs the core
actually read named in the refusal.
Stated as a requirement on the host rather than as a list of distributions that fail:
Creating an unprivileged user namespace must succeed, and a
MS_REC|MS_PRIVATEremount of/inside it must succeed.vitrind --print-isolationanswers both, for the machine in front of you, without spawning anything.
The evidence behind that sentence is one data point, and it is worth exactly
one. On a GitHub ubuntu-latest runner — kernel 6.17.0-1020-azure,
measured 2026-08-14 — kernel.apparmor_restrict_unprivileged_userns is 1,
read from the runner’s own sysctl before CI changed anything, so it is the
value that stock image ships. (Calling it the distribution’s default is one
step further than this reaches — the runner’s /etc/os-release was never
opened, and one image is not a distribution.) AppArmor permits the unshare and
then
confines the process to a profile denying the capabilities the new user
namespace should have conferred, so the first mount answers EACCES. The
matrix reads ns.all=available,
mount.in_userns=restricted-by-policy(errno=13), tier=none, and no realm
starts. That is one CI image, on one kernel, on one date — not a
distribution survey. Nobody here has run one. Do not read this as “one
distribution is broken and the rest are fine”; read it as “one mainstream
default was measured and it was the unhappy answer”. Collecting the matrix
across kernels is #281.
What is missing is packaging, not a fix to the refusal. The refusal is the
behaviour this project wants, and its message already tells an operator where
to look. What nothing published said, until this entry, was that a host may
need to be granted something at all before the default isolation will run —
so an operator met a stop rather than a prerequisite. Saying it, and shipping a
profile that makes the grant, was
#286, which is closed.
Making it routine — having an installation of this project put the profile
and the binaries where the profile expects them — is
#293, and until that lands
nothing here installs anything: a build outside
/usr/lib/vitrin/ is not attached to the profile at all.
--isolation=off is not that arrangement: it starts an unconfined session,
and every confinement claim on this page stops applying to it.
There is an AppArmor profile in the tree, and as of 2026-08-15 it has been
loaded and measured — on one kernel, on one CI image.
packaging/apparmor/vitrind is the per-binary grant Ubuntu ships a mechanism
for — the same shape the chrome, firefox and flatpak profiles in Ubuntu
24.04’s own apparmor package already use, chosen over telling operators to
weaken a system-wide default. It was written on a machine with AppArmor
compiled out (/sys/module/apparmor/parameters/enabled reads N), so for
its first day here it had not been parsed, loaded, attached or observed to
grant anything, and this page said exactly that. The apparmor-profile job now
reports otherwise, and the numbers are below rather than a paraphrase of them.
Two different kinds of claim are in play here and this page keeps them apart,
because an earlier draft did not. The profile’s behaviour is now measured —
that is the table below. The profile’s form is cited: every structural
choice in it is copied from a profile Ubuntu actually ships, and the file’s own
header carries a provenance block naming the URL each one was fetched from and
the date. An earlier draft named bubblewrap in that list from memory; the
bwrap-userns-restrict profile is not in 24.04’s apparmor package at all,
and the claim is gone rather than softened. If you are checking this page
against reality, check the header’s URLs — that is what they are there for.
The instrument is the apparmor-profile job in .github/workflows/ci.yml. It
runs on a ubuntu-latest runner it does not modify — the only job in that
workflow that never touches kernel.apparmor_restrict_unprivileged_userns —
and it fails rather than skips if that knob is not 1 when it starts, so
it cannot quietly measure nothing. It re-reads the knob after setup and fails
if it moved, because installing the apparmor package would load the distro’s
own profiles and grant what this profile is meant to grant
(parser_present=stock: the parser is already on the image, so no install
happens). It installs the profile, loads it, spawns a real realm, runs the
real-app confinement gate, then removes the profile and requires the spawn to
fail again.
What it reported on kernel 6.17.0-1022-azure with
apparmor_restrict_unprivileged_userns=1 and no sysctl touched:
| baseline | with the profile | |
|---|---|---|
apparmor.label | unconfined | vitrind (unconfined) |
mount.in_userns | restricted-by-policy(errno=13) | available |
tier | none | per-uid |
| realm spawn | refused-as-expected | ok |
with realapp=pass over 8 executed confinement assertions, and the lever in
the same run: lever_without=refused, lever_restored=ok. That lever is what
distinguishes this profile working from Ubuntu’s own fallback
unprivileged_userns profile, which carries audit deny capability, beside
allow userns, and therefore fails with the identical EACCES=13 signature
— a job that only asked “did it spawn?” could not tell a wrong profile from no
profile.
Read the boundary as narrowly as it is written: one kernel, one image, one distribution. Nobody has loaded this profile on an installed Ubuntu system, and this repository has never measured a second AppArmor-carrying distribution.
One question decided whether the profile was worth anything, and the job was
built around it. vitrind does not create the user namespace itself — it
execves vitrin-realm-init, which does. If an AppArmor grant did not survive
that exec, the profile would fix the core’s startup and not the realm’s
spawn, which is worse than shipping nothing because the refusal moves somewhere
less legible. The profile is written to make that question moot — one
attachment glob over /usr/lib/vitrin/{vitrind,vitrin-realm-init}, so the exec
is same-label and performs no transition at all, rather than betting on
fallback semantics that PR_SET_NO_NEW_PRIVS restricts. A realm spawning
under the profile is the measurement that answers it. The shim and the app
deliberately get nothing further: vitrin-realm-init writes
max_user_namespaces=0 inside the realm (K9b), so a nested user namespace is
refused by design.
And the profile has a security cost, which is published here rather than
buried in the file — but it is conditional, and an earlier draft of this page
stated the condition backwards. A profile of this shape —
flags=(unconfined) carrying a userns rule — is a name any local user may
try to borrow: aa-exec -p vitrind -- <anything> asks to run an arbitrary
program under a profile that grants a user namespace and restricts nothing
else. Installing this file adds one entry to the set of names that can be asked
for. It is the same cost Ubuntu already accepted for chrome, firefox and
flatpak, which is company rather than a justification.
Whether the ask succeeds depends on a second knob,
kernel.apparmor_restrict_unprivileged_unconfined, and it has now been
measured: 0. Recorded by the apparmor profile CI job as
RESULT unconfined_knob=0 on a stock ubuntu-latest (kernel
6.17.0-1022-azure, 2026-08-15), on the same machine and in the same run that
apparmor_restrict_unprivileged_userns read 1.
So the cost is real and unmitigated. At 0, aa-exec -p vitrind borrows
this profile’s name and the borrower is genuinely unconfined — any local user
can obtain an unprivileged user namespace by naming a profile they do not own.
That is the price of installing this file, and it does not depend on vitrin
being installed or running.
This page asserted that knob twice before measuring it, wrongly in both
directions — first 0 for the wrong reason, then 1 on the strength of the
AppArmor project’s userns-restriction wiki page describing what
upstream intends /usr/lib/sysctl.d/10-apparmor.conf to contain, which is not
the same as reading what Ubuntu ships. The measurement happens to agree with
the first guess. It was still a guess, and the second correction was confidently
wrong, which is why the job now records this knob on every run rather than
leaving it to prose.
Had it read 1, the unconfined-restriction page describes
change_profile — what aa-exec -p performs — as stacking rather than
transitioning, so the borrow would shed nothing. That is the branch this page
does not get to claim, on this runner.
So: the cost is real where an operator has set that knob to 0, and is
mitigated by the stacking behaviour where Ubuntu’s shipped 1 is in force.
Neither half has been measured by this project — the correction above is a
citation, not an experiment — which is why the apparmor-profile CI job
records the knob’s value on its runner and refuses to report a verdict without
it. vitrind --print-isolation reports the same knob as
policy.apparmor_restrict_unprivileged_unconfined, so you can read your own
machine’s answer, and its own AppArmor label as apparmor.label — the row that
tells “no profile attached” apart from “a profile attached and granted
nothing”, which are otherwise the same errno.
This is not the only host requirement, and the two are easy to confuse. The
entry immediately below is a second one — the kernel must actually have
Landlock — which stops the same command with the same shape of message and has
a completely different remedy. Check which mechanism the refusal names before
following anything here: this entry is the one that says namespaces.
And there is a second host requirement, added by P2.6.3 and just as capable
of stopping a session before any realm exists: the kernel must actually have
Landlock. Since #187 the Landlock ruleset is part of this build’s confinement
floor, not an optimisation on top of it — so a kernel that answers the ABI
query with ENOSYS no longer starts a weaker session, it refuses to start at
all. That is the same D-020(6) trade as above, made deliberately: the
alternative is a session whose realms are confined one mechanism less than its
own journal claims.
Stated as a requirement on the host, in the order an operator should check it:
- Kernel ≥ 5.13, which is where Landlock arrived.
uname -r.CONFIG_SECURITY_LANDLOCK=yin the running kernel’s config.zgrep CONFIG_SECURITY_LANDLOCK /proc/config.gz, or the matching file under/boot.landlockpresent in the active LSM list — the kernel can carry the code and still not enable it.cat /sys/kernel/security/lsm; if it is absent, addlandlockto thelsm=boot parameter, keeping every name already there.- The reported ABI must be at or above this build’s floor, which is
build.landlock_min_abifromvitrind --print-floor— 6 in this build. This is a build requirement rather than a kernel-configuration one, and it is the only one of the four that a correctly configured, working Landlock can still fail. The kernel page lists five measured kernels and which side of this line each falls on.
vitrind --print-isolationanswers (1)–(3) for the machine in front of you, aslandlock.abi=N, without spawning anything; hold that number against--print-floor’s for (4).
Requirement (4) is an owner’s decision (2026-08-15, re-tuned 2026-08-16), and
its remedy is different from the other three. Nothing is misconfigured on such
a machine — Landlock is present, enabled and answering — so no sysctl, LSM list
or boot parameter moves the number and the refusal says so rather than handing
the operator the three checks above. The remedy is a newer kernel. The refusal
carries both numbers, as below-floor(abi=N,required=M). The reasoning is in the
ladder bullet above; the short form is that 6 is the lowest rung at which this
build’s enforced domain is unchanged, so the floor sits at the point where
refusing fewer machines costs no confinement.
Two things this entry does not say. It does not say which distributions
ship (3) unset — nobody here has surveyed that, and
#281 owns it alongside the
namespace survey. (Which kernels fall below (4) is now measured, on five of
them; see the kernel page.) And --landlock=off is not
the remedy for a
kernel that could be configured: it starts realms with no ruleset at all,
so every sentence on this page about the enumerated read set, the write set and
the rung ladder stops applying to that session. It exists for a machine that
genuinely cannot have Landlock, and for the control runs this page’s own
measurements are taken against.
These are two requirements, not one, and their remedies must not be
crossed. Both stop the same command with the same shape of message, so the
first thing to read is which mechanism the refusal names: namespaces is the
entry above, landlock is this one. vitrind walks its confinement floor in
order and refuses on the first mechanism whose probe failed, naming that one —
so the word in the message is the diagnosis, not a heading.
| the refusal names | what the host is missing | what fixes it | what does nothing |
|---|---|---|---|
namespaces | an unprivileged user namespace that carries its capabilities | the sysctl / policy the refusal quotes (kernel.apparmor_restrict_unprivileged_userns, user.max_user_namespaces, …) | adding landlock to lsm=; rebuilding the kernel |
landlock | Landlock: too old a kernel, CONFIG_SECURITY_LANDLOCK=n, or landlock absent from lsm= | the three checks above, two of which need a reboot | any userns sysctl; apparmor_restrict_unprivileged_userns=0 |
landlock, as below-floor(abi=N,required=M) | nothing — Landlock works; the kernel is older than this build’s declared ABI floor | a newer kernel | all of the above, including the three checks: they are already satisfied |
The two conditions are independent, and the one machine measured here shows
it. The namespace refusal was measured on a kernel 6.17.0-1020-azure
runner — four years past the 5.13 where Landlock arrived — so that machine
failed the first requirement while being nowhere near failing the second. That
same runner answered landlock.abi=7, which clears requirement (4) with a rung
to spare.
That runner reading is still a transient observation, and it is worth being
precise about what has and has not changed. It was printed by CI’s own What confinement this runner actually grants (diagnostic, never fails) step —
--print-isolation on an unmodified runner — and read out of the job log for run
31776579437,
integration job, 2026-08-14. No file in this repository records that
runner’s own output. It is not archived as a CI artefact, is not asserted by
any test, and GitHub expires job logs, so the distribution half of it — the
policy rows, the tier, the mount.in_userns refusal — survives only as long
as that log does. What #281
did close, on 2026-08-16, is the kernel half: the same kernel release
(6.17.0-1020-azure) is now booted under QEMU with the shipped binary and its
answer is a checked-in artefact reporting landlock.abi=7. That corroborates
the ABI without turning it into a fact about the runner, because the same boot
reads apparmor_restrict_unprivileged_userns=0 where the runner reads 1. See
the kernel page, which states that distinction as the
reason it is a kernel page and not a distribution page.
For the distribution people will ask about: Ubuntu 24.04’s own GA kernel is the
6.8 series, and this is now measured rather than inferred —
6.8.0-139-generic, booted with the shipped binary, reports landlock.abi=4,
which is below requirement (4) and is refused with
below-floor(abi=4,required=6). An earlier version of this page reached the
same number by arithmetic over mainline release notes and labelled it as
arithmetic; it is a row now. What that row still does not settle is
requirements (2) and (3) on an arbitrary 24.04 install, or anything about that
distribution’s userspace — see the next entry, and the kernel page’s section
on why these are kernel rows. Note also that ubuntu-latest carries an Azure
kernel nine releases newer than 24.04’s own, so the runner above says nothing
about a stock 24.04 in either direction.
One interaction that is easy to miss, because it spans two refusals. PR #290
shipped an AppArmor profile aimed at the namespace requirement on Ubuntu
24.04 (issue #286). That profile is measured, on the Azure kernel the runner
carries — see the entry above for what it reported. This paragraph holds
either way, and that was deliberate when it was written: the point is what the
kernel rows add regardless. 24.04’s GA kernel is the ABI-4 row above, so on a
stock
24.04 the Landlock floor refuses the session at the next gate even if the
profile grants everything it is meant to grant. The two remedies are disjoint —
no AppArmor policy changes the number a kernel reports for its Landlock ABI — so
a working profile there would change which refusal you get, not whether you get
one, and the remedy for the second is a newer kernel. Only a 24.04 running a
newer HWE or cloud kernel — 6.17.0-1020-azure is one — is a machine where the
profile is the only thing standing between it and a session.
One note on this repository’s own CI, because it is easy to over-read. The
integration job takes the printed remedy and modifies the runner before it runs
a single confinement gate. Everything after that step is evidence about a
machine that was granted what it needed, never about a default install — the
measurement above is read from the diagnostic step before it, and the
ordering in .github/workflows/ci.yml is deliberate for exactly that reason.
And when that sandboxing does arrive, it will not close this next gap, so the gap is published before the feature rather than after it. Host-level sidecars sit outside every realm and therefore outside every realm’s confinement — the VLM parser and the egress proxy are ordinary host processes with principal identities, deliberately not realm members, because the parser’s memory-unsafety must stay irrelevant to the TCB and the proxy must hold a listener inside a realm’s network namespace without being confined by it. The consequence, stated rather than left to be discovered:
The VLM sidecar has unmediated host network access. E2.7’s headline claim is therefore “a realm with no egress grant emits zero outbound packets” — a statement about the realm — and is not a statement that realm content cannot leave the machine.
It can leave, through a sidecar the realm’s network namespace says nothing
about. Constraining the sidecars themselves is a decide-by-M3 item; it is not
solved by attribution metadata and must not be described as if it were. See
D-020(5) in docs/plan/20-decision-log.md.
On bare metal at --isolation=off, a realm’s app can plausibly open the real
keyboard and read every key you type — including into other realms, and
including a passphrase. This is the sandbox gap above, pointed at the one
device the whole architecture is built to mediate. logind ACLs
/dev/input/event* to the user owning the active seat session; an unconfined
app runs as the core’s own uid with the core’s full filesystem view, so
nothing stops it from opening those nodes directly. On this project’s own
target machine the maintainer is additionally a member of the input group,
which grants that access independently of any seat — so this is concrete
rather than theoretical.
At --isolation=default this is closed, and by exactly one mechanism. The
realm’s /dev is built from scratch and contains six nodes — null, zero,
full, random, urandom, tty — plus render nodes. /dev/input is not
among them, and the realm cannot mount, so it cannot put it there. Note what
is not doing the work: the input group membership survives into the
realm (see the supplementary-groups limit above), so the app still holds the
credential that would open those nodes. It is the mount namespace alone that
denies it the path. That is a single point of failure, stated as one.
What that bypasses is not a feature but the premise: vitrind’s input router,
the origin tag that distinguishes a human from an agent, the per-realm routing,
the consent grab that makes a prompt unspoofable, and the lock screen are all
downstream of a device the app reached around. An app doing this is not
observed by the journal, is not refused preempted, and does not appear in any
capture.
This entry was published ahead of the code and has now been overtaken twice, in opposite directions. Both corrections are recorded rather than quietly edited, because the pair is the honest history of the hole.
First it got worse: the sentence “it is not reachable today — there is no
DRM/KMS backend” was true when written and stopped being true when WS-E.3.2
landed the bare-metal backend, which has since run on real hardware many times.
Under --nested the host compositor is still the only reader of those devices,
so the exposure was always bare metal only.
Then it got better: P2.6.2’s mount namespace closes it at
--isolation=default, as described above — and the same task gave
spawn/isolation.rs its first real enforcement, so the module that used to
probe this and enforce nothing now refuses a session below the floor. What
remains open is --isolation=off, where every word of the original paragraph
still holds, and the single-mechanism caveat: the credential survives, only the
path is gone.
Testing gaps
The 24-hour fuzz soak has never been run
(#156). fuzz/ ships
two cargo-fuzz targets with a checked-in corpus that CI replays on every PR,
plus a short per-PR burst. The 24-hour clean run the plan asks for is a documented manual
procedure, not a scheduled job, and nobody has executed it end to end.
wlcs conformance is advisory and mostly red
(#157). The
2026-07-25 run, against wlcs 1.6.1-1:
total=180 passed=3 failed=145 skipped=32. The version is part of the number
and not a footnote — the same shim scores 8/49 against wlcs 1.7.0 with no shim
change in between.
That number needs its context, and the context is not an excuse. wlcs tests
a general-purpose desktop compositor. The shim deliberately serves a narrow
surface — no touch, no full xdg-shell policy, no decoration protocols — so
most failures are “no such global” rather than misbehaviour, and the
excluded touch tests are excluded for a structural absence rather than an
expected failure. shim/wlcs/README.md separates the two categories
honestly. But it is still the real number, it has not been re-measured
since that date, and a partial run’s failed= count is a floor rather than
a tally. It never gates a PR and is never built by default.
dmabuf zero-copy is proven by an env-gated test, not by CI. The path is
implemented and wired on the nested backend. The zero-memcpy assertion needs
a real GPU (EGL + a DRM render node) and runs only under
VITRIN_GPU_TESTS=1 cargo test -p vitrin-core --features gpu-tests -- --ignored dmabuf. CI is GPU-free and exercises the shm path exclusively.
Four #[test] functions in this repository run in no CI job at all, and
here they are by name. Issue
#288 made this a checked
number rather than a sentence: cargo xtask skip-scan parses every #[test]
in the tree, works out which CI step compiles and selects it, and fails
until each one that no step runs is listed in UNRUN_TESTS
(crates/xtask/src/test_census.rs) with a reason and either a pointer to
this page or the name of a test that executes it as a child process. The
sentence you have just read is generated from that table and matched against
this page, so adding a fifth gap is a red build until somebody writes both
the fifth bullet and the word “Five” above it — the first version of this
paragraph checked the names and left the number to prose, which is the exact
shape of overclaim this page exists to refuse. Twenty-four tests were selected
by no CI run when that check first ran, and the arithmetic is meant to be
checkable: nineteen were wired into a job instead — which is what the check
pushes toward — four are the bullets below, and the twenty-fourth is the
two-process case described after them, which does execute. (Those historical
numbers are prose; nothing re-derives them.)
dmabuf::gpu_tests::real_gpu_dmabuf_frames_are_zero_copy_end_to_end— needs a real GPU whose renderer imports XRGB8888+LINEAR dmabufs.dmabuf::gpu_tests::real_gpu_probe_accepts_dmabuf_and_kills_memfd_lie— needs an EGL device and a DRM render node; a GitHub runner has neither.dmabuf::gpu_tests::real_gpu_oversized_dmabuf_center_crops_the_full_view— the same GPU, plus the same per-driver import reality (plan risk R3).screenshot::tests::measure_encode_cost_at_a_real_panel_size— not a hardware gap at all: it is a measurement, timing a 2560×1600 PNG encode and printing the number. There is no assertion in it for CI to fail, and a shared runner’s timing would not be a number anybody could act on. It is listed here so the count stays honest, not because a machine is missing.
What this list does not claim is that everything absent from it is
well-tested. It measures whether a test runs, never whether it asserts
anything — and it covers Rust #[test] functions only. The C shim’s Meson
suites, the Python integration ladder and the SDK’s pytest suite each carry
their own collection floors (tests/integration/run.sh,
sdk/python/tests/conftest.py), and those are separate machinery with
separate bounds.
One further test — spawn::isolation::tests::probe_under_ignored_sigchld —
is selected by no CI run either, and is deliberately not on the list
above, because it does execute: it is the child half of a two-process test,
re-executed by name under an ignored SIGCHLD by
a_launcher_that_ignores_sigchld_still_measures, which CI does run. The
check holds that claim to the source, so a rename that broke the chain would
be red rather than quiet.
The DRM/KMS backend will never have a green gate behind it, and that is the weakest evidence in this repository. Every other claim on this page closes on a named, mock-free test. This one cannot, and the reasons are structural rather than budgetary. Eight of them, named rather than summarised:
-
No DRM device in CI. A GitHub runner has no display controller. Nothing there can set a mode, commit a frame or receive a page flip.
-
No seat in CI. No
logindsession, noseatd, nothing forlibseatto open a card through. The backend cannot even reach the point of failing usefully. -
A compile check, and its own name in CI says
COMPILE ONLY. This bullet has now been wrong in both directions and the page keeps both corrections, because a limits page that quietly acquires the right words teaches nothing about how it got the wrong ones. It first said, in the present tense, that a CI rung runscargo clippy … --features drm-backendwhen neither the rung nor the feature existed. It was then corrected to “no such rung exists and no such feature exists — the backend itself is unwritten (#218)” — and #218 landed, so that correction is now the stale half. What is true today:.github/workflows/ci.ymlcarries a job nameddrm-compile-check (COMPILE ONLY - no display controller is touched), which installs the graphics dev stack, runscargo clippy -p vitrin-core --all-targets --features drm-backend -- -D warnings, asserts that smithay’s soft-failing gbm probe actually ran, and runs the backend’s device-free unit tests. It proves the code type-checks against the smithay API and nothing whatsoever about behaviour. It sets no mode, commits no frame and delivers no key. A green tick in a repository whose readers are trained to trust green ticks is exactly how a compile check gets cited as a functional one, which is why the job’s own name shouts the qualifier and why this page quotes the name rather than paraphrasing it. -
vkms-advisorydoes not close this, and must never be read as if it did. There is an advisory job that attemptssudo modprobe vkmsand, when the module is available, reports what it found. What the job actually does is narrower than the device’s capabilities, and the distinction matters: it opens the node, reads mode-setting resources, and probes GBM/EGL/GLES up to locking a front buffer. It deliberately never callsdrmSetMaster, never sets a mode and never flips a page — so it says nothing about mode setting, atomic commit or the page-flip loop, whatever a vkms device is capable of in principle. Whether it exercises the GBM + GLES scanout path at all is unmeasured — vkms exposes no render node, so the GLES half would need a software renderer and may not import into a vkms scanout buffer. The job measures and publishes that answer on each run; until it has run, this sentence is the honest state of it. It is advisory, it never gates a PR, and it is never to be named without the word advisory. -
One machine, one GPU, one panel, one kernel. The evidence that this backend works is one person executing
docs/drm-bringup.mdon one laptop: a single Intel-driveneDP-1at 2560x1600, scale 1, on one Arch kernel and one mesa version. It says nothing about any other GPU, panel, kernel or mesa. The PRD names “hardware matrix” as the first item of the support treadmill that consumed prior alternative display servers; this closes none of it and must not read as if it does.Say the second GPU precisely, because the loose word is the misleading one. That laptop has a second DRM device —
/dev/dri/card2,nvidia, withnvidia_drmloaded and all four of its connectors disconnected, which is howdocs/drm-bringup.md’s hazard H1 records it. What this project has exercised is one device node at a time, whichever one the seat’s primary GPU resolves to: the backend resolves its card throughudev::primary_gpu(&seat_name)and opens exactly that one — and that selection has now been observed twice with two different answers,card1on 2026-08-09 and/dev/dri/card2on the step-13a run of 2026-08-13, from the same machine. An earlier version of this paragraph said “nothing here has ever openedcard2”; that is retracted, and it was already false when this page last said it, which is the more useful half of the correction. What openingcard2established is narrow: that session lit a 2560x1600 output and carried six touchpad rungs through it, so the node the selection chose worked. What it did not establish is which device sat behind the node — the record names the node and not the driver, so whether the selection took the NVIDIA GPU or took the iGPU under a renumbered node is not recorded either way, and nothing here is evidence that this backend drives an NVIDIA card. Hard-code neither node. Nothing about a multi-GPU path changed either: there is no PRIME path, no multi-GPU renderer and no buffer import between devices anywhere in this repository, so no result from one node generalises to two. “Untested” would imply a multi-device path exists that nobody exercised; none exists. No issue tracks a hardware matrix, and none should: a matrix is a support treadmill the PRD names as the thing that consumed prior alternative display servers, not a defect. -
The trusted band has an automated witness, and it covers one backend — not the one you would daily drive.
backend/band_witness.rsmeasures the negative half of the band’s unspoofability property: that a confined app’s own rendering can never reach the band’s rows on the human-visible frame, in numbers a harness can hold without ever holding the session secret. It is wired intobackend/headless.rsand into nothing else. Grep the DRM backend forband_witnessand there are no hits, because a witness needs a framebuffer a test process can read and a bare-metal session’s is a scanout buffer behind DRM master. So the property the whole trust story rests on is machine-checked on the backend CI runs and asserted, not checked, on the backend a human looks at. Nothing was weakened to make that true and nothing restores it; the alternative would be a witness on a backend no runner can reach, which is not a check. #173 tracks the human half nobody has evidence for; the DRM half has no issue, because there is nothing a CI change could do about it. -
The bring-up runbook has been executed twice in full, both on 2026-08-09, and it carries a dated record block for each. Neither was a clean pass: three defects came out of the first, one of which was that the page’s own first line of recovery did not exist. A third, partial execution followed on 2026-08-13: step 13a, the touchpad-class rung, which carries its own “Record block — EXECUTED 2026-08-13” and whose six sub-rungs came back five PASS and one defect — #275, a gesture interrupted by a VT switch that ends
completedwhere it must saycancelled. Read that as one rung, not a third pass of the runbook: steps 12a, 16 and 17 are still marked NOT YET RUN on that page, and #220’s frame-cadence field was never captured in fps. A runbook nobody has executed is a plan, and the wlcs number above is this repository’s standing example of how a manual result ages once it is taken. -
The session-lifecycle checklist has been executed twice, on 2026-08-11 and 2026-08-13 — plus a 2026-08-12 re-read of one rung — and neither full run was a clean pass. Blanking, suspend, lid handling, deliberate-wedge recovery and returning from another VT are rungs
L1–L7in Getting out of a wedged session, where the dated records now live. What they establish, at the counts the rungs themselves ask for:L110 of 10 VT switches with a stable band colour;L25 of 5 suspend/resume cycles, four on 2026-08-11 and the fifth on 2026-08-13, each returning a working panel — with liveness proven on the second run only, because the first had no keymap passed and therefore no way to tell an idle app from a frozen one;L35 of 5 lid cycles, two on 2026-08-11 of which only one ever reached sleep, and three more on 2026-08-13 that all did, each with the same typed-after-resume liveness proof — plus a fourth close reopened inside one second that correctly never suspended at all, which is the short-lid-close case a single sample could never have established;L4blank at 61.2 s with the panel returning on physical input, andL5no lock card.L6’s answer was lost once and is now recovered: the 2026-08-11 wedge came back in ~69 s by a route that could not be reconstructed afterwards — not from the journal, not from either flight recorder, not from the process tree — and 2026-08-13 settled it,kill -CONTagainst a 163.8 sSIGSTOPwedge, recovering in the next logged millisecond, with route 1’s chord found to have been queued rather than defeated.L7is now a measurement rather than an impression: 61.214 s lit, counted from the seat’s return against a 60 s timeout on 2026-08-13. The rungs filed four defects (#257–#260), one of them that the recovery page’s own published command was wrong — and a fifth, #268, came out of the same 2026-08-11 session, from driving alacritty and nautilus rather than from any rung, so a reader counting defects against that date should count five. The generated session app matrix is where that fifth one is recorded; understating a defect count is the direction this page holds to be the more corrosive one. The second run filed a sixth, #277, and it is #260’s class again:kill -TERM, the command route 2 published, is inert against aSIGSTOPed core, so the recovery page has now been wrong twice about its own central instruction — which is the reason to read it sceptically rather than a reason to leave the count at five.L4is therefore not a clean pass: #257, #258 and #259 — the panel blanking ~1.5 s after a return from another VT, a silent unblank, and neither transition reaching the flight recorder — all came out of that session, and #257’s fix has since been observed on hardware — rungL7was written from it and run later the same day, at a 20 s timeout: the panel stayed lit on the return and the lock did not raise, so both symptoms are gone on the machine that produced them. That pass was by eye and produced no figure; the figure exists now, from the 2026-08-13L7run at a 60 s timeout, and it is the 61.214 s above rather than anything the 20 s pass could distinguish from 17 s. #258 and #259 have since been observed on hardware too — a secondL4execution on 2026-08-12 read the log and the recorder rather than only the panel, and found the wake line and thescreen_blanked/screen_wokepair carryingoutcome: flip_landed, and 2026-08-13’sL7run re-observed that same log line and recorder pair in passing. One caveat travels with it: the failed-wakeWARNhas still never been emitted on hardware, since no wake has failed there. Still unexecuted, and named rather than implied: the SysRq route (route 3) and route 4, both still careful predictions;L7’s second pass, which was attempted on 2026-08-13 and caught no absence to measure; and step 12a’simmediateandidleseat policies — onlyneverhas ever run on hardware, andidleis the branch that would return you locked. The advisory VKMS rung is no longer “never attempted” and is worse than that: CI attempts it on every pull request and it currently covers nothing — read on 2026-08-13, the module loads and no card node appears behind it, so no connector enumeration, no mode set and no GBM/EGL probe run at all. Two runs on one laptop are a report about that laptop and nothing more.
This is a recorded decision with a scheduled closure in the sense that page’s last section means: the closure is a dated human run, not a job. The alternative — a green check proving compilation, read as proving function — is strictly worse, and is precisely the honesty gap this page exists to prevent.
Model gaps
The trusted indicator is unforgeable within one VT, and not necessarily noticed. There is a rigorous gate proving a client cannot counterfeit the band. There is no evidence that a human notices when it is wrong — that needs user research nobody has done. The plan explicitly adjudicated unspoofability out of M1.4’s criteria for exactly this reason. Do not cite the milestone as evidence for the human half. The second qualifier is the VT: the band says nothing about any screen other than the one this core is driving, which is spelled out with the VT-switch entry below.
Several realms run; only one is visible. A realm.toml may now declare
up to 16 realms, and each gets its own shim process, its own private runtime
tree, its own Wayland socket and — since the output binding landed — its own
scene, its own capture and its own seat state. What it does not get is its
own output: the core composites one output from one realm’s scene, so with two realms
running only the realm the output is bound to is on screen. Which realm that
is is now somebody’s to choose: a client holding the layout.focus grant
verb moves the output, and the human’s own keyboard and pointer move with it —
one act, because showing a realm and typing into it must never come apart. An
agent’s actuation does not follow the output at all — it follows the realm
its own grant names, so an agent works in a realm nobody is looking at.
Absent such a client the output binds to the first realm to attach, and moves
on one event nobody chooses: the bound realm’s app exiting, after which the
output follows to the first realm still serving, and to no realm at all once
none is serving. Treat a multi-realm configuration as “several apps running,
one of them on screen”.
And there is exactly one output, by contract — a second connected display is
refused at startup rather than half-served. Those are the session’s two
cardinalities and they do not move: up to 16 realms, one output. The
singularity is in the contract rather than in the content, which is why sixteen
live realms do not buy a second panel: Presenter::view_size is one size for the
whole session and every realm’s shim is configured with it once before the first
fork, RealmScenes::bound is a single Option<RealmId> that the human’s seat
target and the agent cursor’s coordinate space both resolve through, and the
status strip has one caption. Coming up on two panels anyway would light
whichever connector enumerated first and leave a powered display dark with no
message and no verb in the protocol that could ever move the output to it, so
on --drm the backend refuses to start and names the connectors it found.
Two consequences, neither closed. A laptop plus an external monitor — the most ordinary desktop arrangement there is — does not work here, and the refusal tells you to unplug one. And the refusal is a startup one: this backend enumerates connectors once and installs no udev monitor, so a panel plugged in mid-session is neither lit nor complained about, and unplugging the only panel leaves the session compositing into a surface nobody sees. That gap is deliberately unowned rather than absorbed into the seat-pause handling, because a paused session still has its panel and is told when it gets it back, while an unplugged one has no event promising a return — holding a consent card and a lock for a screen that no longer exists is a different decision with a different failure mode, and nobody has taken it. The refusal came with WS-E.3.2 (#218); the hot-plug gap has no issue, and is a numbered item in that workstream’s runbook instead.
Layout is two requests, and the absences are deliberate. A holder can
focus a realm and choose whether it fills the output or keeps its own size.
There is no place, no resize, no raise and no stacking — not requests
that refuse, but no requests at all, because a scene showing one unstacked
realm cannot honour them and a verb that silently does less than its name is
worse than one with no request. Do not plan a tiling shell against this yet.
A principal cannot draw, so nothing a client builds can be on screen.
vitrin_view is capture-only and there is no principal-facing surface
interface anywhere in the IDL — a grant can read a realm’s pixels and can put
none back. So the switcher this project ships
(examples/shell/run_shell.py)
is a line-oriented program in a host terminal, and that is not a placeholder
for a graphical one: no amount of client work reaches the output. The intended
eventual shape is the shell running as a realm, drawing through its own
shim like any other app while holding the layout verbs through the ordinary
grant path — which needs no new protocol, and does need that realm to reach the
core socket. Since 2026-08-24 that is decided and unbuilt rather than
unanswered
(#311,
D-046),
and this is what a shell realm may reach that an app realm may not:
- Exactly one extra thing: a principal connection to the core. The core mints it and passes it down the spawn path as an inherited file descriptor — so nothing is mounted, no path is opened, no Landlock rule is added, and the realm’s mount table stays closed. A measured walk of a realm’s whole filesystem finds exactly one socket, the shim’s own, and that stays true after this. Everything else about a shell realm — its mount table, its Landlock domain, its namespaces, its retained supplementary groups — is the confinement every realm gets. An app realm is handed one socket, its own shim’s, and nothing else.
- What the connection buys is fenced. It may petition, and it may hold
layout_focus,layout_arrangeandrealm_launchover other realms — the last of which is authority to create realms, the widest authority here, and it is granted rather than assumed.observe,actuate_pointerandactuate_textare refused to it regardless of what a human approves. - A realm with no principal connection can hold no grant at all, because a grant row keys on a principal identity and a realm has none. That is the real size of the widening: one realm becomes both a confinement subject and an authority holder.
- The authority to hand one over is an operator’s
realm.tomldeclaration and a human’s consent — both, not either — at thewhile_runningrung, because the durable rungs are structurally impossible in this build (they need a verified binary identity, which is Phase 3). So the consent is once per core start, and there is no connected-apps surface to forget it from later. D-046 further asks that the card render a disabled “remember me” with its reason rather than omit it. That is a reversal of what this core does today, not a description of it: a durable rung is currently absent from the card — “not greyed out, not hidden-but-implied” — because it is not representable at all, and two tests pin that. Nothing has changed the card. - None of this is built. It is a decision about work not yet done; no code in this tree passes such a descriptor, and the fence stated above is an intention until a chokepoint enforces it.
Until the shell realm exists, anything you would call a desktop shell — a bar, a launcher, an OSD, a window-switcher overlay — cannot exist on this display server, and the replacements are core-owned surfaces (the trusted band, the consent card, the attention marker, the lock screen and the status strip) that no client can add to.
No client status bar is possible, and the core’s --status strip is the
whole of the replacement. zwlr_layer_shell_v1 is not in the shim’s global
contract, and that was measured rather than assumed: waybar connects, binds six
globals, and never maps a surface; rofi and wofi are the same class. So
vitrind --status draws the strip itself, in reserved rows immediately below
the trusted band, and it shows three facts: the focused realm’s name, the
battery, and a clock. There is no tray, no notifications, no workspace
switcher, and no click targets — it is not interactive at all, because a
principal cannot receive physical input (above) and the core does not want
another core-owned gesture for a status bar (it already owns eight; they are
enumerated under principal-has-no-hotkey below). Three further limits belong
with it:
- The strip is unspoofable in pixels but is not self-authenticating. It always wins the composite, so a confined app cannot cover it — but an app can paint a convincing fake strip one row lower. The band above it is the anchor, and the rule is “trusted content is everything above the coloured line”. That is strictly weaker than the band’s own guarantee: the band proves itself, the strip only inherits position from it. This makes the indicator story three rules where there was one, and a human who cannot state the rule cannot apply it.
- The clock is UTC unless you say otherwise, and there is no DST. The core
carries no timezone database — a
tzfileparser and a recurring read of/usr/share/zoneinfois authority the TCB is not taking for a cosmetic field — so--status-utc-offset +09:00states a fixed offset and the strip always labels the zone it is showing. A session running across a DST boundary shows an hour that is wrong until the operator changes the flag. -
The strip is a recurring filesystem read inside the TCB. The battery
comes from
/sys/class/power_supply, re-read every 30 s, bounded to one fixed root, 16 directory entries and 16–32 bytes per attribute, with every failure — no battery, a desktop, a machine mid-suspend — collapsing to an empty slot rather than a guess. When Landlock over the core’s own process lands, this becomes a rule the core must grant itself, i.e. this widens that future sandbox. And--backlightwidens it further, in the direction that matters: the brightness keys described below are a write to/sys/class/backlight/*/brightness, bounded the same way (one fixed root, names sorted before the 16-entry cap is applied, and the cap bounds the auto-pick — a device you name with--backlight-deviceis matched against the whole directory, or the flag would silently stop working on a class with seventeen entries in it — 24 bytes per read, every failure a no-op), which makes it the first rule in that future ruleset with a write bit in it. Recorded here rather than left for whoever writes the ruleset to discover. It is not a--statusfact and does not need the strip; the two are listed together because they are the two sysfs class trees the core walks, and because the second is the one that turns a read-only future ruleset into a read-write one. They are not the only sysfs paths the trusted core touches. There are four, and the other two are single files read once rather than directories walked on a timer:/sys/class/tty/tty0/active, which the bare-metal backend reads to learn which VT it is on, and/sys/module/apparmor/parameters/enabled, which the spawn path reads to decide whether an AppArmor label means anything on this kernel — the same file this page already cites in the confinement section above. Both the read and the write are owed to #314, which owns the core’s own Landlock self-sandbox. It is unbuilt and no plan document schedules it. This bullet named #187 until 2026-08-23, and that was wrong rather than merely stale: #187 built the realm’s ruleset insidevitrin-realm-init, over a filesystem view that has alreadypivot_rooted away from/sys, and it never owned a rule aboutvitrind’s own process at any point in its life.
A principal cannot receive physical input either, so no client has a
hotkey. There is no observe_input verb and none is designed. The core owns
eight physical gestures — the dead-man chord, the attention chord, the two
clipboard chords (Ctrl-Shift-Insert and Shift-Insert), the lock chord, the
screenshot chord, and, on a --drm session started with --backlight and only
then, the two brightness keys — and owns them precisely because the human’s
off-switch, the human’s attention gesture, a cross-realm transfer, the act of
locking a screen, a picture of one’s own screen and a panel you can actually
read must not depend on a client being alive and correct. (On --drm the core
also consumes the twelve Ctrl-Alt-F<N> chords, which is a different thing
again: they hand the seat to another console rather than doing anything inside
this session, so they are not on that list and are described under VT switching
below.) The count was five before D-041 and that was already one short of
its own list; it is written out in full here so the next entry has to change a
number as well as add a clause. A
convenience hotkey is not in that class and must not borrow that warrant, so
“Super+Tab switches windows” is not a missing feature: it would mean the core
reserving a chord on behalf of whichever client asked first, which is
window-management policy the core deliberately does not have. What follows for
a user is concrete: every layout change starts as a line you type into a
terminal, and the terminal has to be somewhere you can reach.
The seat serves a pointer and a keyboard: there is no touch and no tablet.
wl_touch is deliberately absent from the shim’s advertised seat capabilities —
shim/src/globals.c says TOUCH IS NOT YET SERVED in those words — and a
tablet or stylus has neither a shim global nor a wire event. The absence is
deliberate rather than a smaller version of support: a class advertised with
nothing behind it is worse than an absent one, because a toolkit that sees
TOUCH stops installing its pointer fallbacks and you get an application that
responds to nothing at all.
Both are deferrals with named reopening evidence, not refusals, and the difference is the whole reason they are stated this way. Touch reopens on a touchscreen appearing in the measured device set together with an application that needs it; tablet reopens on a pen or stylus in that set, its application half already being on record. The measured machine has neither device, and that is a measurement of one laptop rather than a property of the protocol — a wire protocol that intends to be permanent may not foreclose a device class because one machine lacks one.
What is served is relative motion, pointer gestures and pointer constraints (#222) — and they are landed in the tree and unproven on hardware. No run has yet delivered any of them to a connected application, because CI has no touchpad and no DRM device, so what stands behind them is unit and component tests rather than a mock-free gate.
A gesture that a consent card or the lock interrupts is ended the wrong
way, and that is owed rather than argued for. The router ends an in-flight
gesture cancelled on a realm switch and on a seat pause, for the stated
reason that a begin with no end leaves the losing app accumulating a gesture
forever. A consent card or the lock screen raising mid-gesture takes a
different path on purpose — the gate withholds the gesture’s updates and
keeps delivering its end, because the router only ever delivers an end for a
begin it delivered — but what then arrives is the device’s own end, so an
app that was previewing a pinch-zoom when a card came up is told the human
completed what they in fact abandoned. Nothing wedges and nothing leaks; the
app’s state is simply wrong in a way the human did not choose. Owned by
#222.
And on the daily-driver backend a held key does not repeat at all. The shim
sets wlr_keyboard_set_repeat_info to a rate and delay of zero, so no
application in a realm ever runs its own repeat timer, and there is no repeat
implementation anywhere in the core — grep crates/vitrin-core/src for one and
there is nothing but comments about filtering a host’s autorepeat out.
Nested, that is invisible: the host compositor repeats and the core forwards
each repeated event individually, so a held key behaves. On --drm there is no
host, libinput synthesizes no repeat, and holding a key therefore produces
exactly one character. The refusal to turn the shim’s timer back on is a real
decision and a good one — repeat is seat-wide, this seat carries an agent’s
actuations beside the human’s, and the repeat machinery cannot see the
per-event origin tag, so a client-side timer would repeat an agent’s held key
— but the compensating core-side repeat that decision assumes was never
written. Read this as an unimplemented half of D-028(5), not as a design: no
run has confirmed it at a prompt, because CI cannot, and the one bare-metal
session that drove a terminal (2026-08-11) did not test for it. It has no
issue, because it was found by reading the tree during this sweep rather than
by using the session.
If the shell dies, you keep the session and lose the ability to re-aim it.
The switcher is a client (PRD §5.1, D-021(4)), so there is no core-side
fallback — that is the price of the invariant, paid rather than argued away.
Kill it and both realms keep running, their shims and apps being children of
vitrind rather than of the shell, and the realm it last focused keeps
receiving your keyboard and pointer, because the output binding is core state
and nothing revokes it when the principal that set it disappears. What you
cannot do is move it. Recovery is running the shell again, which re-petitions
from zero and raises a fresh prompt per realm — and in a real session the
terminal you would restart it from must already be the bound realm. If the
shell died while the output was pointed at a realm with no terminal in it,
nothing on screen can start it, and every remedy is outside Vitrin: an SSH
session from another machine, a VT switch, or restarting vitrind. This is a
genuine wedge; it is documented, asserted
(tests/integration/test_shell.py),
and not solved.
set_fullscreen is a no-op whenever the realm’s view is already the output’s
usable size. The two modes differ only in whether the realm’s view size
tracks the output’s, so while they are equal — which is the ordinary case for a
realm spawned into an output that has not resized — switching between them
changes nothing you can see. It is honest and it is surprising; the IDL says so
in as many words. The size they are equal to is the output’s usable view,
not the output: the core reserves the top rows of every frame for the trusted
band, and for the status strip when --status is on, and an app is configured
for what is left. This bullet said “the output and the realm are the same size”
until that inset shipped, which named a condition that no longer occurs at all.
Captures do tell realms apart, and that is enforced rather than
incidental. An agent’s capture is of the realm its grant names, never
of whatever is on the output: the compositor keeps one composed frame per
realm and the chokepoint resolves the frame from the grant’s realm id, on
the same line it judges that realm’s liveness. A grant over a realm whose
app has died refuses no_surface however busy its siblings are; a grant
over a live but hidden realm returns that realm’s own pixels. There was a
window — between the realm cap being raised and the output being bound —
where this was not true and a capture could carry a live sibling’s pixels;
it is closed.
Every realm renders, whether or not you are looking at it — and whether or
not any agent is connected. A hidden realm keeps receiving frame callbacks
paced by the output’s composites, and keeps having its view composed. That is
not generosity: a Wayland client throttles on frame callbacks, so a realm that
stopped being paced would stop repainting and its capture would go stale —
which the protocol forbids outright (no_surface is documented as “never a
stale frame”). The second half is the one this page used to leave out: the
compositor also does not ask whether anything will read a composed view.
With no agent connected, no --capture-dump and no --screenshot-dir, a
realm that is painting still has its view composed and cached every round.
Gating that on whether a grant happens to exist would make what a capture
returns depend on when the grant appeared, and that trade was declined.
What the compositor does skip is a realm whose scene has not changed since the
last composite: the cached view is then already byte-for-byte what recomposing
would produce, so nothing about what any reader is served depends on it. That
saves the idle case and the sibling case — one realm painting no longer costs
its fifteen neighbours a composite each — and saves nothing at all for a
single realm whose app is busy painting. The rest of the cost is real and is
not traded away: on a laptop, up to sixteen apps compositing at the output’s
rate with nobody watching fifteen of them, plus roughly
2 x width x height x 4 bytes of core-side pixels per realm (~590 MiB
resident at sixteen realms on a 2560x1600 panel, measured).
The agent cursor is drawn only for the visible realm. The core paints a small crosshair where an agent is pointing, so a human can see that an agent is acting. It is painted into the output, which shows one realm — so an agent actuating inside a hidden realm draws no sprite, and the human loses that signal entirely for everything happening off-screen. This reintroduces, for hidden realms, exactly the defect the sprite was added to close, and per-realm input routing makes it more likely to bite rather than less: agents can now actually work in hidden realms, so there is more going on that nothing draws. The fix is a per-realm indicator in the trusted band and it is not built.
A human’s hand no longer stops agents in other realms — and that is a
narrowing of a blanket safety behaviour. The core refuses an agent’s
actuation preempted while physical human input owns the target, and “the
target” used to be the whole session: touching the keyboard suspended every
agent everywhere for half a second, whatever realm each was working in. It is
now judged per realm. Typing in realm A suspends agents acting on realm A
and leaves agents acting on realm B alone, which is what “several apps running
concurrently” has to mean — but if you were relying on the old breadth as a
crude session-wide “hands off while I work”, you no longer have it, and no
wire event tells you so. Layout requests (focus, set_fullscreen) still
yield to a hand anywhere the human is: those move what you are looking at
rather than being delivered into a realm, so they are judged against the realm
your input is following.
You cannot switch realms in the same half-second you typed — unless you tap
Super first. Because layout requests yield to a hand, any physical input marks
the realm you are in as yours for 500 ms, and a focus or set_fullscreen
arriving inside that window is refused preempted. That is correct for the case
the rule was written for — an agent must not move the output out from under
someone mid-keystroke — but it lands hardest on the most ordinary human action
there is: type focus editor into a shell and press Enter, and the Enter is
itself the physical input that preempts the request the Enter just sent.
The core therefore owns a second key, Super (configurable to right-Super,
and to nothing else). Tapping it opens a one-second, single-use window in which
a layout request from a principal holding layout authority is not refused
preempted, and it sends those principals a one-bit attention event so they
know to send the request they had staged. The key is consumed: no app in any
realm ever sees it, which is also why it cannot be used as a keystroke-timing
oracle. It delegates nothing — everything the client does afterwards it
could already do; what you withdrew was a courtesy the core was extending to
your own typing.
What it costs you:
-
The core eats Super, everywhere. A nested compositor, a VM viewer, or a
remote-desktop client running in a realm loses that key with no pass-through
and no way to ask for one. The only remedy is
--attention-chord rsuper, which is not really a remedy. - The window is session-wide. If two clients hold layout authority, either of them may consume the press — the core cannot know which one you meant, and choosing would be window-management policy it deliberately does not have. Your own switch then silently fails and the other one lands. The claim is journaled with the principal that took it, and the grant is revocable, and it is still a hole.
- A client can ask you to press it. A shell printing “press Super to apply” is doing the right thing; a malicious one printing the same string and banking the timing is indistinguishable from it. What bounds this is that the press confers no authority the client did not already hold — but a human who learns “press Super when the screen tells me to” has learned a habit an attacker can invoke.
-
preemptedon the layout verbs is now conditional on core state you cannot see. An agent reading its own journal can no longer reconstruct why onefocuslanded and an identical one did not. - Other principals lose a guarantee nobody tells them they lost. “A human typing means nobody moves the output” was true for 500 ms at a time and is now suspendable by a gesture no wire event announces to anyone but layout holders.
While the window is open the core draws a small marker just below the trusted band — never inside it, because the band has exactly one correct appearance and that is the whole of its value. A focus change that happened with no marker up was not yours.
Switching realms mid-gesture releases what you were holding, into the realm you left. A key or pointer button you are physically holding when the output binding moves is released to the app you are leaving, because your actual release will be delivered to the realm you moved to. The app cannot tell that release from a real one — it is told you let go when you did not. The alternative is worse (a modifier latched down forever, or a wedged pointer grab, in an app you can no longer see), and it is the same trade the core already makes when the nested window loses host keyboard focus; the difference is that it now happens on every switcher keypress rather than only on alt-tab. An agent’s held keys are not touched — its grant still reaches that realm, so it can release them itself.
Resizing the nested window does not resize the apps. vitrind --nested
runs inside a host compositor’s window, and that window can be dragged to any
size. The core announces the view size to a realm’s shim exactly once, when
the shim session starts, and never re-announces it — so an app keeps its
startup size and the composite centres it, 1:1, in whatever the window has
become: background bars around it when the window grows, a centre crop when it
shrinks. Nothing is lost and no capture is wrong (a capture is composed at the
same view size), it simply looks wrong. This is not the per-realm resize
WS-E.1.3 declined; it is the one output changing size with nothing told about
it. --headless has a fixed virtual output and cannot reach this, which is
also why no CI gate can see it — shim/docs/nested-multi-realm.md carries it
as a manual step.
No realm enumeration on the wire — but do not read that as unguessable.
A client cannot ask what realms exist; realm-0 is the one name it can know
without being told, and vitrin_launcher.launched hands back the ids of realms
it started itself. What that does not buy is secrecy of the others: instance
ids are <template>.<n> with a small session-global counter, so a client that
knows or guesses a template name can guess live instance ids cheaply, and
petition admission answers differently for a realm that exists than for one
that does not. So the id space is a naming scheme, not a capability — the
grant is what confers authority, and knowing a name gets you a petition the
human still has to approve. Treat any design that leans on an id being secret
as broken. Multi-realm fleet mode — a 50-realm headless box — is Phase 3 and
is a different thing again.
Runtime launch exists, and it is a real reduction in what the core
guarantees. realm_launch is served: a principal holding it over a realm
template can make the trusted core fork a process, repeatedly, for as long as
its grant lives. Until this landed, the only thing that could make vitrind
fork was startup reading a file the operator had hardened. That property is
gone. What replaces it is weaker than “impossible” and is stated as such: a
human’s approval on a card naming the template’s program, an expiry,
revocation, the grant’s rate ceiling, a cap of 16 live realms (refused
capacity), and a journal entry naming the principal and grant behind every
spawn.
And little bounds what a launched app then does. A launch grant is
authority to start a process confined exactly as much as the session is — at
--isolation=off, an unconfined one with the core’s own uid and filesystem
view; at --isolation=default, one that is path-confined and filtered
against a named list of thirteen syscalls, which is not the same as
syscall-confined. P2.6.2, P2.6.3 and P2.6.4 each narrowed this and none
closed it: the launched process still gets the kernel’s whole syscall surface
minus that list, the operator’s supplementary groups and a read-write render
node, so the confinement limits above apply to it unchanged, one authority
level up.
A launched realm cannot be closed, by anybody, ever. This is the sharper
half of the point below and it is worth stating on its own: there is no wire
request that ends a realm, and nothing in the core reclaims one. Revoking the
launch grant does not close what it started; nor does closing the connection
that asked; nor does the dead-man switch, which revokes every grant and
leaves every process running. A realm ends when its own app exits, and not
otherwise. So one approved realm_launch grant, exercised 15 times before the
human revokes it, commits every remaining slot of the 16-realm cap for as long
as those fifteen apps keep running, and revocation will not get one back.
State the cap’s arithmetic precisely, because the loose version overstates
it. The cap counts live realms, not launches: Realm::occupies_capacity
excludes the terminal state and capacity_used — not len — is what a launch
is refused against, so when a realm’s app exits its slot returns and the
session can launch again. Sixteen simultaneously live realms is the limit,
not sixteen launches per session. Both halves have to be published together or
each becomes a lie: no principal and no wire request can end a realm —
revocation, disconnect and the dead-man switch all leave the process running
(#234) — and a slot
comes back only when the realm’s own app exits. So the human’s remedies for a
realm they no longer want are the app’s own quit path, killing the process from
a terminal, or restarting vitrind; the display server offers none. Revocation
bounds future launches and nothing else; read it that way when deciding
whether to approve one.
Launched realms accumulate for the life of a session. An exited realm
keeps its row so unavailable keeps meaning not ever, so a session that
launches continuously grows a table of dead names it never frees. It costs no
process, no descriptor and no pixels — a name and a spawn config — and it is
bounded only by the grant’s rate ceiling and expiry, not by a count. A
long-lived session driven by an agent launching on a timer will grow that
table without limit.
A human can now move text between two realms, and that is a channel with a stated bandwidth. Copy-paste between realms exists as of WS-E.2.1: pressing Ctrl-Shift-Insert asks the realm you are looking at for its selection and puts it in a single core-held slot; pressing Shift-Insert in another realm offers that slot to that realm’s app, which you then paste into with the app’s own paste key. Two gestures, one direction each, and no client can trigger, force or observe either — the core asks, and there is no message by which an app or a shim can announce a copy.
Read the rest as a bound rather than as an absence, because that is what it is:
text/plain;charset=utf-8only. No images, no rich text, no file paths.- 60 KiB at a time (61 440 bytes), measured against real file sizes and against the wire’s own 64 KiB frame ceiling — a larger cap is not expressible without handing the trusted core a shim-controlled memory mapping.
- The slot is cleared after two minutes, when the realm its contents came from dies, and whenever the dead-man switch fires. Nothing tells you it was cleared; a gesture that finds an empty slot simply does nothing.
- Two colluding realms can therefore move ~60 KiB per human gesture pair. Qubes accepts the same bound. The honest statement is the bound, never “there is no channel”; the PRD’s threat-model row was edited rather than left standing.
The trusted core now stores bytes an application authored. Nothing else in
it does — it holds client pixels it never interprets and typed values it
validated itself. A password copied from a manager transits vitrind and rests
in that slot until one of the three clearing rules fires. The cap, the
one-type allow-list, the digest-only journaling (the flight recorder records a
length and a BLAKE3 digest, never content) and the three clears bound it; none
removes it. This was decided deliberately, with that cost stated, and it is the
first time this project has made that trade.
Two more keys are taken from every app. Ctrl-Shift-Insert and Shift-Insert
are consumed by the core in every realm, with no pass-through and no way to ask
for one. Shift-Insert is the historical X11 primary-paste chord, so an app that
binds it loses it. --clipboard-key moves both to another key, which is not a
remedy so much as a different loss.
The lock screen does not lock out agents, and this is the single most
surprising thing on this page. As of WS-E.2.2 there is a lock screen, and
three things raise it: Ctrl-Alt-Delete, --lock-idle SECS of no physical
input, and — only if you asked for it — a VT switch away under
--lock-on-seat-change immediate, which is described with the other two seat
policies further down this page and which a session that never names that flag
can never produce. Whichever one raised it, it covers the output with a
core-drawn card and takes every physical event away from every realm until
you type your passphrase. What it does not do is touch a grant. An agent
holding observe keeps capturing the realm across a lock, frame for frame,
exactly as if you were sitting there; one holding actuate_pointer or
actuate_text keeps acting.
That is a decision, not a gap somebody forgot to close. Observation is
concurrent by design in the wire protocol (vitrin_view), so preempted and
consent_held never refuse a capture, and a lock takes away your input, not
an agent’s authority. Three alternatives were considered and rejected: a new
refusal code (a v0 wire-semantic change, which belongs to the protocol track);
blanking the realm view so agents receive black frames (a lie by omission — the
agent is never told why it sees black); and routing the lock through the
enforcement chokepoint as a synthetic human principal (which invents a wire
principal the identity layer does not have).
The instrument for “stop everything” is unchanged and still works while locked: hold the dead-man chord, which revokes every grant in the session, denies every pending petition and clears the clipboard slot. The lock card says all of this on the card itself, in the same words, because a human who locks a screen and walks away should not learn it from a documentation page.
On bare metal, that same continued observation holds across a VT switch too —
with one difference that is worse and is not softened here. The subject here
is the agent’s access, not the paragraph immediately above: your dead-man
chord is a physical gesture, and physical input is suspended for the whole
time you are on another VT, so the emergency stop that still works while locked
does not work while you are switched away. From another VT your only stop is
a shell and a signal. An agent holding observe keeps being served
its realm’s capture while you are on another VT — the capture is composed from
the realm’s scene, which a VT switch does not touch, so the request keeps
succeeding — and one holding actuate_pointer or actuate_text keeps acting on
the app. Keeping both is right for the reason above: the grant is the authority,
not your gaze. But the pixels stop changing. While the seat holds the
devices no page flip lands, so no frame_done is issued, so every app that
paces on it stops painting; the agent is served the same frame it had when you
switched away, with no staleness signal and no refusal. That is not what
happens across a lock, where the frame clock keeps running — so read the
sentence above as true of a lock and this one as true of a VT switch. The net
effect is the uncomfortable one: across a VT switch an agent can still act and
cannot see the consequences, and neither can you. Giving realms a software
frame cadence while the seat is away would fix the observation half; it is not
scheduled, and the human’s half needs a mission-control shell (E3), which is
also not in this workstream.
One more thing that gets quietly wider while you are away: preempted — the
refusal that stops an agent acting where your hands are — is judged against
recent physical input, and physical input is suspended for the whole switch.
So the moment you leave is the moment agent actuation stops being refused
preempted. That is correct (you really are absent) and it means agent
authority is at its widest exactly when your view of it is at its narrowest.
In nested mode the lock screen locks a window, not a session. vitrind
runs as a client of your real compositor, which is above it and owns the actual
session: anyone can alt-tab away from the locked window, and the host’s own
screen lock is still the thing protecting the machine. Treat the nested lock as
what it is — a privacy cover over the realms vitrind is showing — and not as
an authentication boundary for the seat.
vitrind never inhibits VT switching, and on bare metal it has to
implement Ctrl-Alt-F<n> for it to work at all. On the nested backend the
chord is the host compositor’s business and outside this project’s reach. On
bare metal it is vitrind’s, and there is no third option: once a process
holds the display, the kernel stops handling that chord, so a display server
that does not implement it is one you cannot leave.
An earlier release of this page said the opposite — that the chord was left
alone on purpose, because a display server that traps you on its own VT is one
you cannot leave when it wedges. The reasoning was right and the effect was
its own opposite. That code was run on a real panel for the first time on
2026-08-09 and the human could not leave: Ctrl-Alt-F1 and Ctrl-Alt-F2 did
nothing, and the session ended only because it was killed from another shell.
The words are being changed, not quietly swapped: the decision that was written
to keep the escape hatch open is what welded it shut.
So, in this release:
Ctrl-Alt-F1…Ctrl-Alt-F12switch virtual terminal, exactly as they do under every other Linux compositor.vitrindnever switches your VT for any other reason — not on a timer, not on an agent’s request, not to bring you back. Only your own hands can move it, and no principal on the wire can, whatever it holds.- They work while the screen is locked. That is deliberate, and it is
argued rather than assumed: being trapped is worst in the state where you
cannot dismiss what is in front of you. It is never a way past the lock —
the lock stays up and still wants your passphrase when you come back. What
someone standing at your locked laptop gains by pressing it is a login prompt
on another terminal, which they could have reached before you started
vitrindor by power-cycling the machine. It is strictly less than what they can already do: the dead-man chord revokes every grant in your session and fires through the lock on purpose. - Twelve keys are taken from every confined app on bare metal. The chords
are consumed in every realm and never delivered. Same as every other Linux
compositor; stated because this project states what it takes.
f1…f12are also no longer available to--dead-man-chord,--lock-chord,--clipboard-keyor--screenshot-chordunder--drm, and a command line that asks for one is refused at startup rather than silently rearming your off-switch every time you leave the terminal. - Know your own VT number before you start. The startup banner logs it. A human who can leave and cannot come back is only half rescued.
- If a switch fails, you will see it on the panel, in a red band that names what happened and what you can still do. A log line is worth nothing to somebody who cannot leave the screen to read it. If that band ever appears, the session is trapped: record it and treat it as serious.
The first of those five bullets is confirmed on hardware; the other four are
not. No test in this project can take DRM master or a seat, so whether
Ctrl-Alt-F2 really puts a tty on your panel is knowable only by running the
runbooks on the one machine that has the hardware — and it has been: 5 chorded
switches on 2026-08-09 and 10 of 10 on 2026-08-11, with the band the same colour
on every return. Every one of those chords was pressed against a healthy
compositor, which is a narrower claim than it sounds like: the one deliberate
wedge on record defeated the chord, because a stopped compositor cannot run the
code that switches the VT. So the chord is proven as a feature and unproven as
an escape, and the recovery page is where that distinction is
argued out. Nothing on hardware has yet exercised the chord under a lock, the
twelve consumed keys, the startup banner’s VT number, or the red band. The four
vt_switch_refused already_here events on 2026-08-09 are not a sighting of that
band: that path returns before raise_trapped_notice, deliberately, because
chording the VT you are already on is not a failure. The band that tells you
the session is trapped has never been drawn on a real panel.
The trusted band covers this screen and this process, and nothing else. The
coloured strip means one thing: everything above the line on this display was
drawn by the vitrind you started, not by an app running inside it. It makes no
claim about any other virtual terminal. While you are away, vitrind cannot see
that screen, cannot draw on it, and cannot tell you afterwards what was on it.
What is checkable when you come back is the colour. It is minted once per
vitrind process and never rotated — not on a VT switch, not on resume, not
for any reason — so the same colour means the same core, and a different
colour means the core you left is not the core you came back to. Treat
everything on screen as untrusted until you know why it restarted.
Photograph the band before you switch and compare side by side on return,
rather than trusting your memory of an arbitrary colour: this page already says
nobody has evidence a human reliably notices a wrong band, and a memory test is
not a check.
Your screen now goes dark on its own — and a dark screen is not a locked
session. With --blank-idle SECS on bare metal, vitrind turns the panel off
after that long with no physical input from you. The session behind it stays
unlocked. Any physical input brings it back, and what comes back is your
session exactly as you left it — not a passphrase prompt.
Say the consequence rather than the feature: anyone who walks up to your dark
laptop and touches a key is inside your session. That is worse than what most
desktops do, where the screen blanking and the screen locking are the same
timer. Here they are deliberately not coupled — locking is Ctrl-Alt-Delete, or
--lock-idle SECS, and it is a separate thing you have to ask for. If you want
a dark screen to mean a locked screen, set --lock-idle to a value you are
comfortable with; nothing in the blank will do it for you, and the two timers do
not know about each other beyond sharing the answer to “when did a human last
touch this?”.
Two smaller things that come with it. Idle inhibition is served now, with
three bounds worth knowing before you rely on it (issue
#306, D-042). An app that
says “don’t blank, I’m playing a film” over zwp_idle_inhibit_manager_v1 is
relayed to the core, and the core holds the blank off — but only while your
output is on that realm, so a video in a realm you are not looking at stops
holding anything the moment you look away; it holds off the blank and never
the lock, so a film longer than your --lock-idle still gets a lock screen
over it, exactly as it would with no inhibit at all; and nobody has yet
watched a video on real hardware and confirmed the panel stayed lit. The
core-side guard, the shim relay and the cleanup on realm death are all tested,
but a blank needs a display controller and CI has none — so the claim you can
rely on today is “the ask reaches the core”, not “your film will not be
interrupted”. And --blank-idle is refused on --nested: a vitrind
running inside your real compositor’s window would be painting a black rectangle
and calling it a dark screen, which asserts something about a display it does
not own.
The volume keys still reach an app that cannot act on them. The brightness
keys now work, on one backend, behind a flag, on the internal panel only. The
keymap fallback learned the XF86 media and brightness rows, so none of these
keys is dropped at intake — but for the media half, what changed is where they
stop, not what they do: a delivered XF86AudioRaiseVolume lands on the focused
realm’s shim seat, and a confined application cannot open a mixer, so the
human presses volume and nothing happens. State it that way rather than
reporting that the media keys were fixed. Volume actuation stays deferred,
with named reopening evidence: a shell client holding a verb for it, which
WS-E Stage 2 sketched and did not build, or an explicit owner decision. There is
no one-file sysfs equivalent for a mixer and every route to one runs through a
sound server — a bus or socket client inside the TCB, which is exactly the
dependency this core refuses for logind. No issue tracks the volume half.
The brightness half closed, and it closed narrowly. On --drm only, and
only when the session was started with --backlight, the core consumes
XF86MonBrightnessUp/Down and writes /sys/class/backlight itself, one step
of 5% of that device’s max_brightness per press — rounded up, and never
smaller than one raw unit, so a ceiling of 10 moves by 1 rather than by nothing
(D-041, issue
#303). Five things about
that are limits rather than features, and all five are permanent until somebody
files work against them:
- It does nothing for an external display. The write reaches the internal
panel this machine exposes under
/sys/class/backlightand nothing else, so the behaviour now varies by which screen you are looking at — which is a worse thing to learn than the uniform nothing it replaces. - It is off unless you ask, and it is off on nested and headless entirely, where the flag is a startup error rather than a silent no-op.
- The two keys stop reaching your applications. That is a reversal of what the previous release shipped: a nested compositor, a VM viewer or a remote-desktop client inside a realm loses both keys, with no pass-through and no way to ask for one. The core takes them because an app that both cannot act on the key and can time the human’s presses is worse than an app that never sees it.
- Whether it works at all is a property of your machine, not of this
checkout. The write is reachable through a
video-group membership or a logind/udev tag this project does not own. Every failure — no device, an unreadable value, a file this uid cannot open — is the key doing nothing, said once at startup and journalled on every press, and never a startup refusal. - The core now has a second way to change what your panel shows, and the blank state machine knows about one of them. Blanked-but-bright and unblanked-at-an-illegible-brightness are both reachable, and nothing makes the two paths agree. The mitigation is one-sided and stated as such: this core will never write a brightness below 5% of the device’s maximum — the percentage is rounded up, so that is a floor and not an approximation of one — because a black panel is indistinguishable from a blanked one — but that bounds the accidental case and not a buggy one.
No agent can touch any of it. There is no verb, no wire message and no
request: the write happens on a physically-originated key press or not at all,
so an agent has nothing to ask for and nothing to be refused. It also is not
the blanking mechanism — --blank-idle powers the panel down through the
display controller, this only dims it, and the two paths do not know about each
other.
A dark screen is not evidence that nothing is watching, either — and this is
the same decision as the lock screen, not a second accident. An agent holding
observe keeps capturing the realm while your panel is off, exactly as it
does across a lock. Read the lock-screen entry above and read this as the same
sentence: a lock, and now a blank, takes away your input and your view;
neither touches an agent’s authority, because the grant is what confers it and
your gaze never did. The instrument for “stop everything” is unchanged and still
works in the dark: hold the dead-man chord. It fires through a blank for a
structural reason rather than a lucky one — the switch watches an input tap no
gate can suppress, so the very press that wakes your screen is also the first
press of the hold.
But it is worse than that, and the honest version is uncomfortable: a blank
stops every realm’s frame clock. With the display powered off there are no
vertical blanks, so nothing tells the compositor a frame landed, so no
application is given permission to draw the next one. Every app in the session
stops painting for as long as the screen is dark. An agent holding observe
therefore does not “keep seeing” — it is served the frame from just before the
blank, over and over, indefinitely, with no signal that the picture is stale and
no refusal to tell it something is wrong. This is the same effect this page
already describes for a VT switch, where the project’s own notes call it “worse
than a stall”; the difference is that a VT switch is something you do
deliberately and a blank happens on a timer. So on a --blank-idle session,
an agent can still act and cannot see the consequences, on a schedule, without
anybody choosing it. The fix — giving realms a software frame cadence while
the display is off — is named and is not scheduled. If you are running agents
unattended, this is the entry to read twice, and the shortest honest advice is
that a blank timeout and unattended agent work do not currently mix.
And vitrind still cannot see a panel that went dark for any other reason.
It knows about the darkness it caused itself, and that is all: your monitor’s own
power button, and the backlight controls your laptop exposes outside the display
server, remain beyond it. And since D-041 the core is one of the things that
can dim your panel without the blank knowing — --backlight writes
/sys/class/backlight from a path that has no idea whether a cover is up, so
blanked-but-bright and unblanked-at-a-brightness-you-cannot-read are both
reachable. The core will never write below 5% of the device’s maximum (rounded
up, so the published number is the floor rather than a truncation of it), which
bounds the accidental case and not a buggy one. So a consent card can still in principle be raised —
and recorded as shown to you — while you are looking at a screen something else
turned off. What vitrind does hold back is a prompt while its own blank is up,
and a prompt while the seat is taken away from it, which is what happens when
you switch to another VT and is the common case by a wide margin. An earlier
release of this page said vitrind “never turns your screen off and has no way
to tell that something else did”. The first half stopped being true with this
release and the second half never covered the case it is now narrowed to; the
sentence is corrected here rather than quietly replaced, because a limits page
that acquires the right words without saying how it had the wrong ones is a page
you cannot check.
The blanking behaviour above was confirmed on hardware across three dated
sessions — 2026-08-11, 2026-08-12 and 2026-08-13 — and the confirmation is
still narrower than the section. No test in this project can
take DRM master, a seat, an ACPI event or a backlight, so all of it is knowable
only by a human running
the recovery runbook’s checklist on the one
machine that has the hardware, and one human did. What that first run settled:
the panel does go dark on the timeout (61.2 s against --blank-idle 60), it does
come back on ordinary physical input, and the wake leaves the session as you
left it with no lock card — idle blank and idle lock are uncoupled in fact
and not only in design. What it did not settle, and what the third run did:
suspend ran 4 of the 5
cycles the rung asks for and lid ran 2 of 5, of which only one suspended at all,
so 2026-08-11 left a single usable lid sample and no basis at all for a claim
about a short lid close; 2026-08-13 took both rungs to 5 of 5, added the
typed-after-resume liveness the first run had no keymap to prove, and observed
the short-lid-close case directly — a close reopened inside one second correctly
never reached sleep. The first run also found the defects that are the honest
headline here — returning to a paused session blanks the panel in ~1.5 s (#257),
the unblank is silent so success and failure look identical (#258), and blank and
unblank leave no flight-recorder event (#259). All three are since fixed, and
all three fixes have since been observed on this same panel — #257 by the L7
run on 2026-08-11 and again, measured rather than eyeballed, by the 2026-08-13
L7 run that timed the panel at 61.214 s lit from the seat’s return against a
60 s timeout; #258 and #259 by a second L4 execution on 2026-08-12 that
read the log and the recorder instead of only the screen. Treat this section as
confirmed to that depth and no further: the frame-clock halt, the agent’s
indefinitely stale frame and the prompt-suppression rules were not observed
on a panel and remain claims about code.
That check runs in one direction only, and you should know which. A different colour on return is a sound alarm: the core you left is not the core you came back to. A matching colour is not proof that it is. Anyone who photographed your band — the very exposure the next paragraph describes — can reproduce it exactly, so photograph-and-compare catches a restarted or substituted core and does not catch a patient one. It is worth doing because the first case is the common one, not because it closes the second.
The cost of never rotating is real and is the price of the property. A colour
observed once — a camera pointed at the panel, which is newly plausible when the
panel is physically in the room — is observed for the whole session, and there
is no rotation path to reach for. Rotation was refused because it would destroy
what it appears to protect: a human who cannot tell a legitimate change from a
forgery has no check left. What still holds is that a forged card gets no
input grab, so a replica cannot mint a grant; the harm is deception, not
authority. And the fix for a compromised colour is ending the session — which
means leaving vitrind’s VT and killing the process. The dead-man chord is
not that fix: it revokes every grant and denies every petition, which is the
right instrument for “stop everything” and does nothing for the trust colour,
because the process and its colour keep running. Note too that the chord is a
physical gesture and physical input is suspended for the whole time you are on
another VT, so from there your only stop is a shell and a signal.
By default a VT switch does not raise the lock screen, and a consent prompt
raised while you are away is not recorded as shown. Switching away is not
treated as walking away: it costs no passphrase, because making the escape hatch
expensive to come back from would erode the reason it is open. The idle timer
is also stopped while you are away, and the countdown restarts when you come
back — so with --lock-idle a switch away does not lock the session either,
however long it lasts. The cost of that is plain: a session you switched away
from eight hours ago is unlocked when you switch back to it.
That is the default and it is unchanged, but it is no longer the only behaviour
available: --lock-on-seat-change immediate|idle|never picks one, on --drm
only, and never is what you get if you say nothing.
| Policy | What leaving does |
|---|---|
immediate | The lock goes up as you leave, so coming back always costs a passphrase (or an Enter, with no --lock-passphrase-file). |
idle | The idle countdown keeps running across the absence, so a long switch-away comes back to a locked screen and a short one does not. Needs --lock-idle; with no countdown there is nothing to keep running. |
never | The default, described above. The countdown freezes for the absence and restarts when you return. |
Two things no policy changes. A lock already up is untouched — a VT switch is
never a way past a lock screen, under any of the three. And none of them
suspends an agent: a locked screen does not stop observation or actuation
(above), so immediate buys you a passphrase prompt and not a pause. What
vitrind will not do is put a consent prompt on a screen it does not own: a
petition that arrives while you are on another VT stays pending, is never
journalled as shown, and times out on the ordinary sweep, which reaches the
agent as a refusal. You will experience that as the system being obstructive.
It is the fail-closed answer, and the flight recorder carries the reason
(petition_resolved{timed_out} with no consent_transition{shown} before it).
Without --lock-passphrase-file the lock is a privacy screen, and it says
so. Enter dismisses it, with no authentication of any kind. The passphrase
path exists (Argon2id, one digest per session, one journal entry per attempt
including the failures) and it is refused at startup with --headless, for a
reason worth stating plainly: a headless core holds no keymap and has no
keyboard. Letters and digits reach a nested core only because the host
compositor interprets the layout; with no host and no device there is nothing
to type with at all, so a passphrase would be unenterable and a session that
came up that way would be locked out rather than locked.
That sentence used to say “the core holds no keymap”, full stop, and it was
about to stop being true. WS-E.3.1
(D-028) puts an xkb keymap
inside the core for the bare-metal backend — behind an off-by-default build
feature, so a nested or headless vitrind links no libxkbcommon at all and
this paragraph still describes it exactly. Two things follow that are worth
knowing before that backend exists. The keymap is a pre-compiled file an
operator points the core at, never a layout name: libxkbcommon’s name
resolution searches ~/.config/xkb before the system path, and a realm’s app
runs as the core’s own uid, so a name-resolved keymap would be an app-writable
file the trusted core parses. And the core will link 383 KB of C it does not
link today, which is a real increase in the trusted computing base, stated here
rather than in a changelog.
Turkish, and every other layout whose letters are not Latin-1, is where this
gets sharp. The lock passphrase reads a keysym as a codepoint, and
libxkbcommon reports ğ ş ı İ as legacy keysyms whose number is not their
codepoint — while ö ç ü are Latin-1 and are. The core normalises both into
one convention so all of them type, but the failure it is avoiding is worth
naming: some of your letters working and some of them silently vanishing looks
like a typo, not a bug, and it would be discovered at a lock screen.
A fourth chord is now taken from every app, and it constrains the other
three. Ctrl-Alt-Delete is consumed in every realm. It also means
--dead-man-chord delete is refused at startup on an otherwise default command
line: the dead-man switch detects in the router’s unconditional observe tap, so
a lock chord sharing its key would arm your off-switch every single time you
locked your screen. --lock-chord moves it, which — as with the clipboard — is
a different loss rather than a remedy.
A vitrin screenshot shows the realm, not what you saw — and it cannot show a
consent prompt. As of WS-E.2.4 there is a screenshot key: with
--screenshot-dir PATH, Ctrl-PrintScreen writes one PNG of the focused realm’s
view into that directory. No grant is involved at any point — a human
photographing their own screen is not an agent capability — and the core mints
the filename itself, so nothing a client controls reaches a path component.
What the file contains is the limit, and it is a deliberate one: the realm’s view only. No trusted band, no consent card, no trusted ring, no lock screen, no status strip, no agent cursor. So the single most useful thing a screenshot does — “send me a picture of that weird dialog” — is the thing this one cannot do. The correct answer today is a phone camera, and that is worse than every other desktop offers.
The reason is the trusted band, whose colour is this session’s secret. The confined realm runs as the core’s own uid, so any file the core writes is a file any app can read: a screenshot carrying the band would hand a forger the one thing that distinguishes a genuine consent prompt from a painted replica, permanently, on the first press of the key. Two softer designs were examined and both are worse. Cropping the band’s rows out does not close it — a genuine consent card is framed in the same colour, in the middle of the output, so a crop protects the secret only while no prompt is up, which is exactly when you want the screenshot. Replacing every pixel equal to the secret hands the app an oracle: an app paints a field of candidate colours, you take a screenshot, and it reads back which of its own pixels were recoloured — a ~22-bit secret falls in a handful of screenshots at 1080p.
Four more things belong with it:
-
The screenshots are readable by every app in every realm at
--isolation=off. They are files written as your uid, and the file mode is600, which keeps them from other users and does nothing whatsoever about an unconfined app running as you. This page creates no new hole — that is D9 — but this feature creates the files.At
--isolation=defaultthe screenshot directory is not in the realm’s mount table at all, so no app in any realm can name it. The narrowing is real, and so is its shape: it is a path denial, not a permission one. The mode is still600and the app still runs as your uid, so anything that ever puts that directory back inside a realm — abindsentry inrealm.toml, a future designation — hands it over in full. Do not read the confinement as having changed what the files are. -
A fifth chord is taken from every app. Ctrl-PrintScreen is consumed in every realm. It is a chord rather than a bare PrintScreen deliberately, and that is the one cost this feature pays back: bare PrintScreen is still delivered, so an app that binds it keeps it.
--screenshot-chordmoves the gesture, and — as with the clipboard and the lock — that is a different loss rather than a remedy. It may not share a key with any of the other four gestures; startup refuses it if it does. -
A wrong
--screenshot-dirputs pictures of your screen somewhere you did not intend, and nothing below the core enforces the choice. The directory is audited at startup (absolute, existing, a directory, not a symlink, not group- or world-writable) and then held open for the process’s life, so no later rename or planted symlink can redirect a write. Until E2.6/E2.7 confine the core, that audit is the whole of the enforcement. -
The screenshot key does not work through a lock or a consent prompt — each of those consumes all physical input while it is up, so the chord never reaches the screenshot hook. The lock one is deliberate rather than incidental: a person standing at your locked machine must not be able to write the session behind it to a file.
-
Pressing it costs the compositor about 4 ms, and the encode no longer happens there at all. It used to be about 70 ms: 71.7 ms in a release build to encode one 2560x1600 frame into a 12.3 MB PNG, synchronously on the event-loop thread — roughly seventeen dropped frames at 240 Hz, on every press. Since issue #240 the encode runs on a worker thread that owns the screenshot directory, and what the press pays on the compositor thread is one copy of the frame out of the capture cache: 4.2 ms for the same 2560x1600 frame, measured in the same release build (73.9 ms for the encode itself, unchanged, on the same run). That is one frame at 240 Hz rather than seventeen. The remaining cost is the copy, and it scales with pixel count the same way; the queue is bounded at two presses behind the one being encoded, and a press past that is refused and journalled (
encoder_busy) rather than queued, because each job is a whole frame of memory.Both numbers are CPU measurements on the development machine, in a release build — not a measurement of a session driving a real panel. What a screenshot does to a bare-metal session’s frame timing is knowable only from a run of
docs/drm-bringup.md, which needs hardware CI does not have. -
It DOES work during a dead-man hold, and that is deliberate. An earlier version of this page said otherwise and was simply wrong:
DeadManHook::gateconsumes only its own chord’s key and delivers every other, so Ctrl+Print reaches the screenshot hook mid-hold and a file is written. The behaviour is the intended one — the off-switch destroys authority, and a human photographing their own screen is not authority — but it means the dead-man chord is not a way to stop a screenshot you have already started, and a screenshot taken during a hold captures the session as it was before the revocation landed. Since the encode moved to a worker thread this is literal: a hold does not cancel an encode already accepted, and the end of the session waits for it — but only for five seconds. Past that the core stops waiting, the process exits over the top of the worker, and that last file may be truncated. The wait is bounded on purpose and in both halves (the outcome and the thread), because a screenshot directory on a mount that has stopped answering must not be able to makeSIGTERMdo nothing.
Identities are static tokens. Listed in principals.toml. The IDL is
shaped for SPIFFE/OIDC credentials; the machinery is not here yet.
No semantic layer. Agents work on pixels. The AccessKit/AT-SPI2 bridge, versioned and diffable semantic trees, and epoch/CAS action semantics are all Phase 2 — which is to say the token-hungry screenshot loop this project criticises is still what an agent does against it today. The difference so far is authorization, not efficiency.
No portals, because a realm is advertised no session bus — and that absence is
a missing service, not a confinement. There is no xdg-desktop-portal here:
nothing in the core or the shim starts one, talks to one, or advertises one. The
core injects no DBUS_SESSION_BUS_ADDRESS and points XDG_RUNTIME_DIR at the
realm’s own private directory, so a well-behaved application looking for a
session bus finds nothing. What that costs a desktop user is concrete and larger
than it sounds: no portal file chooser (you get whatever dialog the toolkit
draws itself, which cannot reach a file the application could not already open),
no screen sharing, no notifications, and no “open this link in a
browser” — a click that would hand a URL to another application does nothing.
Read the next sentence as the whole point of this entry. This is not a
security property, and it must never be cited as one — in either mode. Where
a realm is confined, the confinement is the kernel’s, not the missing portal: no
version of “we serve no bus” is a boundary, and the paragraph below is about
what is reachable, not about what is advertised. At --isolation=off there is
no sandbox at all: /run/user/<uid>/bus is still on the filesystem and still
connectable by any process of this uid, and the abstract-socket namespace is
shared, so a determined application connects to the host session bus with no
help from anybody. In practice an operator running Firefox allow-lists
DBUS_SESSION_BUS_ADDRESS in realm.toml, which turns the implicit hole into an
audited one — and hands that realm the host’s bus, with whatever services the
host happens to be running on it, entirely outside anything this project
mediates. What a toolkit then does with a host portal from inside a realm is
unmeasured; nobody has run it. What has changed since this entry was written
is the default, and it changed the reachability half only: P2.6.2’s mount
namespace removes /run/user/<uid>/bus as a path — the realm’s /run holds one
entry, vitrin — and its network namespace removes the abstract-socket namespace
the bus also listens on, because abstract sockets are scoped to a network
namespace, so at --isolation=default the same allow-list line names something
that is not there. That is the half Phase-2 confinement
(#160, E2.6/E2.7) named,
delivered by the kernel; it makes the bus unreachable and it does not make the
unserved portal a confinement. Read that closure as derived from the mount
table rather than measured, because that is a different claim from “the kernel
did it”: no test asserts the absence of /run/user, and
tests/integration/test_real_confinement.py lists “that a realm cannot reach
the session bus by other means” among the things it explicitly does not
prove, saying in as many words that a full escape survey is not what it does.
One residual is narrower than the closure and survives it: binds names any
absolute path outside / and /home, so an operator who binds the host’s
runtime directory into a realm puts the bus socket back inside it at
--isolation=default, under a key that says nothing about buses. Serving
portals properly — a core-mediated file chooser under a grant — is the Phase-2 powerbox’s job and is a
different thing again from restoring the toolkit’s. Serving portals has no
issue and appears in no plan document, so read this as an absence nobody has
scheduled rather than as work in a queue.
No X11 shim. Wayland only. Per-app X11 with an embedded WM is Phase 3.
There is no X server anywhere in this stack — not in the core, not among the
globals a shim advertises, not as a process anything here ever starts — and a
realm’s app is handed no DISPLAY at all, because DISPLAY and XAUTHORITY
are refused outright by the environment the core builds for it. So xterm in a
realm dies before it draws anything, and the failure this project recorded is
Can't open display. That is the fragment the run wrote down; the full line
xterm emitted was not captured, and this page does not reconstruct it.
For anyone thinking of this as a desktop, the consequence is not a footnote. On
the one machine that has been measured, xterm, feh, xsel and
nvidia-settings are X11-only, as are the X11 window manager, compositor, bar,
launcher and screen locker installed on it. None of them can run here. The
maintainer’s interim is a second session, on another virtual terminal, for
X11-only software — so “I did not have to go back to my old compositor” is
false for that set of programs. That is a workaround he accepts, not something
this project offers or confines: the second session is another compositor with
full access to the same devices, nothing here knows it exists, and switching to
it leaves the confined world entirely. It is also one person’s arrangement on
one machine, and it is not advice.
What has been run, with the observable each run actually checked, is the session app matrix. Read it before assuming anything else works; it is deliberately shorter than the list of things people expect a desktop to run.
The protocol will break. v0 is frozen for Phase 1, not forever.
No accessibility of any kind
This project builds an accessibility-derived semantic tree for agents and provides none at all for humans. Somebody was going to write that sentence about this project eventually; it is better here, in our own words, than as an external finding.
Concretely, and this is the whole list rather than a sample:
-
No screen reader. Nothing here speaks, and nothing here can be spoken to.
-
No magnifier. No zoom, no lens, no focus-follows-magnifier.
-
No on-screen keyboard. There is no
input-method/text-inputsupport at all — the same absence that stops you composing text in any non-Latin script — so there is nothing for one to type through even if one existed. -
No sticky keys, no slow keys, no bounce keys, and no repeat tuning — on the daily-driver backend, no key repeat at all (see the entry below, which publishes that as its own limit rather than as an accessibility footnote). The input router forwards what the device reports.
-
No high-contrast signal and no reduced-motion signal. A confined app has no way to ask what the human needs and no way to be told.
-
No AT-SPI2 bus is advertised to a realm — and read that word exactly, in the register the portals entry above uses, because the stronger word is the one this project must not use about itself. There is no accessibility bridge, bus or client in the core, the shim, the wire protocol or the SDK; the core injects no
DBUS_SESSION_BUS_ADDRESSand pointsXDG_RUNTIME_DIRat the realm’s private directory, so a well-behaved toolkit looking fororg.a11y.Busfinds nothing, and the shim’s own acceptance runs disable the bridge a toolkit would otherwise start —GTK_A11Y=noneandNO_AT_BRIDGE=1, for the stated reason “neither exists here”.That is advertisement, not reachability, and it is a missing service rather than a confinement.
crates/vitrin-core/src/spawn.rssays “That is advertisement, not reachability” about the session bus in exactly those words; the missing-service framing is this page’s. Andorg.a11y.Busis activated on that bus: at--isolation=off/run/user/<uid>/busis still on the filesystem, still connectable by any process of this uid, and neitherDBUS_SESSION_BUS_ADDRESSnorAT_SPI_BUS_ADDRESSis inRESERVED_ENV, so either can be allow-listed inrealm.toml. In practice an operator running Firefox allow-listsDBUS_SESSION_BUS_ADDRESS— which hands that realm the host’s accessibility bridge along with everything else on that bus. At--isolation=defaultthe mount and network namespaces P2.6.2 landed close the reachability half — the realm’s/runholds one entry,vitrin, and abstract sockets are scoped to a network namespace — so the same allow-list line names a bus that is not there; that is the half #160 (E2.6/E2.7) named, delivered by the kernel, and it makes the bus unreachable rather than making the unserved bridge a confinement — with the same residual the portals entry names, since an operator who binds the host’s runtime directory in withbindsputs the socket back at--isolation=defaulttoo. That closure is derived from the mount table rather than measured: no test asserts the absence of/run/user, and the test that would prove it — P2.1.10’s adversarial probe, which attemptsorg.a11y.Busactivation on every reachable bus from inside a realm — does not exist yet. It is scheduled for what it settles in both modes: the route is open today at--isolation=off, and at--isolation=defaultnothing has yet measured the closure from inside a realm. Grep the core, the shim, the wire protocol and the SDK forAT-SPIand there are no hits, andcargo xtask limits-checkholds that absence; the only mentions anywhere in this repository are prose about the backdoor this project exists to close, and the gate that holds this sentence.
The semantic tree does not make Orca work, and reading it as accessibility is the misreading this section exists to prevent. The AccessKit/AT-SPI2 bridge (#175, Phase 2) is derived from accessibility technology and serves a different consumer over a different transport under a different authority: it hands an agent a versioned tree over the Vitrin wire protocol, only where a human has approved a grant that names the realm. An assistive technology on this machine is a program running as the human, expecting a D-Bus bus name it can talk to without asking anybody’s permission, for a person who is not going to answer a consent card in order to read their own screen. Nothing in the agent path becomes the human path by being pointed at a different reader. If Phase 2 ships in full, Orca still does not work here.
This is an exclusion, not a deferral, and the distinction is deliberate. “Deferred” implies a schedule and there is none. PRD §5.3 places human accessibility inside the support treadmill that the horizon phase carries — “hardware matrix, HDR, color management, fractional scaling, human accessibility, IME for every user” — and that phase opens only on the M4 gate, whose thresholds (an independent implementer’s statement of intent, two regular non-author contributors, grant funding signed, a published benchmark) are unmet, every one of them. There is no issue tracking this, and that is on purpose: an issue would imply somebody intends to close it, and nobody has said so.
The reasons, stated so this does not read as indifference: there is no assistive-technology stack in this project and building one is not weeks of work; there is no session bus inside a realm for an existing stack to attach to (see the portals entry above), and the sandbox that would make that absence meaningful is itself unbuilt; and there is one maintainer. None of those is an argument that the exclusion is acceptable. A daily driver with no screen reader excludes people, and the honest thing to publish is the exclusion, not a promise. If that reads badly, it is supposed to.
Project gaps
One maintainer. Governance is a documented BDFL. Bus factor is tracked as a first-class project risk rather than waved away; the standing mitigations are spec-first artifacts, a design-doc-per-subsystem rule, and a review norm against cleverness in the TCB.
No OIN membership yet (#159). The project files no patents and relies on defensive publication plus the Apache-2.0 §3 and MPL-2.0 §2.1(b) grants, which are in force today. Joining the Open Invention Network is decided and not yet done. None of this is a freedom-to-operate opinion.
SPDX header coverage is not machine-checked
(#155). There is no
reuse lint-style CI gate, so a new file without a header will not be caught
automatically.
What holds this page to the others, and what it does not
This page, the README,
SECURITY.md
and the project site state the same gaps in four different registers, and
cargo xtask limits-check fails the build when they stop agreeing. How much of
that is machine-held is worth writing down, because “there is a check” and
“this page is checked” are different sentences, and only the second one is
what a reader is really asking.
What it holds. Every claim in its table has to appear on each surface that carries it and still be true of the code — a page that overstates a gap fails as loudly as one that hides it, and both directions have caught real drift here. Every value with a single canonical definition — the Landlock ABI floor, the advisory wlcs counts, the wlcs release they were measured against, the kernel the AppArmor run was taken on, the size of the booted-kernel set — has to appear in every place each surface renders it, not merely somewhere on the page, so a surface cannot contradict itself the way this project’s own site once did. Constants duplicated between two files under a comment promising they mirror have to still mirror. The plan documents that enumerate this project’s limits have to enumerate the same set as this page. And the tables themselves are held to a written roll of ids, so losing coverage is a red build rather than a smaller number in a log nobody reads.
What it does not hold, listed rather than left to be inferred from a green build:
- Claims about the world. Dates, hardware, “the runbook has been executed twice”, “the suite has only ever run on two machines”. No program can check those. They are published with their date and their one machine named, and a human repeating the run is the only check there is.
- Whether the wlcs numbers are still true of the shim. The gate holds every surface to the same four counts and the same wlcs release; nothing re-runs wlcs, because the advisory job commits no artefact to compare against. That is #157.
- A paragraph that states a held value in a register the table does not know. The check finds every occurrence of the registers it is given; it cannot find one nobody told it about. That gap closes by adding a row, and the same is true of any claim on this page with no row at all — most of this page is argued prose, and only the named subset is machine-held.
- A published page nobody added to the table. Coverage is per page and per
claim: a page the table does not name is unheld entirely, however many of
these claims it repeats.
docs/ARCHITECTURE.mdand the Phase-2 plan document both restate the five-kernel figure today and neither is held; a page added tomorrow inherits the same gap on the day it ships. - Text a reader never sees. The check reads the file’s bytes, not the page a browser draws, so a block commented out or fenced still satisfies every anchor in it. The gate would report agreement across surfaces that had stopped publishing the claim at all — which is the understating direction, the one this page cares about most.
- That this page and the issue tracker describe the same set. They do not,
by policy and on purpose: many gaps here are permanent decisions with no
issue, and the README promises exactly that. What runs on every pull request
is the narrower, offline direction — every issue a held claim names must be
cited on one of that claim’s own surfaces, so a reader who meets a gap can
find what tracks it without leaving the page. The other direction needs the
GitHub API and is a scheduled advisory report
(
.github/workflows/honesty-tracker.yml), never a gate, because a build that goes red when somebody else opens an issue is a build people learn to delete.
The tables, their rolls and the full argument for each of these bounds are in
crates/xtask/src/limits.rs, and the gate prints what it compared on every run.
Why this page exists
From the project’s own security notes: a half-believed confinement claim is worse than an honest gap. Every item above is a recorded decision with a scheduled closure, not an oversight — see the decision log.
If you find something true that belongs on this page and is not here, that is a bug worth reporting, and it will be treated as one.