Skip to content
Conduit

mcp server

qubes-mcp

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

alex-schose/qubes-mcpcommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "qubes-mcp": {
      "command": "uvx",
      "args": [
        "qubes-mcp"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 17, 2026

qubes-mcp

Autonomous AI workflows inside a Qubes-isolated sandbox. AI agents get real capabilities — creating qubes, building templates, running commands, moving files — while the rest of the machine stays invisible to them. Qubes provides the isolation; this project provides the capability surface, mediated by dom0 so the boundary is enforced, not trusted.

Threat-model-driven implementation: human-designed boundaries, AI-assisted code. Review from Qubes engineers welcome and needed.

An MCP server runs in one qube, the hub (mcp-control), and in each project's lead. Agents connect to it over stdio (usually through SSH) and call its tools. Every tool is a qrexec call, and dom0 decides it. Most go to a small library in dom0 that decides which qube is in scope, what may change and what a new qube is born with; running a command, copying a file out and the firewall go to the qube or the Admin API under dom0's qrexec policy. The hub, and each lead, can do what dom0 allows it, and nothing more.

Status: 0.9.32 — the audit round's window fixes, and limits on what one principal can put on the audit log. Besides the hub, up to 15 projects, each with its own lead agent that creates and runs its own workers inside the project's names, templates, networks and disk quota, and sees nothing outside it; a copy out of the project needs the operator's dialog. A worker or a lead may be a standalone: its own copy of a template's root, so the tools installed in it stay across restarts. Every network an AI qube is given is a gateway the operator enrolled, and a lead's firewall, which dom0 writes to allow its model endpoint (and DNS for a host name), nothing else, is the operator's. A lead can instead use a self-hosted model in a model qube with no network, which, while it is guarded, nothing in AI space reaches but the leads it serves (the hub only through your dialog); the lead then has no network either. A project can be anonymous: its qubes sit only on anonymising routers (Tor, a VPN), its templates update only through the qube above one, and a gate in dom0 checks that every 15 seconds and after every qmcp change, and stops the project when it is not so. One hidden from the hub (the default) is out of the hub's reach, its names are random, and the hub's services answer about them as about names that do not exist. An installation can be in anonymous mode: every project is anonymous, and the gate judges the hub too, with its own qubes and the templates it made. A guarded qube — a template kept as a clean root, a model qube, any reference — is sealed: the rulebook refuses the hub every way into it. The operator can open one for a bounded time with qmcp open QUBE --for 2h, to install software in it or maintain it; the hub may then run commands in it and copy a file in behind your dialog, and with --firewall write its firewall rules, never its network. The window ends by itself, at every boot, and on qmcp seal, which kills the qube so nothing the hub started in it runs on. The operator runs it all from qmcp-gui, a window in dom0 that is the qmcp command with forms: every change it makes is a command it shows first. The hub may ask for a project, a change to one, a new lead, a lead's new firewall, a deletion, a window on a guarded qube, a qube brought into AI space or out of the guarded state, a qube taken out of AI space, or exact packages installed into a template by its own package manager; nothing happens until the operator accepts it in dom0, in that window or with qmcp proposal accept. The two in-qube services need python3 and qvm-copy-to-vm and nothing else; measured on Qubes 4.3.1, a lead and its workers run on Debian 13, Fedora 43 and Arch templates alike, and dom0 keeps its own copy of them, so qmcp template prepare writes them into a template or a standalone at any time. A qube that comes back from a backup, or is copied by hand, is held for your review rather than rejoining a project that may have changed, and qmcp export puts qmcp's settings where a Qubes backup carries them. The audit round of 0.9.28 is being fixed by severity: 0.9.29 fixed the high findings, 0.9.30 and 0.9.31 the medium ones, and this release the window's, with limits on what one principal can put on the audit log (60 lines an hour of calls that changed nothing, about 20,000 a day of changes). Next: the low findings and the docs, then 1.0.0 once all of it is green on a reinstalled machine. 0.9.17 replaced the tier model of 0.9.0–0.9.16; see CHANGELOG.md.

How it works

  dom0 (trusted)
    30-mcp-control.policy     static rulebook, checked by qrexec's own parser at install
    qmcp.* services           one shared, fail-closed check; identity = the qrexec caller
    qmcp (command)            the operator's tool: check, list, gateway, manage, guard,
                              revoke, project, proposal, migrate
        ▲  qrexec only
        │
  mcp-control (the hub)       MCP server, standard library only; reaches what dom0 allows
        │  qrexec, decided in dom0
        ▼
  AI space (tag ai-managed)
    the hub's own qubes       slot p00: the hub operates them
    project p01 … p15         a lead (its own MCP server and agent) and the workers it makes;
                              the hub operates these too, the lead only its own project
    guarded qubes             listed, read, spawned from — never operated (gateways always)
  • AI space is every qube carrying ai-managed. Everything else is invisible: a qube outside AI space answers exactly like one that does not exist, and reads redact references to it, except the gateways the operator enrolled, which the hub reads by name. Three things still tell the hub whether a name exists: a create colliding inside the reserved prefix; a proposal the operator accepted, which succeeds or fails; and a call to @dispvm:<name>, which Qubes refuses at once unless the name is a disposable template (see the residual risks in CLAUDE.md).
  • Managed or guarded. The hub operates managed qubes, including the templates and disposable templates it manages, so it can build them. Guarded qubes — gateways, and any template the operator wants kept clean — are references only.
  • Projects. A lead operates only its project's workers, spawns only from the templates the operator approved for the project, puts workers only on the project's networks, and creates only inside its names (ai-<label>-) and disk quota. Workers call no qmcp service, cannot reach their lead or the hub, and reach another project only by a copy through the operator's dialog, never a hidden one. Copies inside a project, and into its dump sink, need no dialog. What a project's workers can reach is bounded by the gateways you list for it, which must be enrolled; inside that, the hub and the lead narrow each worker's reach with its firewall rules. The lead's own reach is its firewall, which is yours.
  • Proposals. The hub cannot create a project, change or remove a lead, delete a project, change a project's templates, networks or quota, change a lead's firewall, open a guarded qube, bring a qube into AI space or take one out, or install packages into a guarded template. It may propose each of those, as the options of one qmcp command, and the operator accepts or rejects it in dom0, in the window or with qmcp proposal accept or reject: accepting runs that command's own code, refused if the stored proposal is not the one shown. Removing a lead or a project, a network AI space does not use yet, promoting one of the hub's qubes, an over-committed quota, a model endpoint no project uses, giving a project a different model endpoint, every change to a lead's firewall, naming a model qube or taking one away, and every window, manage, revoke and package install need a second tick. The hub learns only whether it was accepted, rejected, expired or failed; dom0 announces each proposal with a desktop notification whose text the hub cannot choose.
  • Anonymous projects. dom0's anonymity gate judges each one every 15 seconds and after every qmcp command that changes qubes, projects or gateways: its networks and its lead's are anonymising gateways still on the network recorded for them, no qube of it has an update proxy around its router, its self-hosted model qube (if any) has no way out, its templates are guarded or outside AI space, a TemplateVM has no network and its updates go only to the qube directly above an anonymising gateway you ticked for updates (or nowhere), and its qubes wear its badges. When one condition breaks, the gate badges the project's lead and every member qmcp-blocked (the rulebook and the dom0 services then refuse every call that would reach or wake one), then kills them, turns their autostart off, logs it and tells you (a desktop notification, or the command's output when a qmcp command broke it); it removes nothing, and only you clear it. An anonymous qube opens no URL or file in another qube, not even through a dialog. A hidden one has a random label, its qubes are out of the hub's reach by the rulebook, and the services answer the hub about them as about names that do not exist. A stopped qube you start by hand reaches no other qube and no dom0 service (it keeps its network). In anonymous mode the gate also judges the hub qube and every qube in AI space that is not an anonymous project's lead, member or model qube, guarded routers aside; each of them wears qmcp-anon. A violation by the hub or a p00 qube stops the hub and p00; one by any other of them stops that qube alone.
  • Creates take only names inside the caller's own space under a reserved prefix (default ai-: the hub's ai-hub-, a lead's ai-<label>-; Qubes names disposables itself), strip the qmcp badges a new qube must not carry, stamp it, and prove its network before handing it back.
  • AI space cannot call dom0 beyond the services a qube needs to boot and keep its clock (a lead also calls nine qmcp services), cannot reach the hub or a lead, cannot use Qubes' raw disposable shortcut, and cannot touch the Admin API, except a lead's firewall methods on its own workers. A copy needs the operator's dialog unless it stays inside one slot or goes into that slot's dump sink, and a copy into a guarded qube, the hub, a lead or a hidden project from outside it is refused, as is every other dialog into a hidden project.
  • Every change made through a dom0 service (creates, removes, lifecycle, properties, features, proposals), and every change the operator makes with the qmcp command or the window, is recorded on a hash-chained log in dom0 that AI cannot read. Logging is best-effort: a failed write never blocks the change. Commands, copies and firewall writes are decided by the policy and are not on it.

The full design, and the risks it accepts, are in CLAUDE.md.

Tools

Tool
qubes_list, qubes_state, qubes_props_getwhat is in scope, and its properties
qubes_spawn, qubes_clone, qubes_removecreate (AppVM, standalone, disposable template, named disposable) and remove
qubes_start, qubes_shutdownlifecycle
qubes_props_set, qubes_feature_setlabel/memory/maxmem/vcpus, netvm only to none; features on an allowlist
qubes_run, qubes_copyrun a command as root in a managed qube; copy a file out of one (operator dialog)
qubes_spawn_disposable, qubes_run_disposabledisposables, or one command in a fresh disposable
qubes_firewall_get, qubes_firewall_setread any AI-space qube's firewall; replace a managed one's (a lead's is the operator's: propose it)
qubes_eventsa window of events for qubes in scope
qubes_get_pool_statsthe caller's disk budget: AI space, every project's record and the enrolled gateways for the hub, the project for a lead, with the names, templates and networks a lead may use and its model (an endpoint, or a model qube and its port)
qubes_propose_project, qubes_propose_project_edit, qubes_propose_dump, qubes_propose_lead, qubes_propose_lead_firewall, qubes_propose_project_deletethe hub asks the operator for a project, a change to one, a dump sink, a new lead or none, a lead's new firewall, a deletion
qubes_propose_open, qubes_propose_manage, qubes_propose_revoke, qubes_propose_packagesthe hub asks for a window on a guarded qube, a qube brought into AI space or out of the guarded state, a qube taken out of AI space, or exact packages installed into a template in AI space
qubes_proposalswhat became of the hub's proposals: pending, accepted, rejected, expired or failed

The hub and a lead run the same server and see the same tools; dom0 scopes each call to its caller. A lead has no event stream and cannot propose.

From ~/qubes-mcp in the hub, python3 -m qubes_mcp.cli <tool> key=value ... runs any tool from a shell.

Setup

Three places: the hub, dom0, and an AI template. The paths below assume the repository is cloned to ~/qubes-mcp in the hub.

1. The hub. Any AppVM with Python 3.10 or later. It must not carry ai-managed.

# in dom0
qvm-create --class AppVM --template "$(qubes-prefs default_template)" --label gray mcp-control
# in mcp-control
git clone https://github.com/alex-schose/qubes-mcp.git ~/qubes-mcp

Nothing to install: the server uses only the standard library and talks to dom0 through qrexec-client-vm, which every qube has.

git is not in every template, and the hub's template is whatever qubes-prefs default_template names — measured on Qubes 4.3.1: the Debian 13 xfce template ships git, the Fedora 43 xfce and Arch templates do not. Install git in the hub's template, or skip the clone and copy the release in from dom0 the way step 5 does for a lead.

2. dom0. The installer runs as root, so fetch it somewhere AI cannot write: a fresh disposable, not the hub. Any disposable template with curl and network will do; Qubes' stock default-dvm does.

qvm-run --dispvm=default-dvm --pass-io \
  'curl -fsSL https://github.com/alex-schose/qubes-mcp/archive/refs/tags/v0.9.32.tar.gz' \
  > /tmp/qmcp.tgz
rm -rf /tmp/qubes-mcp && mkdir /tmp/qubes-mcp
tar -xzf /tmp/qmcp.tgz -C /tmp/qubes-mcp --strip-components=1
sudo bash /tmp/qubes-mcp/deploy/install.sh

The installer runs every preflight check before it changes anything: its options, the fleet's shape, and the policy, which it validates with qrexec's own parser against your policy directory, including that no file sorting earlier overrides its 52 checked claims. It installs the anonymity gate's timer as your dom0 user, keeps it stopped while it changes things, installs the policy after the code, starts the timer again and ends with qmcp check. Options: --hub NAME, --birth-egress QUBE, --pool-cap BYTES, --private-cap BYTES, --gate-user NAME, --anonymous (step 8), --updates-via GATEWAY, --dry-run. deploy/uninstall.sh removes the policy first, then the rest, and ends with a clean-state check that names what it keeps.

It also installs the operator's window: qubes-mcp in the Qubes menu under Settings > Qubes Tools, or qmcp-gui in a dom0 terminal, run as your own user. It shows AI space and the projects as a tree, the gateways AI space may use, each lead's model endpoint and firewall, the qmcp check light, the audit log and the settings, each anonymous project with what the anonymity gate found, and offers every command that changes something as a form, but migrate, restored accept --all (its Accept all names the qubes it shows) and what the timers and the boot unit run. Each form shows the command it will run (sudo -n qmcp ...); the window runs nothing but the qmcp command. Text that AI chose, such as a name in the audit log, is shown escaped: \u202e, never a reversed line.

Coming from 0.9.31? Install over it. A principal's calls that changed nothing, past 60 in a clock hour, are counted rather than logged, and its changes past 20,000 lines a day are refused; qmcp check warns on both; deleting a project keeps a qube that is also another project's member, or is a template, a disposable template, a gateway, a model qube, a guarded qube, a dump sink or the hub, and its plan names every qube it removes, keeps or strips; the window keeps Seal, Revoke and Reject restored on when a read fails, and turns the rest off from the moment a refresh starts. The CHANGELOG's 0.9.32 entry has the whole list. To roll back, run this release's uninstall.sh before 0.9.31's installer. Coming from 0.9.30? Install over it, then read the 0.9.31 note. A qube that changes slot (but between p00 and no slot), or becomes a lead, does so only once it is shut down (nothing is killed), and such a move waits 10 s under qmcp-blocked to be sure it stays so; a hub proposal cannot manage a template your own qubes, or guarded ones, run on; dom0's services refuse every change but a kill while less than 64 MiB is free under /var/log, and qmcp check fails on audit lines that could not be written; qmcp restored reject refuses the hub; qubes_run_disposable says ok only once its disposable reads back halted or gone. The CHANGELOG's 0.9.31 entry has the whole list. To roll back, run this release's uninstall.sh before 0.9.30's installer. Coming from 0.9.29? Install over it, then read the 0.9.30 note. Nothing in AI space reaches a hidden project's qubes from outside it any more, not even through a dialog (a hidden lead's dialog copy into its own sink goes with it); qmcp seal takes an open badge off a drop box or the hub, and a seal whose kill fails is tried again and fails qmcp check until it is made or no longer needed. The CHANGELOG's 0.9.30 entry has the whole list. To roll back, run this release's uninstall.sh before 0.9.29's installer. Coming from 0.9.28? Install over it, then read the 0.9.29 note. qmcp revoke, an accepted revoke proposal and qubes_run_disposable kill the qube instead of asking it to shut down, and a revoke whose qube still runs exits 1; the anonymity gate kills a stopped project's qube it finds running, on every run, so you can no longer start one by hand while its project is unsound; approving a disposable template for a hidden project is refused while the hub can reach a qube made from it. The CHANGELOG's 0.9.29 entry has the whole list. To roll back, run this release's uninstall.sh before 0.9.28's installer. Coming from 0.9.27? Install over it, then read the 0.9.28 note. Besides the new proposal types and sudo qmcp template install: qmcp manage, guard, revoke and open refuse dom0, and every name while /etc/qmcp/hub cannot be read (a seal still takes a window off then, and only skips the kill); template prepare refuses while an install or another prepare runs in the same qube; managing an open qube ends its window with no kill; the window's proposal list heads its subject column "Subject". The CHANGELOG's 0.9.28 entry has the whole list. To roll back, run this release's uninstall.sh before 0.9.27's installer; a pending proposal of a new type then reads as unreadable, and qmcp proposal reject N closes it. Coming from 0.9.26? Install over it. Clones, the hub's and a lead's, change: a clone lands where its source is, so the hub's clone of a project's member joins that project (named inside its names, counted against its quota, on its worker networks) and its clone of an AppVM in no slot joins none (0.9.26 put it in p00); a clone, a lead cloned from one of the hub's qubes included, no longer copies its source's device assignments, menu entries or a disposable template's preload-dispvm-max; and a clone is refused when its template is stopped by the gate, waits for review, or, for a lead, is off its approved list. Every create also refuses to copy or build on a qube in AI space whose qmcp-id label is not its own. The CHANGELOG's 0.9.27 entry has the whole list. Coming from any release before 0.9.26? Besides that release's own note below, the installer labels every qube that wears a qmcp badge and has no label, for the restore check (see "Backing up and restoring"). Then prepare, once, each template qmcp check names under "in-qube services" (sudo qmcp template prepare QUBE): dom0 keeps no record of what an earlier copy loop wrote into it. Rolling back to 0.9.25 needs every held qube accepted or rejected first (qmcp restored list), then this release's uninstall.sh before 0.9.25's installer. Coming from 0.9.23? Install over it. If you have anonymous projects, the installer first checks them against this release's rule for template updates (step 7): a template's updates count only through a gateway you ticked. It names each project that would be stopped and changes nothing; run it again with --updates-via GATEWAY for each gateway whose upstream is the anonymiser itself (sys-whonix, a VPN qube), never sys-firewall. Rolling back to 0.9.23 needs the tick off every gateway first (0.9.23 refuses a registry that carries it), then this release's uninstall.sh (it keeps /etc/qmcp) before 0.9.23's installer. Coming from 0.9.22? Install over it; nothing changes until you create an anonymous project (step 7). Rolling back to 0.9.22 needs every anonymous project deleted first, then the upstream keys removed from /etc/qmcp/gateways.json: 0.9.22 refuses both, and has nothing that keeps the hub out of a hidden project. Then run this release's uninstall.sh (it keeps /etc/qmcp) before installing 0.9.22, or 0.9.22 leaves qmcp-gate.timer running a command it does not have. Coming from 0.9.21? Install over it. A lead whose model endpoint is an address keeps the DNS rule 0.9.21 gave it until you set its model again (sudo qmcp project firewall NAME --model ADDRESS:PORT); qmcp check warns until you do. Coming from 0.9.17 to 0.9.20? Install over it; nothing is retagged. Then enroll the gateways AI space uses (step 3): the registry starts empty, and until then no AI qube can be given a network and qmcp check fails on every one (other than a gateway) that has one. A Whonix gateway cannot be enrolled: put AI qubes that sit directly on sys-whonix on a router in front of it (qvm-prefs QUBE netvm ROUTER), or clear their network. Then accept each lead's current firewall, or give it a model (sudo qmcp project firewall NAME --accept-current or --model HOST:PORT); --accept-current takes only rules in qmcp's format (no comment or expire, at most 32), so a lead with others needs --model or --rule. qmcp check warns until you do. The hub's spawns and clones must be named ai-hub-… (from 0.9.27, a clone of a project's member is named inside that project's names instead). Coming from 0.9.16? Run the staged migration first; see "Install, migrate, uninstall" in CLAUDE.md.

3. AI space. Agents run commands only in qubes whose template carries the two in-qube services. Install them into a template and put it in AI space; then enroll the gateways AI qubes may sit on. Use a plain router of your own per kind of network (clearnet, Tor, a proxy, a VPN), in front of the qube that kind uses for your own work, named so it reads as yours: sys-ai-net, sys-ai-tor, sys-ai-proxy, sys-ai-vpn. A qube's own firewall rules are carried out by the qube directly above it, and only a router that passes its clients' traffic on carries them out: sys-whonix does not, so a Tor router goes in front of it, and a limit for all your Tor AI belongs inside that router.

# in dom0
qvm-clone <a Debian template> ai-debian-13    # a template for AI qubes, if you have none
sudo qmcp template prepare ai-debian-13       # writes the in-qube services in, from dom0's copy
sudo qmcp manage ai-debian-13       # the hub may build on it; `guard` keeps it a reference only
qvm-create --class AppVM --template "$(qubes-prefs default_template)" --label red sys-ai-net
qvm-prefs sys-ai-net provides_network True
qvm-prefs sys-ai-net netvm sys-firewall
sudo qmcp gateway enroll sys-ai-net --label clearnet   # AI qubes may sit on it
echo sys-ai-net | sudo tee /etc/qmcp/birth-egress      # where the hub's template-based qubes go online

qmcp template prepare takes a TemplateVM or a StandaloneVM: it starts a halted one, writes the two services in as root, labels it with their version, and shuts it down again. dom0 keeps its own copy of the services, so this works at any time, not only while a fetched release is unpacked; qmcp check warns on a template it runs commands in that was never prepared, or carries an older version. After an update, a running prepared qube's own disk is brought up to date within a minute, and a halted one's the next time it runs (none is started for it); the qubes built on a template get the new services once it has shut down and they restart.

qmcp gateway list shows what is enrolled and whether each is still usable, and marks a router whose upstream is a Whonix gateway. The birth-egress file is needed unless the hub's own netvm is enrolled.

4. Connect a client. For Claude Code (~/.claude.json):

{
  "mcpServers": {
    "qubes": {
      "command": "ssh",
      "args": ["-T", "user@<mcp-control-host>",
               "cd ~/qubes-mcp && exec python3 -m qubes_mcp"]
    }
  }
}

Reach the hub over a network you control (a tailnet, Headscale, WireGuard).

5. Projects (optional). A project gets its own lead agent and workers. New project... in the window, or in dom0, as root:

sudo qmcp project create osint --lead-template ai-debian-13 --lead-netvm sys-ai-net \
  --model api.anthropic.com:443 --network sys-ai-net --network none --quota 40G --dump

The lead is born as ai-osint-lead, with a firewall that allows its model endpoint, DNS (for a host name) and nothing else (qmcp project firewall osint shows it, and changes it); the qubes it creates will be named ai-osint-* (Qubes names disposables), built from the approved templates (the lead's own when it is in AI space, plus any --template), on the listed networks, inside the quota. --lead-clone QUBE copies one of the hub's own qubes that you prepared as an agent instead, and --lead-promote QUBE makes one of the hub's qubes the lead in place; either may be a standalone, and the lead keeps its class. --lead-template T --lead-class standalone makes the lead a standalone: its own copy of T's root, so an agent you install in it stays after a restart, and so does anything a compromised agent leaves there. A lead may spawn standalone workers from its approved templates too (qubes_spawn with klass StandaloneVM). A standalone's root counts at the template root's full size, often 20 GiB, against the pool cap and, for a worker, the project's quota: a 40 GiB quota holds one standalone and a few AppVMs. Sizes count as provisioned, not as used, so what AI space keeps (private volumes, and the roots of templates and standalones) cannot outgrow the cap through its own creates even on a thin pool; volatile volumes and an AppVM's changes to its template's root are not counted, and a lead you make with qmcp project is not checked against the cap. A Linux standalone you built yourself joins a project once it is in AI space: sudo qmcp manage QUBE, sudo qmcp template prepare QUBE (so the lead can run commands in it), then sudo qmcp project move QUBE LABEL. Moving it in is your approval of what it runs, as approving a template is. Build it from a template outside AI space: qvm-create --standalone -t T copies T's tags and features, so a copy of a template in AI space comes up wearing ai-managed and another qube's label, and the restore check holds it as a hand copy (sudo qmcp restored reject QUBE takes it out again). Windows qubes are not supported. The lead's firewall lets it reach its model endpoint and DNS (for a host name) only, so it cannot fetch software, git clone included: its agent and qubes-mcp come from outside. Prepare both in one of the hub's qubes and make the lead with --lead-clone or --lead-promote. Or install the agent in the lead's template yourself, and copy qubes-mcp in from dom0, from step 2's tarball (if dom0 has restarted since, /tmp is empty: fetch it again as step 2 does):

qvm-run --pass-io ai-osint-lead \
  'mkdir -p ~/qubes-mcp && tar -xzf - -C ~/qubes-mcp --strip-components=1' < /tmp/qmcp.tgz

Give the lead's agent its own model key. The agent runs in the lead, so its MCP client starts the server there (cd ~/qubes-mcp && exec python3 -m qubes_mcp), with no ssh. qmcp project, and the window, also edit, move qubes between slots, remove or change a lead, and delete a project.

Or let the hub ask: its agent calls qubes_propose_project (or another qubes_propose_* tool), dom0 shows a notification, and you accept or reject the proposal in the window, where the options the hub sent, what accepting does now and anything that needs a second tick are shown first.

6. A self-hosted model (optional). A lead can use a model that runs in a qube of yours instead of a remote API: a model qube, guarded and with no network, which the leads of the projects it serves reach on port 11434 through Qubes' qubes.ConnectTCP, and, while it is guarded, nothing else in AI space (the hub only through your dialog). The lead then has no network either, so a project with a self-hosted model, a lead with no network and workers on no network is sealed.

Set the model qube up while it still has a network. The hub can, like any qube it manages: spawned from a template the hub does not manage (a guarded one) that carries the two in-qube services, with room on its private volume (private_size). A qube you set up yourself outside AI space joins it with sudo qmcp guard QUBE once it has no network (qvm-prefs QUBE netvm '') or sits on an enrolled gateway; guard refuses any other network. An AppVM keeps /usr/local, /home and /rw/config/rc.local across restarts. With Ollama, as root in the model qube:

cd /home/user
V=$(curl -fsSL https://api.github.com/repos/ollama/ollama/releases/latest | python3 -c 'import json,sys; print(json.load(sys.stdin)["tag_name"])')
B=https://github.com/ollama/ollama/releases/download/$V
curl -fsSL -o sha256sum.txt "$B/sha256sum.txt"
curl -fsSL -o ollama-linux-amd64.tar.zst "$B/ollama-linux-amd64.tar.zst"
want=$(awk '$2 ~ /(^|\/)ollama-linux-amd64\.tar\.zst$/ {print $1}' sha256sum.txt)
got=$(sha256sum ollama-linux-amd64.tar.zst | cut -d' ' -f1)
if [ -n "$want" ] && [ "$want" = "$got" ]; then
  tar --zstd -xf ollama-linux-amd64.tar.zst -C /usr/local --exclude='cuda_*' --exclude='rocm*' --exclude='vulkan*'
else
  echo "checksum MISMATCH: nothing installed"
fi
rm -f ollama-linux-amd64.tar.zst
cat > /rw/config/rc.local <<'EOF'
#!/bin/sh
runuser -u user -- env HOME=/home/user OLLAMA_HOST=127.0.0.1:11434 setsid /usr/local/bin/ollama serve >/home/user/ollama.log 2>&1 < /dev/null &
EOF
chmod 0755 /rw/config/rc.local && /rw/config/rc.local
sleep 5; runuser -u user -- env HOME=/home/user /usr/local/bin/ollama pull qwen2.5:0.5b

A qube has no IPv6 route, so a pull can stop at network is unreachable on an IPv6 address; run the pull again, and it resumes.

Then, in dom0, make it the project's model qube. One command takes the lead's network away, takes the qube out of p00, removes its network, guards it and kills it if it runs, and only then lets the lead reach it (--model-qube on project create and project lead does the same):

sudo qmcp project firewall osint --model-qube ai-hub-model

In the lead, qvm-connect-tcp 11434:ai-hub-model:11434 (it runs until stopped) makes the model answer on the lead's own localhost:11434; point the agent there. qubes_get_pool_stats tells the lead the model qube's name and port. To change the model qube later, un-guard it for a while (sudo qmcp manage ai-hub-model; qmcp check warns until you guard it again, and sudo qmcp guard ai-hub-model kills it if it runs, so no process the hub started in it runs on; what it left in /home, /usr/local or /rw stays, and whatever is set to start from there runs at its next start). Pulling another model needs a network: take the qube off its projects first (sudo qmcp project firewall osint --model-qube none), because a model qube with a network would be its leads' way out, and qmcp check fails on one.

A model qube may serve several projects, and that is a path between them: Ollama's API has no login and lets any client create, copy and delete models, so a hijacked lead can change the model another project's lead uses, or pass it data. It is safe only behind an API filter that lets inference calls through and nothing else, and qmcp ships none: the command and the window say so whenever a model qube is shared. One model qube per project needs no filter. Several can share one copy of the server and the weights if a guarded template holds both in its root filesystem (under /opt, say: a qube based on a template has its own /usr/local and /home, never the template's).

7. Anonymous projects (optional). Put a plain router of yours in front of sys-whonix (or a VPN qube) and enroll it as anonymising: dom0 records the network it sits on, and the gate stops any anonymous project on it if that ever changes. In Qubes' Global Config (Updates), send your templates' updates through sys-whonix, the qube directly above the router, and tick the router for updates (--updates): your word that this upstream is the anonymiser itself. Never tick a router whose upstream is sys-firewall. Give the project a template the hub never touched, guarded so it never can:

# in dom0
qvm-create --class AppVM --template "$(qubes-prefs default_template)" --label red sys-ai-tor
qvm-prefs sys-ai-tor provides_network True
qvm-prefs sys-ai-tor netvm sys-whonix
sudo qmcp gateway enroll sys-ai-tor --anonymising --updates --label tor
qvm-clone <a Debian template of yours> anon-debian-13   # one the hub never touched
sudo qmcp template prepare anon-debian-13
sudo qmcp guard anon-debian-13
sudo qmcp project create --anonymous --lead-template anon-debian-13 --lead-netvm sys-ai-tor \
  --model api.anthropic.com:443 --network sys-ai-tor --quota 20G --note "what it is for"

dom0 picks the label and prints it with the lead's name; the note is yours alone. qmcp project list shows each anonymous project, its kind and whether it is stopped; qmcp gate and the window's Anonymity tab show what the gate found; sudo qmcp project unblock NAME clears a stop once the gate finds the project sound again. --hub-sees makes one the hub may see and operate (hidden from the network, not from the hub). A model qube or a template the hub operated before you guarded it can harm a hidden project, since guarding removes nothing the hub left; projects behind one router share its exit. Mark no anonymous qube to start at boot: the gate's first run after boot is not ordered before Qubes' autostart. A qube may move into or out of an anonymous project (qmcp project move QUBE NAME --yes): the command says first that it has a past, and refuses a move the gate would stop.

8. Anonymous mode (optional). Every project is anonymous and the gate judges the hub as well. Turn it on with no ordinary project and no clearnet gateway enrolled, with the hub on an anonymising router (or on none) and its template's updates going through the anonymiser, as in step 7:

# in dom0, after step 7's router is enrolled and ticked
qvm-prefs mcp-control netvm sys-ai-tor
sudo bash /tmp/qubes-mcp/deploy/install.sh --anonymous

The installer refuses, and names why, while any of that is not so. It lists every qube already in AI space that the hub's check will cover (guarded routers and anonymous projects' qubes aside): each has a past, and all of them, with the hub, get qmcp-anon, which keeps them from opening a URL or a file in another qube. qmcp cannot check whose account the hub's model uses, nor what the hub qube did before: use a hub qube and a model account that never ran on clearnet. From then on qmcp project create makes anonymous projects (hidden unless --hub-sees), the hub's own qubes and templates are judged every 15 seconds, and a stopped hub is cleared with sudo qmcp project unblock p00. No qmcp command turns the mode off; uninstall.sh --purge does. Then remove qmcp-anon from the hub (qvm-tags mcp-control del qmcp-anon), or qmcp check fails on it.

Updating

Fetch the new release exactly as in step 2, into a fresh /tmp/qubes-mcp, and run the installer again: it is idempotent, backs up what it replaces and ends with qmcp check. Then:

  • Prepare what the check names. A release that changes the in-qube services marks every prepared template and standalone as behind. A running one's own disk is brought up to date within a minute, a halted one's the next time it runs, and the qubes built on a template get it once it has shut down and they restart. To do one now, sudo qmcp template prepare QUBE, which starts it if it is halted.

  • Restart the window if it is open: one opened before the install runs the old code.

  • Update the hub's copy of qubes_mcp/ (step 1) and each lead's. A hub cloned with git: git -C ~/qubes-mcp pull in the hub (the head of main is always the newest release). One without git, and each lead, from step 2's tarball in dom0 (name the lead in place of mcp-control; nothing in ~/qubes-mcp is kept):

    qvm-run --pass-io mcp-control \
      'rm -rf ~/qubes-mcp && mkdir ~/qubes-mcp && tar -xzf - -C ~/qubes-mcp --strip-components=1' \
      < /tmp/qmcp.tgz
    

    Then restart its agent's MCP client, which starts the server afresh.

The CHANGELOG's entry for each version says what else an update from the one before it needs.

Backing up and restoring

A Qubes backup takes dom0 in as your home directory and nothing else, so it never holds qmcp's own settings. Export them into your home first, then back up with dom0 ticked:

# in dom0
sudo qmcp export                  # writes ~/qmcp-export-<time>.json, mode 0600

To restore onto a reinstalled Qubes:

# in dom0
qvm-backup-restore --skip-conflicting ...   # everything, dom0 ticked; nothing operates the AI qubes yet
# fetch and unpack the release into /tmp/qubes-mcp as in step 2, then:
sudo bash /tmp/qubes-mcp/deploy/install.sh --hub mcp-control   # the same hub
sudo qmcp import ~/home-restore-<time>/dom0-home/<you>/qmcp-export-<time>.json
sudo qmcp restored accept --all   # the restored AI qubes, back into their projects

The installer's closing check is FAILED until the import and the accept: no gateway is enrolled yet, and the restored AI qubes are held. That is expected here; for an export of an install in anonymous mode, until the mode is on again (step 8).

A restore gives every qube a new identity and keeps its tags, so the installed qubes-mcp holds each restored AI qube for your review: no call from another qube reaches it, and it reaches nothing, until you accept it (its network is unchanged). qmcp restored list shows what each came back as and whether that agrees with the projects. With anonymous projects, send your templates' updates through the anonymiser again (step 7) before the import: a Qubes backup does not carry dom0's update routing, and the gate, which runs right after the import, would stop each anonymous project (clear one with sudo qmcp project unblock NAME once it is sound). An export of an install in anonymous mode is imported onto a normal install the same way; once the qubes are accepted, turn the mode on (step 8), and start neither the hub nor any AI qube, nor mark one to start at boot, before then.

Do not restore AI qubes into a running install whose projects have changed. Until the gate's next pass holds it, normally within about 15 seconds, a worker restored months later is routed by its old badges into whatever project holds its slot now. If you must, shut the hub and the leads down first, then review each one (qmcp restored accept QUBE or reject QUBE). The same hold catches an AI qube you copy by hand with qvm-clone.

Tests

python3 -W error::DeprecationWarning -m unittest discover -s tests -p 'test_*.py'

The policy suite runs every rule through qrexec's real parser (it needs python3-qrexec), next to upstream Qubes 4.3's default policy, and fails if any rule decides nothing. tests/seat_suite.py, tests/redteam_suite.py and tests/project_suite.py run in the hub against a real dom0; the last carries tests/lead_seat.py into a project's lead and runs it there. On a test machine running Qubes 4.3.1 they passed 41 of 41 checks, 39 of 39 probes and 80 of 80 checks on 2026-10-05, the lead seat inside a lead with no network that asked its self-hosted model qube (Ollama, qwen2.5:0.5b) a question through qvm-connect-tcp. tests/test_strict_reads.py fails qube reads one at a time, at each point a call makes them, and requires the restrictive answer: a failed read is never taken for "no tags", "no network" or "not a gateway". tests/test_proposals.py covers proposals: who may submit, the store, the second tick, accepting and rejecting through the real commands. tests/test_gateways.py covers the gateway registry, the networks AI qubes may be given and leads' firewalls. tests/test_anon.py covers anonymous projects: each gate condition broken on its own, with dom0's update question asked of qrexec's real parser, the block and its order, a read that fails once and twice, unblock, and what the hub can no longer see. tests/test_mode.py covers anonymous mode: the hub's check and what each violation stops, the updates tick, creates and proposals in the mode, and moves into and out of anonymous projects. tests/test_operator_tools.py covers 0.9.26's tools: preparing templates and standalones and keeping them up to date, the restore check that holds a qube back from a backup or a copy, the settings, and export and import. tests/test_gui.py drives the window's forms through the real qmcp command and fails if the command has a command, option or field the window neither offers nor exempts; tests/GUI-CHECKLIST.md is the click-through for a person.

Reviewer asks

Review from people who know qrexec policy and the Admin API is genuinely wanted. The specific questions are in OPEN_QUESTIONS.md.

Where this has been discussed:

Caveat

This is operator-grade infrastructure for one use case: AI agents working in Qubes-isolated qubes. It is not a hardened product. The hub is itself part of the trust boundary: a compromised hub controls all of AI space but hidden anonymous projects, and the audit log records what it does through the dom0 services (past 60 calls that changed nothing in a clock hour, those are only counted).

License

MIT — see LICENSE.

↑↓ navigate openesc close