Skip to main content
Glama

mcp-devvm

A working implementation of the architecture in MCP_SERVER_DEVVM.md: an MCP server that lets an AI agent do real work on a dev VM — run tests, switch branches, restart services — while being treated as hostile at every step. The agent never runs a command. It selects a typed operation from a fixed, reviewed registry; a daemon (devd) on the VM re-validates and sandboxes it from the same declaration a human approves; nothing executes without an human decision that devd itself verifies: a signature from an approver key that only the human can unlock.

This covers the doc's Phases 0–5 (§26.3) against a Docker container standing in for the dev VM, with namespace-mode (bubblewrap) sandboxing. Phase 6's production hardening — HSM-backed keys, real cloud IaC, external audit anchoring, paging — needs real infrastructure this sandbox doesn't have; see What's simulated below for the honest accounting.

Everything described as "working" here was actually run — the dev-VM container is built and driven for real, over a real SSH tunnel, through real bubblewrap sandboxes, with a real approval CLI in a separate process. 145 tests pass, including 12 integration tests against the live container.

See ARCHITECTURE.md for two diagrams — the request lifecycle end to end, and which file talks to which and when — and ROADMAP.md for what's done vs. outstanding against the doc's own build order.

Architecture

🤖 Agent → MCP Client → MCP Server (server/, this host)
                              │
                              ├─ policy · proposals · approvals (SQLite)
                              ├─ audit (hash-chained JSONL)
                              │
                         ══ SSH, cert auth, no forwarding ══
              local unix socket → one SSH session per connection
                → forced command pipes it to devd's unix socket
                              │
                              ▼
                    devd (devd/, the "VM" container)
                              │
                    operation registry · own validation
                    · own policy · sandbox from manifest
                              │
                              ▼
                    bubblewrap namespace · git/tail/tee
                    · run records · local audit

Two independent enforcement points (doc's B5): a compromised control plane still can't exceed the operation set, because devd re-validates every param against its own on-disk manifests, re-checks the per-request token's operation scope, and — for anything that needs approval — verifies a human approver's signature over the exact proposal before doing anything.

Related MCP server: MCP ToolHub

Quick start

uv venv .venv && uv pip install --python .venv/bin/python -e ".[dev]"

# Install a root-owned copy of the approval CLI that the agent can't edit
# (it asks you to confirm the git commit it installs from), and create
# /etc/mcp-devvm/approvers/, the root-owned directory of approver public
# keys that devd trusts.
sudo scripts/install_decide.sh

# Build + start the demo dev-VM container, mint the CA and token-signing
# keys, and pin the container's real sshd host key into config/targets.yaml.
.venv/bin/python scripts/setup_dev_vm.py

# Create your approver key. It's named after your OS user, one per user.
# You pick a passphrase; it's never stored, and you'll type it each time
# you approve something.
/opt/mcp-devvm-approve/bin/approver-keys init

# Enroll it, so devd trusts it. This needs root because the trusted key
# directory must not be writable by you (or an agent running as you). It
# asks for your passphrase to prove the key is yours.
sudo /opt/mcp-devvm-approve/bin/approver-keys enroll

# Run the MCP server (stdio transport) -- wire this into Claude Code / any
# MCP client the same way you would any other stdio server:
#   command: <repo>/.venv/bin/python
#   args:    ["-m", "server.main"]
#   cwd:     <repo>
.venv/bin/python -m server.main

From the agent, request_operation("git.checkout", {"repo": "api", "branch": "..."}, "reason") returns {"approval_id": ..., "status": "PENDING"} and executes nothing. Approve or deny it from a separate terminal — this is deliberately not an MCP tool (doc §7.2, B4). Approving asks for your approver passphrase and signs the exact proposal and effect text you were shown:

/opt/mcp-devvm-approve/bin/decide list
/opt/mcp-devvm-approve/bin/decide show appr_xxxxxxxx
/opt/mcp-devvm-approve/bin/decide approve appr_xxxxxxxx

(.venv/bin/python -m server.approvals.decide ... runs the same code from the repo. That's fine for trying it out with an agent that has no shell, but an agent that can edit the repo could change that copy to capture your passphrase.)

For DESTRUCTIVE and PRIVILEGE operations, devd needs two approvals from two different OS users, and neither can be the user who made the request.

The running MCP server's background dispatcher notices the approval (a separate process writing to the same SQLite store) within ~2s and executes it. check_run_status(approval_id) from the agent shows the outcome.

Using this from VS Code (Copilot Chat agent mode)

VS Code's built-in MCP support (via the GitHub Copilot Chat extension's Agent mode) reads server definitions from a .vscode/mcp.json file in the workspace. Create one at the repo root:

{
  "servers": {
    "mcp-devvm": {
      "type": "stdio",
      "command": "${workspaceFolder}/.venv/bin/python",
      "args": ["-m", "server.main"],
      "cwd": "${workspaceFolder}"
    }
  }
}

On Windows, use "${workspaceFolder}\\.venv\\Scripts\\python.exe" instead.

cwd matters here specifically: server/main.py resolves config/ and data/ as paths relative to the process's working directory, not the script's location — without "cwd": "${workspaceFolder}", VS Code may launch the server from some other directory and every tool call will fail looking for a config/targets.yaml that isn't there.

Then, before using it:

  1. Complete the Quick start above first (.venv created, scripts/setup_dev_vm.py has built and started the container) — the server has nothing to talk to otherwise.

  2. Reload or open the Command Palette (Cmd/Ctrl+Shift+P) → MCP: List Servers to confirm VS Code picked up mcp-devvm, or MCP: Add Server if you'd rather click through the setup instead of hand-writing the JSON above (choose "Command (stdio)", point it at the same command/args/cwd, and save it to the workspace).

  3. Open Copilot Chat, switch the mode dropdown at the bottom of the chat box from Ask/Edit to Agent.

  4. Click the tools icon (🔧) in the chat input and confirm mcp-devvm's tools are enabled — list_targets, list_operations, describe_target, run_operation, request_operation, check_run_status, get_run_output, list_my_pending_approvals.

  5. Chat normally — e.g. "check out feature/login-fix in the api repo." The agent calls request_operation and stops there; nothing executes.

The approval step stays outside VS Code's agent loop, on purpose. server/approvals/decide.py is deliberately not an MCP tool (doc §7.2, B4 — the decision channel the agent cannot reach), so approving or denying what the agent proposed happens in an ordinary VS Code integrated terminal (Ctrl+`), same commands as the Quick start section:

/opt/mcp-devvm-approve/bin/decide list
/opt/mcp-devvm-approve/bin/decide approve appr_xxxxxxxx

The shipped operations

Four operations across three risk classes — enough to exercise the whole pipeline without the scope of NETWORK_EXEC/container-sandboxing or DESTRUCTIVE/four-eyes, which this build skips (see below):

operation

risk

approval

what it does

git.status

READ_ONLY

none

branch, HEAD, dirty state for a workspace repo

logs.tail

READ_ONLY

none

last N lines of the demo app log

git.checkout

MUTATING_LOCAL

required

switch branches locally, with precondition check-and-act

service.restart

SERVICE_CONTROL

required + step-up

rewrites a state file (stand-in for systemctl restart)

Add a new one by dropping a <name>.yaml manifest + <name>.py implementation into devd/operations/ (see git_checkout.py for the shape) — devd/registry.py picks it up automatically.

What's simulated, and why

Being upfront about every substitution, so nothing here is mistaken for a production posture:

  • --privileged on the dev-VM container. Bubblewrap needs unprivileged user-namespace creation, which this sandbox's own Docker doesn't grant to a nested container without it (tested: fails with pivot_root: Operation not permitted under narrower capability sets). On a real, non-containerized dev VM kernel, bubblewrap works unprivileged out of the box — this flag is purely an artifact of running the "VM" as a container inside this environment's own container.

  • Software SSH CA, not HSM-backed (scripts/ca.py). Real ssh-keygen -s mechanics — short-lived certs, source-address pinning, principal attribution — just with the CA private key as a 0600 file instead of in a KMS. The doc's own fallback ladder for topology-A-without-CA-infra explicitly allows this.

  • Approver keys are passphrase-encrypted files, not hardware keys. data/approvers/private/<name>.key is readable by your OS user, so the passphrase is what protects it. A hardware key or passkey would remove the file entirely; see Known limitations.

  • devd's mutating-call handling runs synchronously, not backgrounded with polling (doc §13.5). Every shipped operation finishes in well under a second, so POST /operations/{id}/run awaits completion and returns the terminal status directly, while still writing a durable run record before the effect starts with status written last — so GET /runs/{id} and idempotency-key replay behave identically to the fully backgrounded design even if the HTTP response never reaches the caller (exercised for real in tests/integration/test_end_to_end.py::TestFaultInjection). A genuinely long-running operation would need asyncio.create_task + polling; nothing else here would need to change to support that.

  • SQLite control-plane store, single MCP-server process. Several users and targets are supported in config, but only one demo target is provisioned by scripts/setup_dev_vm.py. The doc says SQLite (WAL, BEGIN IMMEDIATE) is adequate for the local topology; this just doesn't scale it to Postgres/multi-tenant.

  • CLI approval (server/approvals/decide.py), not a web UI. The doc treats the decision path as "the human path — NOT an MCP tool"; a terminal satisfies that boundary as well as a browser does, just with a plainer rendering of §19/§23's UI rules (never truncate, show writes/reads/network/services even when empty, typed confirmation for high-risk classes, a rate indicator).

  • No NETWORK_EXEC / container-sandbox mode, no DESTRUCTIVE / four-eyes operations. Deliberately out of scope for this build (namespace-mode sandboxing only) — §14.3's ephemeral-container-per-run design for npm install-style operations is unbuilt, and so is the four-eyes / typed-confirmation path for anything actually destructive, though the approval store's approvals_required mechanism and the CLI's high-risk confirmation prompt both already support it structurally.

  • No Phase 6 hardening: no external audit-chain anchor, no real IaC provisioning or quarterly rebuild drill, no HSM/KMS, no paging/monitoring stack, no cgroup/disk-quota resource limits beyond what bubblewrap's namespace isolation gives for free. The hash-chained JSONL audit sink (server/audit/sink.py) is the local piece such a pipeline would ship off-box; nothing here does the shipping.

What's real and independently verified

Not simplified, not mocked — each of these was driven end-to-end against the live container and is covered by a passing test:

  • Real bubblewrap sandboxing: verified undeclared reads fail (No such file or directory), undeclared writes land in a throwaway filesystem (never the real host path), network is unreachable when undeclared.

  • Real SSH: a genuine certificate-authenticated connection, a genuine per-request JWT devd independently verifies against a key it only holds the public half of. The tunnel account can't forward anything: TCP and stream-local forwarding are both refused, and any command it's asked to run only ever reaches devd's socket (TestTunnelAccountScope).

  • Real human-approval signatures: a DB row marked approved by hand (what an agent with a shell could do) is refused by devd with 403, and so is a real signature when the effect text shown to the human was altered (TestApprovalSignatures).

  • Real cross-plane enforcement (B5): a token minted for one operation is rejected (403) if replayed against a different operation's endpoint.

  • Real manifest-version binding: a proposal made against an older manifest version settles STALE, not silently run against different code; editing the stored version without a matching hash settles UNAUTHORIZED.

  • Real precondition check-and-act: git.checkout re-verifies HEAD and worktree cleanliness inside the sandboxed operation, microseconds before the mutation; stale preconditions get 409 precondition_drift.

  • Real idempotency: replaying the same Idempotency-Key never re-executes, whether the original attempt settled or never started.

  • Real fault injection: a simulated transport drop mid-call settles UNKNOWN, never SUCCEEDED/OP_FAILED; the reconciler (a real polling task, not a mock) resolves it via devd's own idempotency-keyed run lookup — read-only, never re-executing — and escalates to NEEDS_HUMAN once genuinely exhausted.

  • Real hash-chained audit trail: tampering with any past record breaks verify_chain().

Testing

.venv/bin/python -m pytest tests/unit -q          # pure logic, no infra
.venv/bin/python -m pytest tests/adversarial -q   # isolation/structural gates
.venv/bin/python scripts/setup_dev_vm.py --test-approvers
.venv/bin/python -m pytest tests/integration -q   # against the live container

The integration tests trust throwaway approver keys by writing them into devd's key directory, so they need a VM set up with --test-approvers, which mounts a directory your user can write. Don't use that VM with an agent connected. Against a normal VM, the tests that approve something are skipped.

Project layout

server/            control plane: auth, policy, proposals, approvals (CAS
                   store + state machine + CLI decide.py), execution
                   (executor/triage/reconciler/dispatcher), tunnel (the
                   ONLY module that opens SSH), audit, the MCP tool surface
devd/              data plane: FastAPI app, operation framework
                   (OperationContext/Workspace/Precondition), bubblewrap
                   sandbox builder+runner, operations/ (the perimeter)
common/            shared pure logic: param validation, canonical JCS-like
                   hashing, output sanitize/redact -- independently invoked
                   by both planes, never a trust dependency between them
vm/                the container standing in for the dev VM: Dockerfile,
                   sshd hardening, entrypoint, demo data seeding
scripts/           SSH CA, token-signing keypair, approver keys, dev-VM setup
config/            targets.yaml (server-side only), policy.yaml, roles.yaml
tests/             unit, adversarial (isolation/structural), integration
                   (against the real container)

Known limitations to know about before extending this

  • The approval CLI is only as safe as the copy you run. Signed approvals stop an agent from approving on its own: writing the DB, or using the server's token key (both readable by your OS user), no longer gets anything that needs approval past devd. The repo copy of decide.py can be edited by an agent, so use the root-owned copy from sudo scripts/install_decide.sh, invoked by its full path. That copy still runs as your OS user, inside a shell whose startup files the agent can edit, so a hardware key or a separate OS account for approving is stronger. Use a strong passphrase: the encrypted key file can be copied and attacked offline.

  • Enrolling approvers needs root. devd only trusts public keys in the root-owned /etc/mcp-devvm/approvers/, so an agent running as you can't add one of its own. Adding a person means sudo approver-keys enroll; revoking one means sudo rm /etc/mcp-devvm/approvers/<user>.pub. Keys from before this change in data/approvers/public/ are no longer trusted: enroll them again.

  • Multiple users and targets need some setup by hand. Add users to config/roles.yaml and give each approver their own key; add targets to config/targets.yaml and config/policy.yaml. The agent passes target_id when there's more than one. scripts/setup_dev_vm.py only provisions the one demo container.

  • No egress allowlist / IMDS blocking / disk quota enforcement on the VM side beyond what vm/harden.sh does (link-local block, permission checks) — real §17 blast-radius hardening needs a real VM and IaC.

License

MIT — see LICENSE.

Related MCP Connectors

Related MCP Servers

  • F
    license
    A
    quality
    C
    maintenance
    Enables AI coding agents to run Kubernetes inspection and Terraform plan/apply operations inside ephemeral gVisor-sandboxed jobs with short-lived, narrowly-scoped credentials, while routing destructive changes through a human approval gate.
    3
    -
  • F
    license
    A
    quality
    A
    maintenance
    Enables coding agents to perform workspace-confined file operations, read-only Git inspection, and structured shell commands, while requiring out-of-band human approval for mutations and external executions and maintaining an audit trail.
    14
    3
    -
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI clients to securely inspect and edit approved code, use semantic code intelligence, run builds/tests, supervise bounded processes, inspect Git, and execute owner-approved SSH commands on engineering workstations.
    2
    Apache 2.0
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI assistants to securely inspect, modify, and manage local projects with trusted filesystem access, safe Git operations, controlled task execution, and developer runtime process management over stdio or HTTP.
    4
    Apache 2.0