An agent-first display server

Humans and AI agents observe and operate the same GUIs — under granular, revocable, capability-scoped authorization.

The sentence today's 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.

Every clause is a mechanism, not a policy setting:

The claimWhat makes it true
one formA grant: (principal × resource × verbs × constraints). Not a role, not a config flag — a row the core checks on every single action.
five minutesExpiry is a constraint on the grant, enforced at the chokepoint. There is no path that skips it.
can't see the other windowThe agent's Firefox is in a realm, talking to its own private nested shim. The other window is not hidden from it; it is not in its universe.
you touch the mousePhysical input is origin-tagged at the core and preempts agent input by construction — not by a race between two clients.
hold EscapeThe dead-man switch: every live grant dies, transitively. The agent's next call fails revoked.

See it run

An agent connects, petitions for a capability, a human approves a prompt the core drew itself, and the agent drives a real Firefox inside a realm.

Two takes, unedited, in one recording. First the run completes: the core-drawn consent card goes up over a real Firefox, a human clicks Allow, and the agent fills the record it was handed — name=Ada Lovelace, email=ada@example.org — then clicks submit and reads the confirmation back out of its own pixels. The small blue crosshair is the agent's cursor, composited by the core so you can see where it is acting; your own pointer is drawn by the host, outside the realm.

Then the same demo again, interrupted: a physically held Escape (1328 ms) revokes every grant in the session, the agent's next call fails Revoked, and the run exits non-zero. Nothing is spliced — a revoked run cannot also print PASS, which is why it is a second take rather than a longer one.

Two things this is not. The agent is deterministic — it locates fields by marker colour in its own capture; there is no language model anywhere in it. And the coloured bands at the end are a 36-bit checksum of the record the app received, not the characters read back, so it proves the right values arrived without claiming the agent read them. Run it yourself in about five minutes.

Get started

From a clean clone to an agent driving a real app.

1

Build the core and the per-app shim.

# the Rust side: vitrind, xtask, fixtures
cargo build --workspace

# the C side: a real wlroots compositor, one per app
bash shim/ci/install-deps.sh
meson setup shim/build shim && meson compile -C shim/build
2

Run the demo agent against a real application, headless.

cargo xtask demo --headless
# → xtask demo: PASS
3

Write your own — the SDK is pure Python, standard library only.

import vitrin_os

conn  = vitrin_os.connect(sock, identity="vitrin://local/agent/demo")
grant = conn.request_grant(verbs=("observe", "actuate.pointer"),
                           expiry_ms=300_000)
grant.await_consent()          # a human decides

grant.observe().to_png("before.png")
grant.pointer.click(640, 84)

How it fits together

Legacy apps never touch the trusted core. Each gets its own private compositor, which is itself an unprivileged client.

human agent principal physical input SDK client │ │ │ capability-native wire protocol │ handshake · grants · observe · actuate ▼ ▼ ┌───────────────────────────────────────────────────┐ │ vitrind — the trusted core │ │ capability kernel · grant store · compositor │ │ input router · consent surface · journal │ └───────────────────────────────────────────────────┘ ▲ ▲ │ frames up (fd) │ frames up (fd) │ input down (origin-tagged) │ input down ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ │ realm │ │ realm │ │ per-app shim │ │ per-app shim │ │ ↓ │ │ ↓ │ │ unmodified app │ │ unmodified app │ └──────────────────┘ └──────────────────┘

Small trusted core

The entire TCB: capability kernel, grant store, scene composition, input routing, consent, journals. Window management and theming stay out of it, permanently.

Legacy complexity, exiled

Serving the full Wayland surface is a large, messy job. It happens in an untrusted, disposable, per-app shim — not in the process holding the grant table.

Unspoofable consent

The prompt is drawn by the process that owns the screen, composited above every client, with an exclusive input grab. No client can paint over it.

Status: Phase 1 complete

Every milestone closed on a named test that runs against the shipped binaries with no mock on any seam it claims — the project's decision D12. Mock-based tests are labelled component tests and close nothing.

MilestoneProven by
M1.2 — buffer pathtest_real_app.py — real core + real shim + real weston-terminal
M1.3 — observationtest_real_capture_fidelity.py — through the enforcement chokepoint
M1.4 — actuation, consent, dead-mantest_real_actuation.py, test_real_consent.py, test_real_deadman.py
M1.5 — demotest_demo.py — asserting the typed text actually landed

The sandbox is half-built. A realm now gets six namespaces, an identity uid/gid map, zero capabilities and a private mount table, all verified by the core from outside, and since P2.6.3 a Landlock ruleset with an enumerated read set, plus a generated matrix of the ABI that ruleset requires. That matrix is a table about the build and measures no kernel; the per-kernel half its criteria ask for now exists separately and is measuredfive distribution kernels booted under QEMU with the shipped vitrind, reporting ABI 1, 2, 4, 6 and 7. Since P2.6.4 (#188) there is also a seccomp filter, and it is a deny-list: it closes the 13 denied syscall rows vitrind --print-seccomp prints and leaves the rest of the kernel's syscall surface unenumerated, so a realm is filesystem-confined and filtered against a named list but not syscall-confined — and it keeps the invoking user's supplementary groups, which the kernel gives no window to drop. Environment hygiene confines the well-behaved; it does not contain the hostile.

And that ruleset costs a sandbox your app was building for itself. A Landlock domain denies every mount-topology change to a realm’s app and its descendants, unconditionally — mounting is not an access right, so no rule grants it and widening the ruleset cannot restore it. That was measured, not assumed: a domain granting every rung-9 right on / still cannot mount. So an app that decodes images inside a nested sandbox — GTK → glycinbwrap — cannot have one, and decodes unsandboxed instead. That is a real loss of defence in depth, published rather than worked around: an image decoder is a large attack surface fed untrusted bytes, and inside a realm it is contained by the realm’s own boundary and by nothing else. To make it a degradation rather than a crash, a realm also refuses nested user namespaces outright (/proc/sys/user/max_user_namespaces = 0, written inside the realm’s own namespace), so such a sandbox fails at unshare(CLONE_NEWUSER) — the refusal sandbox libraries already handle — instead of at a mount(2) it never expected to fail. That takes no capability away, since a nested namespace could not have mounted anything either. Until 2026-08-15 this took three of this project’s own real-app gates red, one of them a named M1.4 milestone gate; all three pass at the shipped default as of 2026-08-15, measured on this repository’s development box (kernel 7.1.8-arch1-3) and on no other machine.

And on some hosts it will not start at all. That confinement is built on an unprivileged user namespace, so the host has to let one carry its capabilities. Where it does not, vitrind refuses to start rather than quietly running a weaker session. Measured once, on a GitHub ubuntu-latest runner (kernel 6.17.0-1020-azure, 2026-08-14): kernel.apparmor_restrict_unprivileged_userns is 1 on that stock image, read from the runner’s own sysctl before CI changed anything, and no realm can start there. That is one CI image, not a distribution survey — nobody has run one. vitrind --print-isolation answers for your machine; packaging that makes the grant routine is tracked as issue #293.

There is an AppArmor profile for exactly this, at packaging/apparmor/vitrind, and it is measured working on one kernel on one CI image. It is the per-binary grant Ubuntu ships a mechanism for — the shape Chrome, Firefox and flatpak already use — taken instead of asking operators to weaken a system-wide default. A CI job that runs on a runner whose userns sysctl it never touches (installing the apparmor package does load the distro's own profiles) is what says so: it loads the profile, spawns a real realm, then removes the profile and requires the spawn to fail again. On kernel 6.17.0-1022-azure that took mount.in_userns from restricted-by-policy(errno=13) to available and tier from none to per-uid, with the lever confirmed both ways. Nobody has loaded it on an installed Ubuntu system, and nothing here installs it for you — a build outside the paths the profile names is not covered by it, which is issue #293. Installing it also has a published cost — the profile's name can be borrowed by any local user through aa-exec, though whether that borrow yields a user namespace turns on kernel.apparmor_restrict_unprivileged_unconfined, at which value AppArmor stacks the borrowed profile with unconfined rather than transitioning to it. Measured 0 on a stock runner, so the borrow works and the cost is real. Both are stated in full, with citations, on the limits page.

And since P2.6.3 there is a second host requirement, with a different remedy. The Landlock ruleset is part of the confinement floor, so the kernel must actually have Landlock: a kernel ≥ 5.13, built with CONFIG_SECURITY_LANDLOCK=y, and with landlock in the active LSM list (/sys/kernel/security/lsm — a kernel can carry the code and still leave it out of lsm=). Without all three, vitrind --isolation=default refuses to start rather than confining a realm one mechanism less than its own journal claims. There is now a fourth condition, and it is the one a correctly configured kernel can still fail: the reported Landlock ABI must be at or above this build’s declared floor (build.landlock_min_abi from vitrind --print-floor, 6 in this build). Nothing is misconfigured on a machine that fails it — the remedy is a newer kernel, and the refusal says so instead of handing you the three checks above. Which kernels that admits is measured, on five of them: Debian 13 (ABI 6) and the 6.17.0-1020-azure kernel this project’s CI runs (ABI 7) start; Ubuntu 22.04 (ABI 1), Debian 12 (ABI 2) and Ubuntu 24.04’s GA kernel (ABI 4) are refused. Those are kernel rows, taken with no distribution policy loaded, and they are not distribution rows — the kernel page has the rows and says why the difference matters. Each row records the build it was taken with beside the kernel’s answers, and cargo xtask kernel-matrix --check holds that half to the tree: it goes red the day the floor moves out from under them, so a row cannot go on describing an older binary in silence. That check re-boots nothing — re-taking the kernel half is tests/kernel-matrix/collect.sh --check, and that needs QEMU. Do not cross the two remedies: the refusal names the mechanism it could not get — namespaces for the paragraph above, landlock for this one — and no userns sysctl makes a kernel report a Landlock ABI. Which distributions ship the third condition unset has not been surveyed here either.

Do not run untrusted applications, or untrusted agents, against this. Every other known gap is listed here — the 24-hour fuzz soak that has never been run (issue #156), advisory wlcs conformance at total=180 passed=3 failed=145 skipped=32 on the 2026-07-25 run, against wlcs 1.6.1-1 (issue #157), and the rest. Those counts are quoted here in the same four-number form the canonical statement uses, with its date and the wlcs version beside them, because a bare ratio from this harness means nothing without them — the same shim scores 8/49 against wlcs 1.7.0 with no shim change in between.

It can drive a real panel. It is not a desktop.

Since Phase 1 closed, vitrind has grown a bare-metal DRM/KMS backend, a lock screen, an idle blank, a status strip, a screenshot key and a cross-realm clipboard. It looks like a desktop, which is exactly when unstated gaps mislead — so the gaps are here, on the page that makes the claim.

Everything below was measured on one machine, on the dates given. One laptop: an Intel iGPU driving the only connected output, eDP-1, at 2560×1600 @ 240 Hz, scale 1, on one mesa version and — on the two runs that recorded a kernel — two consecutive Arch ones. Not even the device node is stable: the core's own selection chose /dev/dri/card1 on 2026-08-09 and /dev/dri/card2 on 2026-08-13, two answers from the same machine. The bring-up runbook was executed twice on 2026-08-09 (7.1.5-arch1-2), and its touchpad rung once more on 2026-08-13; the session-lifecycle checklist was executed on 2026-08-11 (7.1.6-arch1-1), again on 2026-08-12 for the blank rung alone, and again on 2026-08-13, which is the run where its suspend and lid rungs first reached the counts they ask for. No full execution of either has been a clean pass: the 2026-08-11 session produced five defects — four from the checklist rungs and one from driving real applications — and the 2026-08-13 sessions produced two more, a gesture interrupted by a VT switch that ends completed where it must say cancelled (issue #275) and a published recovery command that is inert against the wedge it is published against (issue #277). Nothing here generalises to any other GPU, panel or kernel.

The gapWhat it means for a person using this
No accessibility of any kind No screen reader, no magnifier, no on-screen keyboard, no sticky or slow keys, and no AT-SPI2 bus advertised to a realm — a missing service, never a confinement. At --isolation=off the host session bus, where org.a11y.Bus is activated, stays connectable and nothing here blocks it. At the shipped --isolation=default the core's own spawn docs record it closed twice over — the realm's /run holds one entry, vitrin, and the abstract socket the bus also listens on is scoped to the realm's own network namespace — so an operator who allow-lists DBUS_SESSION_BUS_ADDRESS there gets a variable naming something that is not there, unless that same operator names a host path in binds. That is the confinement doing it, not this absence, and it is derived from the mount table rather than measured: the adversarial probe that would attempt org.a11y.Bus activation on every bus reachable from inside a realm has still not been written. The semantic tree Phase 2 builds for agents is derived from accessibility technology and is not a substitute for it — different consumer, different transport, and only under a grant a human approved. It does not make Orca work. Published as an exclusion, not a deferral: there is no schedule.
CI cannot test the DRM backend A runner has no DRM device, no seat and no GPU. The only job that touches this code is named drm-compile-check (COMPILE ONLY - no display controller is touched) and proves the code type-checks — no mode set, no frame committed, no key delivered. Every hardware claim on this site is a dated run by one person, never a green tick.
No X11 Wayland only: there is no X server anywhere in this stack, and a realm's app is handed no DISPLAY, so xterm in a realm fails with Can't open display. Per-app X11 is Phase 3. In the meantime the maintainer runs a second, unconfined session on another virtual terminal for X11-only software — a workaround he accepts, not something this project offers or confines.
No bars or launchers zwlr_layer_shell_v1 is not in the shim's global contract, and there is no core-owned surface a client can add to — the trusted band, the consent card, the lock screen and the status strip are drawn by the core and nothing else may draw beside them. A principal cannot draw at all; the shipped switcher is a line-oriented program in a host terminal.
No portals No xdg-desktop-portal, and a realm is advertised no session bus: no portal file chooser, no screen sharing, no notifications. It buys no security — the absence is advertisement, not reachability, and what closes it is the confinement rather than this row. At --isolation=off every word of the older claim still holds: /run/user/<uid>/bus is on the filesystem, connectable by any process of this uid, and the abstract-socket namespace is shared. At the shipped default the realm is spawned into a mount namespace whose /run holds one entry, vitrin, and into a network namespace, which scopes the abstract-socket namespace away with it, so an operator who allow-lists DBUS_SESSION_BUS_ADDRESS hands the realm a variable naming something that is not there, unless that same operator names a host path in binds (D-037 supersedes D9 for that path only). Read that as a consequence of how the mount table is built rather than as a measurement: no test asserts the absence of /run/user, and the real-app confinement gate (tests/integration/test_real_confinement.py) names the session bus itself among the things it does not assert — "That a realm cannot reach the session bus by other means", where the gate's own words are that "a full escape survey is not this" — so nothing in the tree rules out a route to the bus this row has not thought of.
One output, one visible realm Up to 16 realms run at once; the core composites exactly one output from one of them, and refuses to start on a second connected display rather than half-serving it. A laptop plus an external monitor does not work. Nothing you can ask for ends a realm — revocation, disconnect and the dead-man switch all leave the process running — and a slot comes back only when the realm's own app exits, so the cap is sixteen simultaneously live realms rather than sixteen launches.
A cross-realm clipboard Stated as a bound, never as an absence: two physical human gestures move text/plain;charset=utf-8, up to 60 KiB at a time, one direction each, through a single core-held slot no client can trigger, force or observe. Two colluding realms can move ~60 KiB per gesture pair.
A lock does not stop an agent An observe holder keeps capturing across a lock and across an idle blank; an actuate holder keeps acting. A lock takes away your input, not an agent's authority — the instrument for "stop everything" is the dead-man chord, which fires through both. And --blank-idle blanks the screen without locking it: a dark laptop is an unlocked session. A blank is worse than a lock for the agent — with the display off, every realm's frame clock stops and the agent is served the pre-blank frame indefinitely, with no staleness signal and no refusal.
A shell crash loses window management The switcher is an unprivileged client by design, so there is no core-side fallback. Kill it and the core, the realms and their apps all survive, and the last-focused realm keeps taking your input — what you lose is the ability to re-aim it, and recovering means starting the shell again from a terminal that must already be in the realm on screen.
Idle inhibition: served, bounded, unproven on hardware zwp_idle_inhibit_manager_v1 is advertised and relayed to the core, so an app can ask the screen not to blank. Three bounds: only the realm your output is on can hold one, it holds off the blank and never the lock, and nobody has watched a video on real hardware and confirmed the panel stayed lit — blanking needs a display controller and CI has none.
The band's witness: one backend The band's unspoofability is machine-checked on the headless backend CI can read, and asserted, not checked, on the bare-metal backend a human looks at. Nobody has evidence a human notices a wrong band either.

Each of these is argued out in full on the limits page, which is longer, harder on this project, and the surface that governs if this table and it ever disagree.

Read on

Run the demo

Clean clone to a passing run, and what each step actually did.

Your first agent

The Python SDK end to end, including every way actuation is refused.

The security model

Grants, consent the core draws itself, and the dead-man switch.

The wire protocol

Framing, the interfaces, and the fatal-vs-recoverable razor.

PRD & architecture

The canonical vision doc: object model, threat model, roadmap.

Contribute

DCO not CLA. What gets a change rejected, stated up front.