在 NixOS 上使用 Microvm.nix 编码代理虚拟机
Coding Agent VMs on NixOS with Microvm.nix

原始链接: https://michael.stapelberg.ch/posts/2026-02-01-coding-agent-microvm-nix/

## 使用 NixOS 微型虚拟机进行安全编码代理沙箱化 本文详细介绍了一种使用 NixOS 上的短暂虚拟机 (VM) 安全运行编码代理的方法。这些代理是代码分析、调试和原型设计的重要工具。作者发现审查每个代理命令很繁琐,并寻求一种即使系统被攻破也不会影响主系统的解决方案。 该方法利用 `microvm.nix` 创建轻量级、一次性 VM,不保留数据,从而将代理与个人文件隔离。设置包括配置网络桥接、在 `flake.nix` 和 `microvm.nix` 文件中定义 VM,以及利用 `microvm-base.nix` 和 `microvm-home.nix` 中的基本配置来处理网络、共享目录和用户环境。 作者演示了为 Emacs 和 Go Protobuf 创建 VM,并展示了 Claude Skills 如何根据项目需求自动创建 VM。此设置允许轻松复制并使 VM 与主机系统的软件保持更新。虽然 NixOS 具有学习曲线,但作者强调了它在实现这种安全高效工作流程方面的强大功能,这反映了更广泛的趋势,即 AI 代理解锁了以前无法实现的功能。此外,还提到了 exe.dev 和 Fly.io Sprites 等商业沙箱解决方案作为替代方案。

这个Hacker News讨论围绕运行编码代理——辅助代码生成的AI工具——以及隔离和资源使用的挑战。一篇文章重点介绍了在NixOS上使用Microvm.nix为每个代理创建隔离虚拟机,但一位评论者质疑了这种复杂性和成本对于自动补全等功能而言是否合理。 另一位用户分享了在k3s上运行10,000个代理pod的经验,选择使用gVisor代替Microvm.nix,因为Microvm在大规模部署时内存开销更高。他们发现gVisor的系统调用拦截有效地阻止了代理(运行任意Python代码,因此被视为潜在恶意)访问敏感主机资源,例如`/proc`或Kubernetes元数据API,从而增强了Kubernetes环境内的安全性。本质上,这次对话探讨了基于规模和安全需求的不同隔离方法的权衡。
相关文章

原文
Table of contents

I have come to appreciate coding agents to be valuable tools for working with computer program code in any capacity, such as learning about any program’s architecture, diagnosing bugs or developing proofs of concept. Depending on the use-case, reviewing each command the agent wants to run can get tedious and time-consuming very quickly. To safely run a coding agent without review, I wanted a Virtual Machine (VM) solution where the agent has no access to my personal files and where it’s no big deal if the agent gets compromised by malware: I can just throw away the VM and start over.

Instead of setting up a stateful VM and re-installing it when needed (ugh!), I prefer the model of ephemeral VMs where nothing persists on disk, except for what is explicitly shared with the host.

The microvm.nix project makes it easy to create such VMs on NixOS, and this article shows you how I like to set up my VMs.

See also

If you haven’t heard of NixOS before, check out the NixOS Wikipedia page and nixos.org. I spoke about why I switched to Nix in 2025 and have published a few blog posts about Nix.

For understanding the threat model of AI agents, read Simon Willison’s “The lethal trifecta for AI agents: private data, untrusted content, and external communication” (June 2025). This article’s approach to working with the threat model is to remove the “private data” part from the equation.

If you want to learn about the whole field of sandboxing, check out Luis Cardoso’s “A field guide to sandboxes for AI” (Jan 2026). I will not be comparing different solutions in this article, I will just show you one possible path.

And lastly, maybe you’re not in the mood to build/run sandboxing infrastructure yourself. Good news: Sandboxing is a hot topic and there are many commercial offerings popping up that address this need. For example, David Crawshaw and Josh Bleecher Snyder (I know both from the Go community) recently launched exe.dev, an agent-friendly VM hosting service. Another example is Fly.io, who launched Sprites.

Setting up microvm.nix

Let’s jump right in! The next sections walk you through how I set up my config.

Step 1: network prep

First, I created a new microbr bridge which uses 192.168.33.1/24 as IP address range and NATs out of the eno1 network interface. All microvm* interfaces will be added to that bridge:

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";
};

Step 2: flake.nix

Then, I added the microvm module as a new input to my flake.nix (check out the microvm.nix documentation for details) and enabled the microvm.nixosModules.host module on the NixOS configuration for my PC (midna). I also created a new microvm.nix file, in which I declare all my VMs. Here’s what my flake.nix looks like:

{
  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
          ];
        };
      };
    };
}

Step 3: microvm.nix

The following microvm.nix declares two microvms, one for Emacs (about which I wanted to learn more) and one for Go Protobuf, a code base I am familiar with and can use to understand Claude’s capabilities:

{
  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
      ];
    };
  };
}

Step 4: microvm-base.nix

The microvm-base.nix module takes these parameters and declares:

  • Network settings: I like using systemd-networkd(8) and systemd-resolved(8) .
  • Shared directories for:
    • the workspace directory, e.g. ~/microvm/emacs
    • the host’s Nix store, so the VM can access software from cache (often)
    • this VM’s SSH host keys
    • ~/claude-microvm, which is a separate state directory, used only on the microvms.
  • an 8 GB disk overlay (var.img), stored in /var/lib/microvms/<name>
  • cloud-hypervisor (QEMU also works well!) as the hypervisor, with 8 vCPUs and 4 GB RAM.
  • A workaround for systemd trying to unmount /nix/store (which causes a deadlock).
Expand full microvm-base.nix code
{
  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";
  };
}

Step 5: microvm-home.nix

microvm-base.nix in turn pulls in microvm-home.nix, which sets up home-manager to:

  • Set up Zsh with my configuration
  • Set up Emacs with my configuration
  • Set up Claude Code in shared directory ~/claude-microvm.
Expand full microvm-home.nix code
{
  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;
  };
}

Step 6: goprotobuf.nix

The goprotobuf.nix makes available a bunch of required and convenient packages:

# 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
  ];
}

Running the VM

Let’s create the workspace directory and create an 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

Now we can start the VM:

sudo systemctl start microvm@emacsvm

It boots and responds to pings within a few seconds.

Then, SSH into the VM (perhaps in a tmux(1) session) and run Claude (or your Coding Agent of choice) without permission prompts in the shared workspace directory:

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

This is what running Claude in such a setup looks like:

Claude Code in “bypass permissions” mode

Creating VMs with Claude

After going through the process of setting up a MicroVM once, it becomes tedious.

I was curious if Claude Skills could help with a task like this. Skills are markdown files that instruct Claude to do certain steps in certain situations.

I created .claude/skills/create-microvm/SKILL.md as follows:

---
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.

When using this skill with Claude Code (tested version: v2.0.76 and v2.1.15), with the Opus 4.5 model, I can send a prompt like this:

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

Now Claude churns for a few minutes, possibly asking a clarification question before that. Afterwards, Claude reports back with:

The dcsvm microvm has been set up successfully. Here’s what was created:

Configuration:

  • /home/michael/machines/midna/microvms/dcs.nix - Project-specific packages (Go toolchain, protobuf tools, uglify-js, yuicompressor, zopfli)
  • Updated /home/michael/machines/midna/microvm.nix to include dcsvm with:
    • IP: 192.168.83.8
    • tapId: microvm6
    • mac: 02:00:00:00:00:07

Workspace:

  • /home/michael/microvm/dcs/ - Workspace directory
  • /home/michael/microvm/dcs/ssh-host-keys/ - SSH host keys (ed25519)
  • /home/michael/microvm/dcs/dcs/ - Cloned repository from https://github.com/Debian/dcs

Build verified - The configuration builds successfully.

To start the microvm after deploying: systemctl start microvm@dcsvm

To SSH into it: ssh [email protected]

Wonderful! In my experience, Claude always got the VM creation correct.

Replicating this setup with Claude Code

In fact, you can go one step further: Instead of just asking Claude to create new MicroVMs, you can also ask Claude to replicate this entire setup into your NixOS configuration!

Try a prompt like this:

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!

Conclusion

NixOS has a reputation of being hard to adopt, but once you are using NixOS, you can do powerful things like spinning up ephemeral MicroVMs for a new project within minutes.

The maintenance effort is minimal: When I update my personal PC, my MicroVM configurations start using the new software versions, too. Customization is easy if needed.

This actually mirrors my experience with Coding Agents: I don’t feel like they’re automatically making existing tasks more efficient, I feel that they make things possible that were previously out of reach (similar to Jevons paradox).

It was fascinating (and scary!) to experience the quality increase of Coding Agents during 2025. At the beginning of 2025 I thought that LLMs are an overhyped toy, and felt it was almost insulting when people showed me text or code produced by these models. But almost every new frontier model release got significantly better, and by now I have been positively surprised by Claude Code’s capabilities and quality many times. It has produced code that handles legitimate edge cases I would not have considered.

With this article, I showed one possible way to run Coding Agents safely (or any workload that shouldn’t access your private data, really) that you can adjust in many ways for your needs.

Did you like this post? Subscribe to this blog’s RSS feed to not miss any new posts!

I run a blog since 2005, spreading knowledge and experience for over 20 years! :)

If you want to support my work, you can buy me a coffee.

Thank you for your support! ❤️

联系我们 contact @ memedata.com