Coding Agent VMs on NixOS with microvm.nix

Michael Stapelberg

在 NixOS 上用 microvm.nix 建立 Coding Agent 專用虛擬機

原文由 Michael Stapelberg 發布,訂閱此部落格

我漸漸體會到,不論是要理解程式架構、診斷錯誤,還是開發概念驗證,coding agent 在處理程式碼的各種情境下都是相當有價值的工具。不過,依使用情境而定,要逐一審核 agent 想執行的每個指令,很快就會變得繁瑣又耗時。為了能不經審核就安心地執行 coding agent,我想要一套虛擬機(VM)方案:讓 agent 完全接觸不到我的私人檔案,即使被惡意軟體入侵也無關緊要——大不了把整台 VM 丟掉重來就好。

比起建立一台有狀態的 VM,需要時再重灌(光想就累!),我更偏好拋棄式的暫時性 VM:除了明確與主機共享的內容之外,任何東西都不會留在磁碟上。

microvm.nix 專案讓在 NixOS 上建立這類 VM 變得非常簡單,本文會分享我個人設定 VM 的方式。

延伸閱讀

如果你還沒聽過 NixOS,可以參考 NixOS 維基百科頁面nixos.org。我在 2025 年分享了為何轉向 Nix,也發表過幾篇關於 Nix 的部落格文章

想了解 AI agent 的威脅模型,可閱讀 Simon Willison 的〈The lethal trifecta for AI agents: private data, untrusted content, and external communication〉(2025 年 6 月)。本文處理威脅模型的方式,是直接把其中的「私人資料」這一項從等式中移除。

如果想全面了解沙盒(sandboxing)領域,可以參考 Luis Cardoso 的〈A field guide to sandboxes for AI〉(2026 年 1 月)。本文不會比較各種不同的解決方案,只會展示其中一種可行的做法。

最後,如果你沒興趣自己搭建或維運沙盒基礎設施,好消息是:沙盒現在是熱門話題,已經有許多商業服務應運而生來滿足這項需求。例如,我在 Go 社群認識的 David Crawshaw 和 Josh Bleecher Snyder 最近推出了 exe.dev,這是一個對 agent 友善的 VM 託管服務。另一個例子是推出 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 模組新增為一個新的 input(詳情請參考microvm.nix 文件),並在我的電腦(midna)的 NixOS 設定中啟用 microvm.nixosModules.host 模組。我還建立了一個新的 microvm.nix 檔案,用來宣告所有的 VM。我的 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(我想多了解它),另一台用於 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,讓 VM 能(通常)從快取取得軟體
    • 這台 VM 的 SSH host keys
    • ~/claude-microvm,這是一個獨立的狀態目錄,僅供 microvm 使用。
  • 一個 8 GB 的磁碟覆蓋層(var.img),儲存在 /var/lib/microvms/<name>
  • cloud-hypervisor(QEMU 也很好用!)作為 hypervisor,配備 8 個 vCPU 和 4 GB 記憶體。
  • 針對 systemd 嘗試卸載 /nix/store(會造成死結)的 workaround。
展開完整的 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
  ];
}

執行 VM

來建立工作區目錄並產生 SSH host key:

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

現在可以啟動 VM 了:

sudo systemctl start microvm@emacsvm

它會在幾秒內完成開機並開始回應 ping。

接著,透過 SSH 連進 VM(或許是在 tmux(1) 工作階段中),並在共享的工作區目錄中以略過權限提示的模式執行 Claude(或你選擇的任何 Coding Agent):

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

在這樣的環境中執行 Claude 的畫面如下:

處於「bypass permissions」模式的 Claude Code

用 Claude 建立 VM

走過一次設定 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(測試版本:v2.0.76 與 v2.1.15)搭配 Opus 4.5 模型使用這個 skill 時,我可以送出像這樣的提示:

please set up a microvm for Debian Code Search (dcs). see ~/dcs for the source code (but clone from https://github.com/Debian/dcs)

現在 Claude 會運算幾分鐘,中間可能會先問一個釐清問題。完成後,Claude 會回報:

dcsvm microvm 已設定完成。以下是建立的內容:

組態:

  • /home/michael/machines/midna/microvms/dcs.nix - 專案專屬套件(Go toolchain、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 host keys(ed25519)
  • /home/michael/microvm/dcs/dcs/ - 從 https://github.com/Debian/dcs 複製的儲存庫

已驗證建置——組態建置成功。

部署後啟動 microvm:systemctl start microvm@dcsvm

SSH 連入方式:ssh [email protected]

太棒了!就我的經驗,Claude 每次都能正確地建立 VM。

用 Claude Code 複製整套設定

其實,你還可以更進一步:不只是請 Claude 建立新的 MicroVM,還可以請它把整套設定完整複製到你的 NixOS 組態中!

可以試試像這樣的提示:

read https://michael.stapelberg.ch/posts/2026-02-01-coding-agent-microvm-nix/ — I want the exact same setup in my midna NixOS configuration please!

結論

NixOS 給人的印象是難以入門,但一旦開始使用,你就能做到像這樣強大的事:幾分鐘內為新專案啟動拋棄式的 MicroVM。

維護成本極低:當我更新個人電腦時,我的 MicroVM 設定也會跟著使用新版本的軟體。如果需要客製化,也很容易。

這其實也呼應了我對 Coding Agent 的體驗:我不覺得它們是自動讓既有工作變得更有效率,而是讓原本做不到的事情變得可能(有點類似 Jevons paradox)。

在 2025 年間親身經歷 Coding Agent 品質的提升,既令人著迷又有點嚇人。2025 年初,我還覺得 LLM 是被過度吹捧的玩具,甚至覺得別人拿這些模型產生的文字或程式碼給我看有點冒犯。但幾乎每一次前沿模型的更新都有顯著進步,到現在,Claude Code 的能力和品質已經多次讓我驚艷。它產出的程式碼甚至能處理到我自己都不會想到的合理邊界情況。

透過本文,我展示了一種安全執行 Coding Agent 的可行方式(其實任何不該存取你私人資料的工作負載都適用),你可以依需求做各種調整。

本文章由 muse-spark-1.2-contributor 進行翻譯

留言