qubes-mcp design review — five Admin API / qrexec questions

53 views
Skip to first unread message

Alex Schose

unread,
May 31, 2026, 6:08:10 AMMay 31
to qubes-devel
Hi all,

I've built a FastMCP server that exposes a tag-scoped Qubes Admin API
to AI agents via dom0-mediated wrappers — the qrexec policy + the
qmcp.* RPC scripts are the load-bearing pieces. Wanted to put the
design questions in front of the developer community before claiming
the design is idiom-correct.

Repo: https://github.com/alex-schose/qubes-mcp  (MIT, stages A through
F2 tested on Qubes R4.3-era; eleven dom0 qmcp.* RPC services)

Writeup with the broader threat-model case:
https://alexschose.com/writing/mcp-trust-boundaries-belong-below-the-protocol.html

Five questions, all reproduced from the README's reviewer-asks list:

1. Wrapped-reads existence-hiding. qmcp.GetPropertyAIManaged returns
the literal string "not found" indistinguishably whether the target
qube doesn't exist or simply isn't tagged ai-managed. Is uniform "not
found" from a dom0 qmcp wrapper robust against qrexec-layer leaks
(timing, error chains, side effects), or are there paths I'm missing?

2. qubes.Filecopy @tag:ai-managed -> @tag:ai-managed allow. Stage B
adds a policy line bypassing the default ask dialog for inter-qube
file transfer between ai-managed qubes. Are there assumptions in
qubes.Filecopy's implementation that depend on the operator dialog
being present?

3. target=@adminvm clause on tag-scoped admin allows. Without it,
qrexec attempts to start the target VM during read-only operations.
This is subtle, easy to miss, and not surfaced in current Qubes docs.
Worth a docs PR? Happy to write it.

4. Single-egress chokepoint vs cascade as Qubes idiom. The original
Stage C design was a cascade (ai-sys-firewall <- ai-sys-tor /
ai-sys-vpn) with multiple ai-managed network qubes. The implemented
design is one egress qube with operator-chosen upstream
(sys-firewall / sys-whonix / a VPN qube / null) and the
provides_network invariant on it. Documented Qubes patterns lean on
cascades; is the single-egress chokepoint an established pattern I
missed, or a reinvention?

5. @tag: matching on klass=DispVM targets on R4.3. Stage D testing
surfaced this: qrexec policy refuses
"admin.vm.Remove * mcp-control @tag:ai-managed allow target=@adminvm"
with "Request refused" when the target is a klass=DispVM, despite the
DispVM carrying the ai-managed tag (verified via Admin API from dom0).
Same rule works for klass=AppVM and klass=TemplateVM. Workaround
deployed: lifecycle (Start/Shutdown/Kill/Pause/Unpause/Remove) routes
through a dom0 wrapper (qmcp.LifecycleAIManaged) doing the
ai-managed check in dom0 with qubesadmin authority. Is the underlying
@tag:-on-DispVM behaviour intentional, a bug, or a configuration step
I'm missing?

Even short corrections would help. Thanks.

Regards,
Alex Schose
alexs...@atomicmail.io
PGP: F4F9 735B E899 60F4 70E6  ADBE 5312 A67E E8CE 816B

Marek Marczykowski-Górecki

unread,
Jun 1, 2026, 10:12:15 AMJun 1
to Alex Schose, qubes-devel
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256

On Sun, May 31, 2026 at 02:56:37AM -0700, Alex Schose wrote:
> Hi all,

Hi,

> I've built a FastMCP server that exposes a tag-scoped Qubes Admin API
> to AI agents via dom0-mediated wrappers — the qrexec policy + the
> qmcp.* RPC scripts are the load-bearing pieces. Wanted to put the
> design questions in front of the developer community before claiming
> the design is idiom-correct.

Interesting idea, but honestly, with the current state of LLM, I would
be _very_ careful with giving them access to modify system state without
checking what it actually does. Mistakes like deleting important data
due to some hallucination happen quite often...
And this doesn't even tackle the prompt injection risk, which is yet
another story.

You described this risk in your article, and IIUC the idea is to simply
don't give access to sensitive qubes. That's fair. But surely people
will want to do use it to do something with their actual qubes, not just
empty mockups. Separation into "read-only" access and some additionally
protected write access would be useful.
Maybe have user confirm any (potentially) destructive action? Or maybe
make the LLM write an ansible playbook that user can review before
executing? This is hard problem, because such verification usually
requires substantial knowledge and time (if user would know how to do
that themselves, maybe they wouldn't ask LLM for it?) ...

> Repo: https://github.com/alex-schose/qubes-mcp (MIT, stages A through
> F2 tested on Qubes R4.3-era; eleven dom0 qmcp.* RPC services)
>
> Writeup with the broader threat-model case:
> https://alexschose.com/writing/mcp-trust-boundaries-belong-below-the-protocol.html

Regardless of the above remark, it is possible to scope some Admin API
access based on tags, without needing to introduce extra wrappers in
dom0. For example, to allow listing only qubes with ai-managed tag, you
can add rule like:

# allow calling admin.vm.List at all
admin.vm.List * mcp-control @adminvm allow target=dom0
# limit its scope to just ai-managed qubes
admin.vm.List * mcp-control @tag:ai-managed allow target=dom0

And for calls that are about specific qube, the first line wouldn't be
needed, for example:

admin.vm.Start * mcp-control @tag:ai-managed allow target=dom0

Oh, and in your article I see you had issue with disposables, likely
because they didn't have necessary tag added. I guess you will be
interested in `tag-created-with` feature - for example to automatically
add "ai-managed" tag to any qube created by mcp-control:

qvm-features mcp-control tag-created-with ai-managed

> Five questions, all reproduced from the README's reviewer-asks list:
>
> 1. Wrapped-reads existence-hiding. qmcp.GetPropertyAIManaged returns
> the literal string "not found" indistinguishably whether the target
> qube doesn't exist or simply isn't tagged ai-managed. Is uniform "not
> found" from a dom0 qmcp wrapper robust against qrexec-layer leaks
> (timing, error chains, side effects), or are there paths I'm missing?

I'm pretty sure you can't easily eliminate timing side channel.
Existence check is faster, than existence check + tag check. But I'm not
sure how much is that measurable in practice. Other than that, it should
be okay.

> 2. qubes.Filecopy @tag:ai-managed -> @tag:ai-managed allow. Stage B
> adds a policy line bypassing the default ask dialog for inter-qube
> file transfer between ai-managed qubes. Are there assumptions in
> qubes.Filecopy's implementation that depend on the operator dialog
> being present?

There is nothing requiring the GUI confirmation at the protocol level.
Note to use such allow rule, you need to use qvm-copy-to-vm, not just
qvm-copy - otherwise you cannot specify the target, and you'll get
interactive prompt (based on the default qrexec policy, unless you block
it).

But, is your intention really to allow any copy operation between any
AI-managed qubes? Didn't you want to allow copy only to/from
mcp-control?

> 3. target=@adminvm clause on tag-scoped admin allows. Without it,
> qrexec attempts to start the target VM during read-only operations.
> This is subtle, easy to miss, and not surfaced in current Qubes docs.
> Worth a docs PR? Happy to write it.

Sounds like a good idea, yes. Currently it's only mentioned in a comment
in the default policy file, very easy to miss.

> 4. Single-egress chokepoint vs cascade as Qubes idiom. The original
> Stage C design was a cascade (ai-sys-firewall <- ai-sys-tor /
> ai-sys-vpn) with multiple ai-managed network qubes. The implemented
> design is one egress qube with operator-chosen upstream
> (sys-firewall / sys-whonix / a VPN qube / null) and the
> provides_network invariant on it. Documented Qubes patterns lean on
> cascades; is the single-egress chokepoint an established pattern I
> missed, or a reinvention?

I'm not sure if I understand the question. Are you asking if a whole
separate ai-sys-firewall - ai-sys-tor etc chain is a good idea? If all
the network traffic should be configured uniformely (so, all over
clearnet, or all over VPN / Tor), I wouldn't bother with duplicating the
whole chain, and just create one egress qube attached to the chosen
point.

BTW, currently there is no way to limit value of property to be set via
admin.vm.property.Set on the qrexec level. So, with just qrexec policy,
you can't say "allow setting netvm only to value X, or none". You can do
that via a wrapper service, or an admin-permission event handler.

> 5. @tag: matching on klass=DispVM targets on R4.3. Stage D testing
> surfaced this: qrexec policy refuses
> "admin.vm.Remove * mcp-control @tag:ai-managed allow target=@adminvm"
> with "Request refused" when the target is a klass=DispVM, despite the
> DispVM carrying the ai-managed tag (verified via Admin API from dom0).
> Same rule works for klass=AppVM and klass=TemplateVM. Workaround
> deployed: lifecycle (Start/Shutdown/Kill/Pause/Unpause/Remove) routes
> through a dom0 wrapper (qmcp.LifecycleAIManaged) doing the
> ai-managed check in dom0 with qubesadmin authority. Is the underlying
> @tag:-on-DispVM behaviour intentional, a bug, or a configuration step
> I'm missing?

Does it still exist at the point you want to remove it? Disposables
usually have auto_cleanup property set, which means they get removed
automatically once stopped. And you cannot remove a running qube.

But, if you have a stopped disposable, with relevant tag added and it
still can't be removed based on the above rule, it sounds like a bug.
Check journalctl in dom0 for details about such denial.

- --
Best Regards,
Marek Marczykowski-Górecki
Invisible Things Lab
-----BEGIN PGP SIGNATURE-----

iQEzBAEBCAAdFiEEhrpukzGPukRmQqkK24/THMrX1ywFAmodkzgACgkQ24/THMrX
1ywkbgf9HdxKcjrCtDTIpmVwzPK9wEG3O5siPJmw0YD5hSJlcCuPlR9HGjNxo1C+
P6UYb++2Jjc6isWwhUMt7bQ8H9DOPBMxzDUag9LKpMhbIBhKFP97opFeyKsC/qcI
RYW9JEkdeXLXwYNslhfmGUE9qYSpYI7P2gRVGenSIcw5j7Z9CX3Nqk9W9L4u/Brz
rst9og6ODZSk+QDrEQqgLBx4H+MRmUfx4xiJX/dtWSdxhwBDwMa11Z4KdnaVc78c
Nv56ytWbEu+tyMOK/Zh4JqKgwKGtPTFpbwfJIFDG7TKEJEvM7Cb28lYONnootVLw
4Rr2rDpkFuCqPldrdTx+d0FGwyRqRQ==
=F0HH
-----END PGP SIGNATURE-----

Alex Schose

unread,
Jun 8, 2026, 5:35:59 AMJun 8
to qubes-devel
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512

Hi Marek,

Following up on your replies, with results.

Q5 (the DispVM lifecycle refusal). It's a real bug, and not the @tag: matcher
I'd suspected. The disposable was stopped and present (so not auto_cleanup, and
not a running qube), and checking the journal showed the qrexec policy daemon's
target resolution
failing on the new qube's name ("target '...' does not exist, using @default
instead"). Root cause: SystemInfoCache.on_domain_add in qubes/api/internal.py
doesn't invalidate the cache when a VM is created, so a freshly-created VM can
be absent from the cached system_info until some other event invalidates it.
Reproduced deterministically with instrumentation; one-line fix. Filed as
QubesOS/qubes-issues#10911. (DispVMs with DVMT-inherited tags hit it most
reliably because nothing fires post-create, but it's architectural, not
DispVM-specific.)

The create-time auto-tagging feature you suggested — good call, I tried it. It
works for dom0 / qvm-create, but it doesn't fire for our wrapper path: the
wrappers create via qubesd_call over the local socket, so the creator qubesd
records is dom0 rather than mcp-control, and the auto-tag follows the recorded
creator. So for this architecture I'm keeping the wrappers' explicit
tag-on-create.

Q3 (the target=dom0 requirement for tag-scoped admin rules). Wrote it up —
filed as QubesOS/qubes-doc#1716.

On the broader point — being careful with LLM write access, and separating
read-only from protected/confirmed writes — you're right that that's the real
frontier. I'm working on it and will follow up separately when there's
something concrete to look at.

Thanks for taking the time to reply.

Regards,
Alex

F4F9 735B E899 60F4 70E6  ADBE 5312 A67E E8CE 816B
-----BEGIN PGP SIGNATURE-----

iNUEARYKAH0WIQT0+XNb6Jlg9HDmrb5TEqZ+6M6BawUCaiaBb18UgAAAAAAuAChp
c3N1ZXItZnByQG5vdGF0aW9ucy5vcGVucGdwLmZpZnRoaG9yc2VtYW4ubmV0RjRG
OTczNUJFODk5NjBGNDcwRTZBREJFNTMxMkE2N0VFOENFODE2QgAKCRBTEqZ+6M6B
azAhAQCou64HbEWAmGDhu/mVZIwdbMxRUgxNabMMuVcnRDuHeQD9FIKvGKKxfa/m
8QjlzDsshITtdcBj/DVN7z3H40DTOQ8=
=0Tfc
-----END PGP SIGNATURE-----

Alex Schose

unread,
Jun 25, 2026, 4:41:04 PM (2 days ago) Jun 25
to qubes-devel
-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA512

Marek,

Following up on the write-access point I parked last time.

The use case that forces it: I run untrusted third-party MCP servers inside
ai-managed qubes to reproduce target environments and run vulnerability PoCs.
The agent driving that can be prompt-injected by the very server it's testing,
so a hallucinating or injected agent destroying data inside the boundary isn't
hypothetical for me - it's the normal operating condition. It works today, but
the boundary is still binary, which is the thing your reply pushed me to fix.

The binary part is the problem. ai-managed currently means full read + root
exec + lifecycle + remove + device + copy; untagged is invisible. Isolating
the untagged set is necessary but not sufficient - an injected agent still has
lifecycle over everything it can see and can pivot out of the qube it was
supposed to stay in. So the fix has to bind somewhere the agent can't reach,
which means dom0.

What I built and tested since: the single ai-managed tag becomes a cumulative
ladder - read floor < exec < net < full - plus an orthogonal copy-in-only sink
(ai-dump: no read, no exec, you can only push data into it). It's your
tag-scoping idiom, just with more rungs. The qrexec-expressible surfaces stay
pure @tag: policy and only change which tag they match; the @adminvm wrappers
read the tier in dom0 and fail closed - a missing resolver denies, it never
grants. The AI can't mutate tags (admin.vm.tag.Set/Remove are denied) and
can't read the tier tags either: a tags read returns only ai-managed, so it
finds its ceiling through refusals, not introspection. That last part needed a
fix I didn't expect - qubesd emits tag events as domain-tag-add:ai-full, so the
tier rode out in the event name and leaked the fleet's authority map the moment
a qube was tiered. The event stream now parses the suffix off and redacts it.
Create paths also strip any tier tag the platform propagates, or you could
clone an ai-full qube into a fresh one and self-escalate.

One ordering choice I'd flag because it's non-obvious: net sits above exec, not
beside it. In an egress-controlled sandbox, rewriting a qube's firewall (open
an exfil path) is higher-trust than running code confined inside one qube - and
in-qube root can't widen the envelope the NetVM enforces anyway, so exec
genuinely is the smaller grant. If you think that's backwards I'd like to hear
why.

This all ships behaviour-neutral: untiered = full, today's boundary, until the
operator tiers the fleet and flips one dom0 flag to least-privilege. The
running instance is still in that compat default - tested, not yet flipped -
which is the honest state, not a deployed least-privilege fleet.

On constraining property values - you noted qrexec can't do it and named two
mechanisms, a wrapper service or a qubesd admin-permission event handler. I'm
keeping wrappers for that (the netvm-only-to-X case) and setting the event
handler aside, with reasons to shoot at: it's a Python package running inside
qubesd - so it lives in the dom0 TCB, which cuts against the dom0-minimalism
this whole thing sells, and it's a second artifact in a different trust domain
from the rest of qubes-mcp, harder to reverse and audit than an
operator-installed wrapper plus a policy line. For something meant to be
third-party, auditable and reversible I think that favours wrappers - until a
parallel qmcp.* namespace gets big enough to flip the trade. The drift risk
runs the other way too (wrappers shelling to surfaces that move under them); if
you think that's the bigger exposure, say so.

Two things I'm less sure about and would value your eye on.

First, identity vs capability. qrexec dispatches on source identity + tags, so
an injection turns a legitimate agent into a confused deputy without its
identity or tags changing - the policy can't see it. But capabilities don't fix
that either: the injected agent holds the same caps it legitimately needs, so it
rides its own authority. What a capability model (or a dom0 nonce broker) buys
is tighter per-call scoping and a place to force out-of-band approval on the
destructive subset - not deputy-immunity. So I lean on least-authority plus
per-call confirmation on the destructive calls (the ask verb, rendered outside
the calling qube's control); within a tier an injected agent still acts freely,
blast radius bounded by the tier, not eliminated. The question I'm stuck on: for
a qrexec-native system, is per-call dom0 approval on the destructive subset
where the complexity should go, or is there a Qubes idiom I'm missing?

Second, your copy-scope question. You asked whether I really meant any copy
between any two ai-managed qubes, or just to/from mcp-control. It's any-to-any
on purpose: I treat the ai-managed set as one trust domain and gate
exfiltration at the single egress qube (the one you landed on in Q4), not at
the copy layer - so intra-sandbox copy doesn't widen the exfil surface. But it's
the loosest line in the file and does nothing against lateral movement once an
agent is injected, so I'm not attached to it. If you'd narrow it to
mcp-control-only and route the rest through ask, I'd take that.

The reviewer-competence point you raised is the one I keep coming back to. A
reviewable-plan gate (I'm leaning toward the Salt/qubesctl stack as executor,
not Ansible - heavy YAML makes the review harder, not easier) only protects an
operator who can actually review the plan. So I'm treating the plan text as a
plain-language catastrophe-catcher and the tier ceiling as the real wall: a
rubber-stamped approval still can't exceed what the tier permits. The
action-gate and the per-agent principal split are designed but not built yet.

The wrapper-vs-extension call and the identity-vs-capability line are the two
I'd most like you to push on.

Thanks,

Alex
F4F9 735B E899 60F4 70E6  ADBE 5312 A67E E8CE 816B
-----BEGIN PGP SIGNATURE-----

iNUEARYKAH0WIQT0+XNb6Jlg9HDmrb5TEqZ+6M6BawUCaj2NHl8UgAAAAAAuAChp
c3N1ZXItZnByQG5vdGF0aW9ucy5vcGVucGdwLmZpZnRoaG9yc2VtYW4ubmV0RjRG
OTczNUJFODk5NjBGNDcwRTZBREJFNTMxMkE2N0VFOENFODE2QgAKCRBTEqZ+6M6B
a5tQAP0cEvDF/DHbpA37LlMnJ7nxZvep8Hcp01aQFbn5DaClOwEAy8I3zdD6eFmk
4LGzk1+LwtAC3j9i0Rb74N6u3nIwAAM=
=8wer
-----END PGP SIGNATURE-----
Reply all
Reply to author
Forward
0 new messages