Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 workRun the demo in five minutes
Writing an agent against itYour first agent
Evaluating the security modelGrants, consent, and revocation
Wondering how apps are isolatedRealms and shims
Writing a client in another languageThe wire protocol
Building an alternate core or shimBuild 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.

There is no sandbox yet. No namespaces, no seccomp, no Landlock. An app in a realm runs as the core’s own uid with the core’s full view of the filesystem and network. 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

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

SymptomCause
vitrin-shim not foundThe meson step above did not run, or VITRIN_C_SHIM_BIN points somewhere stale.
Hangs before any captureweston-terminal is not installed, so the realm has nothing to draw.
AuthFailed at connectThe demo identity is not in the principals.toml the core booted with.
Nested mode opens nothingNo Wayland session — check echo $WAYLAND_DISPLAY.

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="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="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

Three more verbs exist in the enum — OBSERVE_CURSOR, LAYOUT_ARRANGE, LAYOUT_FOCUS — and version 1 resolves them unsupported. They are defined but unserved deliberately, and the SDK carries them for a precise reason: an out-of-range verb bit is a fatal invalid_argument that kills the connection, so an SDK that omitted them would turn a recoverable “not yet” into a dead socket.

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 GrantDenied, ConsentTimeout, RealmUnavailable, Busy

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 Busy:
    ...      # another petition is already up

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)
ExceptionMeaningRetry?
NotGrantedThis verb is not in your grant.No — fix the petition.
GrantExpiredexpiry_ms elapsed.No — petition again.
RevokedSomeone revoked it. Often the dead-man switch.No. A human meant this.
RateLimitedOver the event-rate ceiling.Yes, after retry_after_ms.
PreemptedA human touched the input device.Yes, but back off — a person is using the machine.
ConsentHeldA consent prompt is up; actuation is frozen.Yes, once it resolves.
NoSurfaceNothing to act on yet.Yes, cheaply.
OperationFailedThe core tried and could not.Maybe.

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:

  • GrantRefused and GrantResolutionError are recoverable. Your request failed; the connection is fine.
  • FatalError means 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 authorityWho can revokeGranularity
X11The connectionNobody, reallyThe whole session
WaylandThe connectionNobodyYour own surfaces only
AT-SPI2Ambient — anyone on the busNobodyEvery widget of every app
VM-per-agentThe VMDestroy the VMOne whole desktop
VitrinThe grantAnyone, immediatelyVerb × 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 today; observe.cursor and the two layout.* verbs are defined but refuse unsupported in version 1.
  • 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.

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:

  1. The core composites the prompt above every client surface. There is no z-order a client can request that goes higher.
  2. The core takes an exclusive input grab while it is up. Clicks land on the prompt, not on whatever is beneath it.
  3. Actuation on already-granted grants refuses ConsentHeld while 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.

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 0700 runtime directory;
  • builds its environment from nothing — only names the operator allow-listed in realm.toml, plus a WAYLAND_DISPLAY pointing at that realm’s own socket. DISPLAY, the host WAYLAND_DISPLAY, WAYLAND_SOCKET, XAUTHORITY and the host XDG_RUNTIME_DIR cannot 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_range sweep between fork and execve;
  • 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.

What does not confine a realm

There is no sandbox. Decision D9. No namespaces, no seccomp, no Landlock. The shim and its app run as the core’s own uid, with the core’s full view of the filesystem and the network.

An application that ignores WAYLAND_DISPLAY and connects directly to a path it already knows is not stopped by anything in this MVP.

Two specifics worth naming rather than leaving to be discovered:

The session D-Bus is reachable. The core advertises no DBUS_SESSION_BUS_ADDRESS and redirects XDG_RUNTIME_DIR, so a well-behaved client finds no bus. But advertisement is not reachability: /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. In practice, running Firefox means allow-listing DBUS_SESSION_BUS_ADDRESS explicitly — which at least turns an implicit hole into an audited one. Closing it properly (P13, Phase 2) means a loopback-only network namespace and an empty mount namespace, so there is nothing to reach rather than nothing advertised.

Same-uid separation is not attempted. The 0700 runtime directory bounds other users on the machine, not other processes of this user. Note what the realm’s XDG_RUNTIME_DIR therefore is: $XDG_RUNTIME_DIR/vitrin-0/<realm> sits 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. Under D9 the app runs as the core’s uid and can derive those paths with or without a variable pointing at them.

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
]

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.

--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/argb8888 with the linear modifier implied; the allow-list is checked before any driver call. Failure produces an explicit buffer_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                        |
FieldNotes
object_idThe target. Per-connection; referencing an unknown id is fatal invalid_object.
sizeThe 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.
opcodeThe request or event opcode within the target’s interface, at the negotiated version.
fd_countHow 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

TypeEncoding
intsigned 32-bit LE
uintunsigned 32-bit LE
objectu32 object id — may be 0 only where the IDL declares allow-null
new_idu32 id the sender allocates for the object being created
stringlength-prefixed, see the conventions page for padding
fdnot in the payload — carried out-of-band, counted by fd_count

Two connection classes, one wire format

ClassReaches the core byInterfaces
Agent principalConnecting to the core’s listening socket and authenticatingvitrin_handshake, vitrin_principal, vitrin_realm, vitrin_grant, vitrin_consent, vitrin_view, vitrin_actuator_*
ShimInheriting a socketpair from the core across fork/execvitrin_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

InterfacePurpose
vitrin_handshakeVersion + identity hello, resolving to a bound principal
vitrin_principalThe authenticated principal — root of the connection’s authority chain
vitrin_realmRealm address; an authority-free scope handle (v0 serves the well-known realm-0)
vitrin_grantCapability handle — the wire projection of one grant-table row
vitrin_consentConsent-prompt visibility for one petition (events only, no authority)
vitrin_viewObservation facet — poll-model frame capture
vitrin_actuator_pointerPointer actuation facet
vitrin_actuator_textText actuation facet
vitrin_shim_sessionShim connection bootstrap
vitrin_shim_surfaceShim-to-core buffer path
vitrin_shim_seatInput delivery to the shim (events only, origin-tagged)

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_error · resource_exhausted

Recoverable — your request failed. The connection is healthy and you may try something else.

not_granted · grant_expired · revoked · rate_limited · preempted · consent_held · no_surface · operation_failed

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 the three defined-but-unserved verbs: an SDK that omitted them would turn a recoverable refusal 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. 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:

  1. 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.
  2. Handshake. Version and identity hello, resolving to a bound principal. Get version_unsupported and auth_failed right; they are the first two errors you will actually hit.
  3. 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.
  4. Observe. Receive a memfd over SCM_RIGHTS, honour stride (never assume width * 4 — v0 pins it, later versions need not), and map the format.
  5. Actuate. Pointer and text.

Three things to get right, because they are what a naïve port breaks:

  • Carry the defined-but-unserved verbs (observe.cursor, layout.arrange, layout.focus). An out-of-range verb bit is fatal invalid_argument and kills the connection. Omitting them turns a recoverable unsupported refusal into a dead socket for any user who petitions one. The Python SDK’s test_verb_parity.py pins 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_count from 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, handle buffer_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 own wl_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:

ToolWhat it checks
crates/vitrin-goldenPer-pixel + SSIM frame comparison
crates/vitrin-mock-shimA 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.

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

There is no sandbox. Decision D9. No namespaces, no seccomp, no Landlock. A realm’s app runs as the core’s own uid with the core’s full view of the filesystem and the network. An app that ignores WAYLAND_DISPLAY and connects to a path it already knows is not stopped by anything here. The session D-Bus remains reachable in practice.

Environment hygiene confines the well-behaved; it does not contain the hostile. Do not run untrusted applications, or untrusted agents, against this. Real sandboxing is Phase 2 (E2.6/E2.7, P13).

Testing gaps

The 24-hour fuzz soak has never been run. 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. The 2026-07-25 run: total=180 passed=3 failed=145 skipped=32.

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.

Model gaps

The trusted indicator is unforgeable, 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.

One realm in v0. The core serves the single well-known realm-0. Multi-realm fleet mode is Phase 3.

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 X11 shim. Wayland only. Per-app X11 with an embedded WM is Phase 3.

The protocol will break. v0 is frozen for Phase 1, not forever.

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. 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. There is no reuse lint-style CI gate, so a new file without a header will not be caught automatically.

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.