# Qubes Mcp

> Autonomous AI agents inside a Qubes-isolated sandbox - tag-scoped Admin API access with dom0-mediated trust boundary.

- **Type:** MCP server
- **Install:** `agentstack add mcp-alex-schose-qubes-mcp`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [alex-schose](https://agentstack.voostack.com/s/alex-schose)
- **Installs:** 0
- **Category:** [AI & ML](https://agentstack.voostack.com/c/ai-and-ml)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [alex-schose](https://github.com/alex-schose)
- **Source:** https://github.com/alex-schose/qubes-mcp

## Install

```sh
agentstack add mcp-alex-schose-qubes-mcp
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## 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.GetPropertyAIManaged` returns 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.md` for 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 **[OPEN_QUESTIONS.md](OPEN_QUESTIONS.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 (`qmcp_audit.py`) appends one JSON line per call to `/var/log/qmcp-audit.log` (`root:qubes` `0660`, `O_APPEND` + `flock`); each line carries the sha256 of the previous, so any edit/delete/reorder breaks the chain (`verify()` + a `python3 qmcp_audit.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/qubes_mcp
```

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 ~/qubes_mcp/deploy/install-stage-b.sh' > /tmp/install-b.sh
bash /tmp/install-b.sh mcp-control ~user/qubes_mcp
```

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 ~/qubes_mcp/deploy/install-stage-c.sh' > /tmp/install-c.sh
EGRESS_UPSTREAM=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 ~/qubes_mcp/deploy/install-stage-d.sh' > /tmp/install-d.sh
bash /tmp/install-d.sh mcp-control ~user/qubes_mcp
```

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 ~/qubes_mcp/deploy/install-stage-e1.sh' > /tmp/install-e1.sh
bash /tmp/install-e1.sh mcp-control ~user/qubes_mcp
```

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.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** no
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/mcp-alex-schose-qubes-mcp
- Seller: https://agentstack.voostack.com/s/alex-schose
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
