Humans and AI agents observe and operate the same GUIs — under granular, revocable, capability-scoped authorization.
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 claim | What makes it true |
|---|---|
| one form | A grant: (principal × resource × verbs × constraints). Not a role, not a config flag — a row the core checks on every single action. |
| five minutes | Expiry is a constraint on the grant, enforced at the chokepoint. There is no path that skips it. |
| can't see the other window | The 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 mouse | Physical input is origin-tagged at the core and preempts agent input by construction — not by a race between two clients. |
| hold Escape | The dead-man switch: every live grant dies, transitively. The agent's next call fails revoked. |
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.
From a clean clone to an agent driving a real app.
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
Run the demo agent against a real application, headless.
cargo xtask demo --headless
# → xtask demo: PASS
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)
Legacy apps never touch the trusted core. Each gets its own private compositor, which is itself an unprivileged client.
The entire TCB: capability kernel, grant store, scene composition, input routing, consent, journals. Window management and theming stay out of it, permanently.
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.
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.
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.
| Milestone | Proven by |
|---|---|
| M1.2 — buffer path | test_real_app.py — real core + real shim + real weston-terminal |
| M1.3 — observation | test_real_capture_fidelity.py — through the enforcement chokepoint |
| M1.4 — actuation, consent, dead-man | test_real_actuation.py, test_real_consent.py, test_real_deadman.py |
| M1.5 — demo | test_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 measured —
five 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 → glycin → bwrap
— 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.
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 gap | What 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.
Clean clone to a passing run, and what each step actually did.
The Python SDK end to end, including every way actuation is refused.
Grants, consent the core draws itself, and the dead-man switch.
Framing, the interfaces, and the fatal-vs-recoverable razor.
The canonical vision doc: object model, threat model, roadmap.
DCO not CLA. What gets a change rejected, stated up front.