Install
$ agentstack add mcp-alex-schose-qubes-mcp ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
qubes_mcp
Autonomous AI workflows inside a Qubes-isolated sandbox. AI agents get real capabilities — provisioning qubes, building templates, running pentests, moving files between them — while the operator's actual system stays structurally invisible to the agent. Qubes provides kernel-level isolation; this project provides the capability surface AI agents need, mediated by dom0 wrappers so the trust boundary is enforced, not trusted.
> Threat-model-driven implementation: human-designed boundaries, AI-assisted code. > Review from Qubes engineers welcome and needed.
FastMCP server that exposes a tag-scoped Qubes Admin API sandbox to AI assistants. An untrusted-AI principal runs inside a dedicated qube (mcp-control) and can manage a subset of qubes carrying the ai-managed tag — without dom0 access, without visibility into untagged qubes, and without the ability to mutate tags.
Stages A through F3 and the Stage I Wave-1 sub-stages (I-0..I-5) are tested and working on Qubes R4.3-era systems — see the Status table below. Stages G–H are designed but deferred until Stage I completes.
Architecture
Every privileged action the AI takes is mediated by dom0. The MCP principal reaches dom0 only through qrexec; dom0 enforces the invariants and acts on the sandbox on its behalf. The AI never touches qubesd directly and never sees outside its tag scope.
── dom0 (TRUSTED) ────────────────────────────────────────────────
qrexec policy: policy/30-mcp-control.policy
qmcp.* wrappers: force-tag on create, cross-ref checks, opaque errors
operator sets the `ai-managed` tag here, by hand (qvm-tags)
▲
│ qrexec only — no dom0 shell; target=@adminvm routes to dom0
│
── mcp-control (UNTRUSTED — the AI / MCP principal) ───────────────
cannot reach a dom0 shell · cannot see untagged qubes · cannot set/remove tags
│
│ dom0 acts on its behalf, only on tagged qubes ↓
▼
── qubes tagged `ai-managed` (THE SANDBOX) ────────────────────────
ai-vm-1 ai-vm-2 ai-dvm …
network egress funnels through one qube — ai-net-router — whose
upstream only the operator sets in dom0 (Stage C)
untagged qubes = the operator's real system = invisible to the AI
Design highlights
- Tag-scoped trust boundary. AI sees and modifies only qubes carrying the
ai-managed tag. The qrexec policy hard-denies admin.vm.tag.{Set,Remove} for the MCP source qube; tagging happens only in two places: the operator's hand in dom0 (qvm-tags add|del ai-managed) and the create-time wrapper qmcp.SpawnAIManagedQube, which force-tags every qube it creates.
- Dom0-mediated wrappers (
qmcp.*). State-changing calls route through
small Python scripts in /etc/qubes-rpc/ that enforce invariants in dom0 before touching qubesd: forced tagging on creation, cross-reference validation on template/netvm/default_dispvm, opaque error responses.
- Wrapped reads hide existence.
qmcp.GetPropertyAIManagedreturns the
literal string "not found" indistinguishably whether the target qube doesn't exist or simply isn't tagged. The MCP-side helper normalises all qrexec failure modes (policy deny, no-such-VM, transport error) to the same opaque "not found or refused" so the lifecycle path doesn't leak either.
- Multi-stage rollout, reversible at each step. See
CLAUDE.mdfor the
full 8-stage design. Each stage has its own install-*.sh, uninstall-*.sh, and test-*.py in deploy/.
Reviewer asks
This is human-designed, AI-assisted code, and review from people who know the Qubes Admin API and qrexec policy (R4.2+) is genuinely wanted. The detailed, numbered questions — existence-oracle robustness at the qrexec layer, @tag: matching on klass=DispVM, single-egress vs. cascade as a Qubes idiom, event-stream payload minimisation, cap-as-contract disk budgeting, security-tag inheritance on clone_vm / CreateDisposable (a created qube must be stripped to its umbrella, not assumed clean), and more — are written up in [OPENQUESTIONS.md](OPENQUESTIONS.md).
Where this has been discussed:
- qubes-devel design review — five Admin API / qrexec questions, answered
point by point by the Qubes project lead:
- Qubes forum thread — original write-up and discussion:
- Background — the threat-model case for moving MCP trust boundaries below
the protocol:
Status
Stages A through F3 land the binary trust boundary: a qube tagged ai-managed is visible and acted on through the qmcp.* wrappers; an untagged qube is invisible. The F band closes that surface with disk-budget visibility (F3).
Stage I (graduated authority) is the current work line. It adds graduated authority within ai-managed — resource tiers, an action gate (per-call consent for destructive ops), per-trust-class source qubes, a sign-only secrets vault, and persona presets — so a hallucinating or prompt-injected agent cannot destroy real data inside the boundary just because it has a qrexec channel. Stage I lands as sub-stages I-0..I-11 in three waves; I-0 (cap-as- gate), I-1 (read-surface scope redaction), I-2 (dom0 audit log), I-3 (the tier taxonomy + resolution helper, landed behaviour-neutral), I-4 (tiers on the policy-scoped surfaces), and I-5 (tiers on the wrapper
- exec surfaces, with the least-privilege flip available) are done — all
listed below. Wave 1 (I-0..I-5) is complete, behaviour-neutral until the operator tiers the fleet and runs the flip; Wave 2 (I-6..I-8, the action gate) is next. Stages G and H are deferred until Stage I completes — both depend on a non-binary trust model (G's mcp-control lockdown is per-tier; H's remote reach needs Stage I's dom0 gate-lift) and are re-scoped once Stage I lands.
| Stage | Capability | State | |---|---|---| | A | Tag-scoped lifecycle + spawn + wrapped property read/write + existence hiding | tested | | B | Root command execution + inter-qube file transfer inside ai-managed qubes | tested | | C | Single-egress network sandbox (ai-net-router chokepoint, operator-chosen upstream, tag-scoped firewall control) | tested | | D | Clone (qmcp.CloneAIManagedQube) + DispVMTemplate/DispVM klass support in qmcp.SpawnAIManagedQube + dom0 lifecycle wrapper (qmcp.LifecycleAIManaged) covering klass=DispVM uniformly | tested | | E1 | Device attach/detach (qmcp.AttachDeviceAIManaged / qmcp.DetachDeviceAIManaged) between ai-managed qubes, plus tag-scoped block/usb/mic enumeration | tested | | E2 | Ephemeral DispVMs via qmcp.SpawnDisposableAIManaged (auto-cleanup on shutdown) + qubes_run_disposable one-shot | tested | | F1 | Wrapped feature.Set (qmcp.SetFeatureAIManaged) — internal denied (operator-only), opaque cross-ref for audiovm/guivm, echoes post-set value; direct feature.Set stays denied | tested | | F2 | Filtered event stream (qmcp.AIManagedEvents) — bounded-window batch (duration clamped [1, 120]s) of admin events whose subject is ai-managed; minimal {event, subject, subject_klass, ts} payload with whitelisted tag kwarg for tag-add/delete; ships with the opaque-cross-ref backport on SetPropertyAIManaged + SpawnAIManagedQube (closes reviewer ask #8) | tested | | F3 | AI-scoped disk-budget visibility (qmcp.GetPoolStats) — sum of the persistent footprint of every ai-managed qube (each private, plus root for persistent-root klasses; COW root + ephemeral volatile excluded) + operator cap from /etc/qmcp/pool-cap (re-read per call); returns {used, cap, headroom}; pool topology and operator-side volumes intentionally absent. Cap is a contract operator → AI, not a sensor. (Accounting corrected 2026-06-12 — was every volume's provisioned size, which over-stated real usage ~8×.) | tested | | I-0 | F3 cap promoted from advisory signal to a hard gate on every create path (qmcp.SpawnAIManagedQube / qmcp.CloneAIManagedQube / qmcp.SpawnDisposableAIManaged). Refuses with opaque "pool cap exceeded" before the Admin API call; measurement is byte-identical to F3 (shared qmcp_budget.py) so AI's (used, cap, headroom) predicts the gate. A per-qube ceiling /etc/qmcp/private-cap bounds any one qube's persistent private (a spawn may request a bigger private_size up to it). Because a volume can't exceed its size, Σ persistent ≤ cap is a hard ceiling on real usage. Cross-ref refusal still wins; caps fail closed. No new RPC, no policy change. First sub-stage of Stage I. | tested | | I-1 | Read-surface name-leak fix (finding F-3): every VM-valued property read (netvm/template/default_dispvm/guivm/audiovm/management_dispvm) and the list template field is routed through a shared dom0 redactor (qmcp_scope.py) — a referenced qube's name survives only if it is itself ai-managed, else collapses to the opaque ` sentinel; tags reads are filtered to the qmcp vocabulary. The read-path sibling of the F2 write-path cross-ref opacity. Patches the two read wrappers; no policy change, no new RPC. | tested | | I-2 | Hash-chained, AI-unreachable dom0 audit log of every state-changing qmcp.* call. A shared dom0 helper (qmcpaudit.py) appends one JSON line per call to /var/log/qmcp-audit.log (root:qubes 0660, OAPPEND + flock); each line carries the sha256 of the previous, so any edit/delete/reorder breaks the chain (verify() + a python3 qmcpaudit.py verify CLI re-check it). The 8 state-changing wrappers route their single emit() funnel through audit() and log a whitelisted summary (qube names / property + feature keys / action) — never a property/feature value. Best-effort (never blocks an op); AI-unreachable by construction (no service reads the log; no policy line exposes it). Foundational before the tier model. No new RPC, no policy change. | tested | | I-3 | Tier taxonomy + dom0 tier-resolution helper — the keystone of the resource axis. Graduates the binary boundary into a cumulative ladder within ai-managed: ai-managed` (read floor) /tmp/install-a.sh less /tmp/install-a.sh # review before executing bash /tmp/install-a.sh mcp-control ~user/qubesmcp
The two positional arguments are the source qube and the path to the repo
inside it. Defaults: `mcp-control` and `/home/user/qubes_mcp`. Pass them
explicitly if you cloned to a different location.
The script clones `debian-13` → `ai-debian-13` (if needed), tags it
`ai-managed`, and installs the policy + qmcp scripts.
### Step 3 — Verify Stage A (from mcp-control)
cd ~/qubes_mcp .venv/bin/python deploy/test-stage-a.py
(All test scripts work from any cwd — they self-locate the package.)
Expect five PASS markers: existence-leak hidden; SetProperty cross-ref
opaque byte-identical; Spawn template cross-ref opaque byte-identical;
policy refusal on untagged; remove confirmation. The opaque-cross-ref
assertions land in the Stage A wrappers that `install-stage-a.sh`
ships today (they were backported in the Stage F2 bundle — see
reviewer ask #8), so a fresh install passes 5/5. If you're upgrading
an older deployment, expect the SetProperty and Spawn cross-ref
markers to FAIL until you ship Step 10 (which replaces the older
wrappers with the opaque-collapse versions).
### Step 4 — (Optional) Deploy Stage B for command exec + file transfer
From dom0:
qvm-run --pass-io mcp-control 'cat ~/qubesmcp/deploy/install-stage-b.sh' > /tmp/install-b.sh bash /tmp/install-b.sh mcp-control ~user/qubesmcp
Stage B briefly starts the `ai-debian-13` template, installs the two
template-side services into `/etc/qubes-rpc/`, and shuts the template back
down to commit changes.
Then from mcp-control:
.venv/bin/python deploy/test-stage-b.py
Four more PASS markers.
### Step 5 — (Optional) Deploy Stage C for the single-egress network sandbox
Stage C creates one ai-managed network qube — `ai-net-router` — that all
AI qubes route through by default. The operator chooses ai-net-router's
upstream in dom0 (`sys-firewall` for clearnet, `sys-whonix` for Tor, a
VPN qube, or `""` for offline); AI cannot change this. AI can still read
and set firewall rules on `ai-net-router` and on its own qubes.
From dom0:
qvm-run --pass-io mcp-control 'cat ~/qubesmcp/deploy/install-stage-c.sh' > /tmp/install-c.sh EGRESSUPSTREAM=sys-firewall bash /tmp/install-c.sh mcp-control ~user/qubes_mcp
Configurable via env vars (with defaults):
- `EGRESS_UPSTREAM=sys-firewall` — ai-net-router's netvm (any existing qube, or `""`).
- `EGRESS_TEMPLATE=fedora-43-xfce` — the AppVM template for ai-net-router.
- `EGRESS_LABEL=red` — Qubes colour.
- `EGRESS_MEMORY=500` — RAM in MiB.
Switch the upstream any time:
qvm-prefs ai-net-router netvm
Then from mcp-control:
.venv/bin/python deploy/test-stage-c.py
Eight PASS markers — egress visibility, default-netvm application,
explicit-null preservation, egress-qube lock, AI-qube netvm retarget,
firewall rules round-trip, untagged-target refusal, and former-ai-sys
invisibility.
### Step 6 — (Optional) Deploy Stage D for cloning + DispVM klass support
Stage D adds three things: `qmcp.CloneAIManagedQube` (clone an ai-managed
qube into a new ai-managed qube), the `DispVMTemplate` and `DispVM`
klasses in `qmcp.SpawnAIManagedQube`, and `qmcp.LifecycleAIManaged` (a
dom0 wrapper that replaces the Stage A `admin.vm.*` tag-scoped lifecycle
allow lines because qrexec's `@tag:` selector doesn't reach klass=DispVM
targets — see reviewer ask #6). No new dom0 provisioning — only the
policy + RPC scripts change.
From dom0:
qvm-run --pass-io mcp-control 'cat ~/qubesmcp/deploy/install-stage-d.sh' > /tmp/install-d.sh bash /tmp/install-d.sh mcp-control ~user/qubesmcp
Then from mcp-control:
.venv/bin/python deploy/test-stage-d.py
Six PASS markers — clone of ai-managed succeeds, clone of untagged
refuses opaquely, DispVMTemplate spawn sets `template_for_dispvms`,
DispVM spawn inherits template + ai-managed tag, DispVM from a plain
TemplateVM is refused by the `template_for_dispvms` cross-ref, and
end-to-end usability (start ai-dvm + run `whoami` as root inside via
`qmcp.RunInAIManaged` + clean shutdown — proves the
ai-debian-13 → DVMT → DispVM service-inheritance chain).
### Step 7 — (Optional) Deploy Stage E1 for device attach between ai-managed qubes
Stage E1 adds two dom0 wrappers (`qmcp.AttachDeviceAIManaged`,
`qmcp.DetachDeviceAIManaged`) that attach virtual block/USB/mic devices
between ai-managed qubes. Both backend and frontend must be ai-managed;
the wrapper collapses missing/untagged on either side to opaque
`"not found"`. Read-only enumeration (`admin.vm.device.{class}.{List,
Available}`) is tag-scoped at the policy layer — same shape as Stage C
firewall reads. No new qube provisioning.
In practice, **block** is the useful case (e.g. shared scratch volume
between two ai-managed AppVMs). **USB** requires `sys-usb` to be
ai-managed and **mic** requires the audio backend to be ai-managed —
both operator opt-ins. Default install leaves these dormant; the
wrappers are ready when the operator chooses to tag those backends.
From dom0:
qvm-run --pass-io mcp-control 'cat ~/qubesmcp/deploy/install-stage-e1.sh' > /tmp/install-e1.sh bash /tmp/install-e1.sh mcp-control ~user/qubesmcp
Then from mcp-control:
.venv/bin/python deploy/test-stage-e1.py
Six PASS markers (hard): tag-scoped list on ai-managed backend
succeeds; list on untagged refuses opaquely; attach refuses when
either endpoint is untagged; same for detach. Plus a SOFT block of
informational checks for a real loop-device round-trip (template-
dependent — qubes-core-agent's block enumerator may or may not
auto-expose `/dev/loop*` on a given Debian build, so those are
reported but not counted toward the pass total).
### Step 8 — (Optional) Deploy Stage E2 for ephemeral DispVMs
Stage E2 adds `qmcp.SpawnDisposableAIManaged` — a dom0 wrapper around
`admin.vm.CreateDispo
…
## Source & license
This open-source MCP server is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [alex-schose](https://github.com/alex-schose)
- **Source:** [alex-schose/qubes-mcp](https://github.com/alex-schose/qubes-mcp)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.