Coding Agent VMs on NixOS with microvm.nix

Michael Stapelberg

在 NixOS 上使用 microvm.nix 搭建编程智能体虚拟机

原文由 Michael Stapelberg 发布,订阅该博客

我逐渐认识到,编程智能体在处理代码的各类任务中都是很有价值的工具,无论是了解某个程序的架构、排查缺陷,还是开发概念验证。但具体到实际使用场景,逐条审核智能体想要执行的每一条命令,很快就会变得繁琐又耗时。为了无需审核也能安全地运行编程智能体,我想要一种虚拟机(VM)方案:智能体无法访问我的个人文件,即便被恶意软件感染也无伤大雅——大不了直接丢弃虚拟机,重新开始。

与其搭建一个有状态的虚拟机、需要时再重装(太麻烦了!),我更偏好临时虚拟机的模式:除了与宿主机显式共享的内容外,磁盘上不会留下任何持久化数据。

microvm.nix 项目让在 NixOS 上创建这类虚拟机变得十分简单,本文将介绍我搭建虚拟机的方式。

另请参阅

如果你之前从未听说过 NixOS,可以查看NixOS 维基百科页面nixos.org。我谈过我为何在 2025 年转向 Nix,也发布过几篇关于 Nix 的博文

要了解 AI 智能体的威胁模型,请阅读Simon Willison 的《The lethal trifecta for AI agents: private data, untrusted content, and external communication》(2025 年 6 月)。本文应对该威胁模型的思路,是将其中“私有数据”这一项从等式中移除。

如果想全面了解沙箱领域,可以看看Luis Cardoso 的《A field guide to sandboxes for AI》(2026 年 1 月)。本文不会对比不同的方案,只会展示其中一条可行的路径。

最后,如果你暂时不想自己搭建或运行沙箱基础设施,好消息是:沙箱如今是个热门话题,市面上已经涌现出许多满足这一需求的商业化产品。例如,我在 Go 社区就认识的 David Crawshaw 和 Josh Bleecher Snyder 最近推出了exe.dev,这是一项面向智能体的虚拟机托管服务。另一个例子是推出了 Sprites 的 Fly.io

搭建 microvm.nix

闲话少说!接下来几节将带你逐步完成我的配置。

第 1 步:网络准备

首先,我创建了一个名为 microbr 的网桥,使用 192.168.33.1/24 作为 IP 地址段,并通过 eno1 网卡进行 NAT 转发。所有 microvm* 接口都会加入这个网桥:

systemd.network.netdevs."20-microbr".netdevConfig = {
  Kind = "bridge";
  Name = "microbr";
};

systemd.network.networks."20-microbr" = {
  matchConfig.Name = "microbr";
  addresses = [ { Address = "192.168.83.1/24"; } ];
  networkConfig = {
    ConfigureWithoutCarrier = true;
  };
};

systemd.network.networks."21-microvm-tap" = {
  matchConfig.Name = "microvm*";
  networkConfig.Bridge = "microbr";
};

networking.nat = {
  enable = true;
  internalInterfaces = [ "microbr" ];
  externalInterface = "eno1";
};

第 2 步:flake.nix

接着,我在 flake.nix 中将 microvm 模块添加为新的输入(详情可查看microvm.nix 文档),并在我的电脑(midna)的 NixOS 配置中启用了 microvm.nixosModules.host 模块。我还新建了一个 microvm.nix 文件,用于声明所有的虚拟机。我的 flake.nix 内容如下:

{
  inputs = {
    nixpkgs = {
      url = "github:nixos/nixpkgs/nixos-25.11";
    };
    # For more recent claude-code
    nixpkgs-unstable = {
      url = "github:nixos/nixpkgs/nixos-unstable";
    };
    stapelbergnix = {
      url = "github:stapelberg/nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    zkjnastools = {
      url = "github:stapelberg/zkj-nas-tools";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    microvm = {
      url = "github:microvm-nix/microvm.nix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    home-manager = {
      url = "github:nix-community/home-manager/release-25.11";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    configfiles = {
      url = "github:stapelberg/configfiles";
      flake = false; # repo is not a flake
    };
  };

  outputs =
    {
      self,
      stapelbergnix,
      zkjnastools,
      nixpkgs,
      nixpkgs-unstable,
      microvm,
      home-manager,
      configfiles,
    }@inputs:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs {
        inherit system;
        config.allowUnfree = false;
      };
      pkgs-unstable = import nixpkgs-unstable {
        inherit system;
        config.allowUnfree = true;
      };
    in
    {
      nixosConfigurations = {
        midna = nixpkgs.lib.nixosSystem {
          system = "x86_64-linux";
          specialArgs = { inherit inputs; };
          modules = [
            (import ./configuration.nix)
            stapelbergnix.lib.userSettings
            # Use systemd for network configuration
            stapelbergnix.lib.systemdNetwork
            # Use systemd-boot as bootloader
            stapelbergnix.lib.systemdBoot
            # Run prometheus node exporter in tailnet
            stapelbergnix.lib.prometheusNode
            zkjnastools.nixosModules.zkjbackup
            microvm.nixosModules.host
            ./microvm.nix
          ];
        };
      };
    };
}

第 3 步:microvm.nix

下面的 microvm.nix 声明了两个 microvm,一个用于 Emacs(我想借此多了解一些 Emacs),另一个用于 Go Protobuf,这个代码库我比较熟悉,可以用来评估 Claude 的能力:

{
  config,
  lib,
  pkgs,
  inputs,
  ...
}:

let
  inherit (inputs)
    nixpkgs-unstable
    stapelbergnix
    microvm
    configfiles
    home-manager
    ;

  microvmBase = import ./microvm-base.nix;
in
{
  microvm.vms.emacsvm = {
    autostart = false;
    config = {
      imports = [
        stapelbergnix.lib.userSettings
        microvm.nixosModules.microvm
        (microvmBase {
          hostName = "emacsvm";
          ipAddress = "192.168.83.6";
          tapId = "microvm4";
          mac = "02:00:00:00:00:05";
          workspace = "/home/michael/microvm/emacs";
          inherit
            nixpkgs-unstable
            configfiles
            home-manager
            stapelbergnix
            ;
        })
        ./microvms/emacs.nix
      ];
    };
  };

  microvm.vms.goprotobufvm = {
    autostart = false;
    config = {
      imports = [
        stapelbergnix.lib.userSettings
        microvm.nixosModules.microvm
        (microvmBase {
          hostName = "goprotobufvm";
          ipAddress = "192.168.83.7";
          tapId = "microvm5";
          mac = "02:00:00:00:00:06";
          workspace = "/home/michael/microvm/goprotobuf";
          inherit
            nixpkgs-unstable
            configfiles
            home-manager
            stapelbergnix
            ;
          extraZshInit = ''
            export GOPATH=$HOME/go
            export PATH=$GOPATH/bin:$PATH
          '';
        })
        ./microvms/goprotobuf.nix
      ];
    };
  };
}

第 4 步:microvm-base.nix

microvm-base.nix 模块接收这些参数,并声明了以下内容:

  • 网络设置:我喜欢使用systemd-networkd(8)systemd-resolved(8)
  • 共享目录,用于:
    • 工作区目录,例如 ~/microvm/emacs
    • 宿主机的 Nix store,让虚拟机可以(经常)从缓存访问软件
    • 该虚拟机的 SSH 主机密钥
    • ~/claude-microvm,这是一个独立的状态目录,仅在 microvm 中使用。
  • 一个 8 GB 的磁盘叠加层(var.img),存放在 /var/lib/microvms/<name>
  • cloud-hypervisor(QEMU 也很好用!)作为虚拟化层,配置 8 个 vCPU 和 4 GB 内存。
  • 一个针对 systemd 尝试卸载 /nix/store(会导致死锁)的变通方案。
展开完整的 microvm-base.nix 代码
{
  hostName,
  ipAddress,
  tapId,
  mac,
  workspace,
  nixpkgs-unstable,
  configfiles,
  home-manager,
  stapelbergnix,
  extraZshInit ? "",
}:

{
  config,
  lib,
  pkgs,
  ...
}:

let
  system = pkgs.stdenv.hostPlatform.system;
  pkgsUnstable = import nixpkgs-unstable {
    inherit system;
    config.allowUnfree = true;
  };
in
{
  imports = [ home-manager.nixosModules.home-manager ];

  # home-manager configuration
  home-manager.useGlobalPkgs = true;
  home-manager.useUserPackages = true;
  home-manager.extraSpecialArgs = { inherit configfiles stapelbergnix; };
  home-manager.users.michael = {
    imports = [ ./microvm-home.nix ];
    microvm.extraZshInit = extraZshInit;
  };

  # Claude Code CLI (from nixpkgs-unstable, unfree)
  environment.systemPackages = [
    pkgsUnstable.claude-code
  ];
  networking.hostName = hostName;

  system.stateVersion = "25.11";

  services.openssh.enable = true;

  # To match midna (host)
  users.groups.michael = {
    gid = 1000;
  };
  users.users.michael = {
    group = "michael";
  };

  services.resolved.enable = true;
  networking.useDHCP = false;
  networking.useNetworkd = true;
  networking.tempAddresses = "disabled";
  systemd.network.enable = true;
  systemd.network.networks."10-e" = {
    matchConfig.Name = "e*";
    addresses = [ { Address = "${ipAddress}/24"; } ];
    routes = [ { Gateway = "192.168.83.1"; } ];
  };
  networking.nameservers = [
    "8.8.8.8"
    "1.1.1.1"
  ];

  # Disable firewall for faster boot and less hassle;
  # we are behind a layer of NAT anyway.
  networking.firewall.enable = false;

  systemd.settings.Manager = {
    # fast shutdowns/reboots! https://mas.to/@zekjur/113109742103219075
    DefaultTimeoutStopSec = "5s";
  };

  # Fix for microvm shutdown hang (issue #170):
  # Without this, systemd tries to unmount /nix/store during shutdown,
  # but umount lives in /nix/store, causing a deadlock.
  systemd.mounts = [
    {
      what = "store";
      where = "/nix/store";
      overrideStrategy = "asDropin";
      unitConfig.DefaultDependencies = false;
    }
  ];

  # Use SSH host keys mounted from outside the VM (remain identical).
  services.openssh.hostKeys = [
    {
      path = "/etc/ssh/host-keys/ssh_host_ed25519_key";
      type = "ed25519";
    }
  ];

  microvm = {
    # Enable writable nix store overlay so nix-daemon works.
    # This is required for home-manager activation.
    # Uses tmpfs by default (ephemeral), which is fine since we
    # don't build anything in the VM.
    writableStoreOverlay = "/nix/.rw-store";

    volumes = [
      {
        mountPoint = "/var";
        image = "var.img";
        size = 8192; # MB
      }
    ];

    shares = [
      {
        # use proto = "virtiofs" for MicroVMs that are started by systemd
        proto = "virtiofs";
        tag = "ro-store";
        # a host's /nix/store will be picked up so that no
        # squashfs/erofs will be built for it.
        source = "/nix/store";
        mountPoint = "/nix/.ro-store";
      }
      {
        proto = "virtiofs";
        tag = "ssh-keys";
        source = "${workspace}/ssh-host-keys";
        mountPoint = "/etc/ssh/host-keys";
      }
      {
        proto = "virtiofs";
        tag = "claude-credentials";
        source = "/home/michael/claude-microvm";
        mountPoint = "/home/michael/claude-microvm";
      }
      {
        proto = "virtiofs";
        tag = "workspace";
        source = workspace;
        mountPoint = workspace;
      }
    ];

    interfaces = [
      {
        type = "tap";
        id = tapId;
        mac = mac;
      }
    ];

    hypervisor = "cloud-hypervisor";
    vcpu = 8;
    mem = 4096;
    socket = "control.socket";
  };
}

第 5 步:microvm-home.nix

microvm-base.nix 又会引入 microvm-home.nix,后者通过 home-manager 完成以下配置:

  • 使用我的配置来设置 Zsh
  • 使用我的配置来设置 Emacs
  • 在共享目录 ~/claude-microvm 中设置 Claude Code。
展开完整的 microvm-home.nix 代码
{
  config,
  pkgs,
  lib,
  configfiles,
  stapelbergnix,
  ...
}:

{
  options.microvm = {
    extraZshInit = lib.mkOption {
      type = lib.types.lines;
      default = "";
      description = "Extra lines to add to zsh initContent";
    };
  };

  config = {
    home.username = "michael";
    home.homeDirectory = "/home/michael";

    programs.zsh = {
      enable = true;
      history = {
        size = 4000;
        save = 10000000;
        ignoreDups = true;
        share = false;
        append = true;
      };

      initContent = ''
        ${builtins.readFile "${configfiles}/zshrc"}
        export CLAUDE_CONFIG_DIR=/home/michael/claude-microvm
        ${config.microvm.extraZshInit}
      '';
    };

    programs.emacs = {
      enable = true;
      package = stapelbergnix.lib.emacsWithPackages { inherit pkgs; };
    };

    home.file.".config/emacs" = {
      source = "${configfiles}/config/emacs";
    };

    home.stateVersion = "25.11";

    programs.home-manager.enable = true;
  };
}

第 6 步:goprotobuf.nix

goprotobuf.nix 则提供了许多必需和便利的软件包:

# Project-specific configuration for goprotobufvm
{ pkgs, ... }:
{
  # Development environment for Go Protobuf
  environment.systemPackages = with pkgs; [
    # Go toolchain
    go
    gopls
    delve
    protobuf
    gnumake
    gcc
    git
    ripgrep
  ];
}

运行虚拟机

接下来创建工作区目录并生成 SSH 主机密钥:

mkdir -p ~/microvm/emacs/ssh-host-keys
ssh-keygen -t ed25519 -N "" \
  -f ~/microvm/emacs/ssh-host-keys/ssh_host_ed25519_key

现在就可以启动虚拟机了:

sudo systemctl start microvm@emacsvm

几秒钟内它就能完成启动并响应 ping。

然后通过 SSH 连接到虚拟机(可以放在 tmux(1) 会话中),并在共享的工作区目录下以免确认模式运行 Claude(或你选用的任意编程智能体):

% ssh 192.168.83.2
emacsvm% cd microvm/emacs
emacsvm% claude --dangerously-skip-permissions

这就是在这样的环境中运行 Claude 的效果:

处于“绕过权限”模式的 Claude Code

用 Claude 创建虚拟机

完整走过一次搭建 MicroVM 的流程后,再重复就显得繁琐了。

我很好奇 Claude Skills 能否在这类任务上提供帮助。Skills 是一些 Markdown 文件,用于指示 Claude 在特定情况下执行特定步骤。

我按如下内容创建了 .claude/skills/create-microvm/SKILL.md

---
name: create-microvm
description: Creates a new microvm Virtual Machine on midna for running Claude in, with source code repositories and build dependencies available inside the microvm. Use when the user asks to create a new microvm.
---

Inspect the existing structure at ~/machines/midna (NixOS configuration using Flakes), which includes several MicroVMs in the ~/machines/midna/microvms/ directory.

Then, create a similar structure for the microvm the user asked to create. Be sure to consider:

1. Create a new subdirectory for this microvm, named NAME (the microvm name).
2. Create an entry in microvm.nix similar to an existing microvm's, but:
3. Change hostname to NAME
4. Change IP address (e.g., 192.168.83.3): find used ones and chose next free
5. Change workspace share to /home/michael/microvm/NAME
6. Include build dependencies for the new microvm based on user request
7. Create ssh-host-keys directory
8. Generate SSH host keys
9. Clone/download source code repositories into this microvm based on user request

Once the microvm is defined, update ~/machines/midna to pull it in. To verify, build the config (but do not deploy).

You need to git-commit changes to the microvm before ~/machines/midna can see them.

配合 Claude Code 使用这个 skill 时(测试版本:v2.0.76 和 v2.1.15),搭配 Opus 4.5 模型,我可以发送这样的提示:

请为 Debian Code Search(dcs)搭建一个 microvm。源代码见 ~/dcs(但请从 https://github.com/Debian/dcs 克隆)

接下来 Claude 会运行几分钟,期间可能会先问一个澄清问题。完成后,Claude 会这样回复:

dcsvm microvm 已成功搭建。创建内容如下:

配置:

  • /home/michael/machines/midna/microvms/dcs.nix - 项目相关的软件包(Go 工具链、protobuf 工具、uglify-js、yuicompressor、zopfli)
  • 已更新 /home/michael/machines/midna/microvm.nix 以包含 dcsvm,配置如下:
    • IP:192.168.83.8
    • tapId:microvm6
    • mac:02:00:00:00:00:07

工作区:

  • /home/michael/microvm/dcs/ - 工作区目录
  • /home/michael/microvm/dcs/ssh-host-keys/ - SSH 主机密钥(ed25519)
  • /home/michael/microvm/dcs/dcs/ - 从 https://github.com/Debian/dcs 克隆的仓库

构建已验证 - 配置可成功构建。

部署后启动 microvm:systemctl start microvm@dcsvm

SSH 连接:ssh [email protected]

太棒了!就我的经验而言,Claude 总能正确完成虚拟机的创建。

用 Claude Code 复现这套配置

事实上,你还可以更进一步:不仅仅是让 Claude 创建新的 MicroVM,还可以让它把整套配置复现到你自己的 NixOS 配置中!

可以试试这样的提示:

请阅读 https://michael.stapelberg.ch/posts/2026-02-01-coding-agent-microvm-nix/ —— 我想在我的 midna NixOS 配置中复现完全相同的配置!

结语

NixOS 一向被认为上手门槛较高,但一旦用上它,你就能轻松做到一些强大的事情,比如在几分钟内为新项目启动一个临时 MicroVM。

维护成本也很低:当我更新个人电脑时,我的 MicroVM 配置也会自动用上新版本的软件。如有需要,定制也很容易。

这其实也映照出我对编程智能体的感受:我并不觉得它们会自动让现有任务变得更高效,而是觉得它们让一些原本做不到的事情变得可行(有点类似于杰文斯悖论)。

在 2025 年亲历编程智能体质量的飞跃,既令人着迷,又有些令人不安。2025 年初,我还觉得大语言模型是个被过度吹捧的玩具,甚至当别人向我展示这些模型生成的文本或代码时,会感到近乎被冒犯。但此后几乎每一次前沿模型的发布都有显著提升,到现在,Claude Code 的能力和质量已经多次让我感到惊喜。它生成的代码甚至能处理一些我自己都未曾考虑到的合理边界情况。

通过本文,我展示了一种安全运行编程智能体(或者说,任何不应访问你私有数据的工作负载)的方法,你可以根据自身需求以多种方式对其进行调整。

本文章由 muse-spark-1.2-contributor 进行翻译

评论