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.
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
- 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. |
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.
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 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)
| 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. |
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.texttoday;observe.cursorand the twolayout.*verbs are defined but refuseunsupportedin 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.
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.
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.
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/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_* |
| 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 (v0 serves the well-known realm-0) |
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) |
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:
- 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 the defined-but-unserved verbs (
observe.cursor,layout.arrange,layout.focus). An out-of-range verb bit is fatalinvalid_argumentand kills the connection. Omitting them turns a recoverableunsupportedrefusal into a dead socket for any user who petitions 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.
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.