A cute little robot toy behind bars

Coding Agents einsperren mit microvm.nix

Sebastian Staffa

Alle LLM-getriebenen Coding-Agents wie Claude, Codex oder OpenCode haben ein Problem gemeinsam: Die zugrundeliegende Technik ist unberechenbar. Stößt z. B. ein Coding-Agent in einem Softwareprojekt auf eine Prompt-Injection, besteht eine gute Wahrscheinlichkeit, dass er sie auch ausführt. Im besten Fall löscht der Agent nur die eigenen Projektdateien, im schlimmsten Fall werden Zugangsdaten extrahiert oder eine Backdoor auf dem Host-System installiert.

Daher ist es wichtig, dass Agents so gut es nur irgend geht weggesperrt werden. Bestenfalls kann der Agent nur den Code des gerade bearbeiteten Projekts sehen und verfügt nur über die Zugangsdaten, die er für die Ausführung seiner Aufgabe wirklich benötigt. Die Realität sieht meist anders aus: Besonders Coding-Agents laufen meist direkt auf dem Rechner eines Entwicklers. Für den Agenten sind weitaus mehr Projekte einsehbar als nur das gerade Bearbeitete. Außerdem sind häufig Zugangsdaten verfügbar, die einen Vollzugriff auf die Cloud-Ressourcen einer oder mehrerer Applikationen ermöglichen.

Aber wie kann man einen Agenten so sicher einsperren, dass er keine Möglichkeit hat, aus seinem Gefängnis auszubrechen? Die Antwort bietet eine Technologie, die schon seit langem im Bereich der Security-Forschung eingesetzt wird: Virtuelle Maschinen (oder kurz VMs). In den letzten Monaten hat sich um diese Idee ein ganzes Ökosystem von Tools gebildet. In diesem Blog soll es um die Werkzeuge gehen, die ich für diese Aufgabe evaluiert habe, sowie um das Setup, mit dem ich am Ende gelandet bin.

Docker Sandbox

Mein erster Kontakt mit dem Thema der Agent-Isolierung entstand durch einen Social-Media-Post in einer meiner Timelines, in dem ein Nutzer über seine Erfahrungen mit Docker-Sandboxes gesprochen hat. Schnell hatte ich entschieden: “sowas brauche ich auch” und begonnen, einen genaueren Blick auf das Tool geworfen.

Docker selbst ist kein Security-Werkzeug. Docker-Container bieten keine strikte Isolation zwischen den Ressourcen im Container und denen im Host. Das wissen auch die Docker-Entwickler, weshalb die docker sandbox auf kleine VMs, sog. microVMs, setzt.

Docker Sandboxes run AI coding agents in isolated microVM sandboxes

— 

“Get started with Docker Sandboxes”, Docker Sandbox Dokumentation

Docker-Sandboxes funktionieren technisch also grundlegend anders als die bekannten Container. Trotzdem sind sie in der Lage, bereits bestehende Docker-Images auszuführen. Zudem bieten sie eine Menge sinnvolle Erweiterungen über das bloße, vereinfachte Bereitstellen einer schlanken VM hinaus: Docker-Sandboxes besitzen z. B. ein eigenes Konzept für das Teilen von Credentials mit den Agenten im Container sowie die Möglichkeit, granulare Netzwerkrichtlinien zu hinterlegen.

Aber wenn das Tooling so durchdacht ist, warum setzte ich es dann nicht ein? Die Antwort auf diese Frage findet sich im Setupguide des Tools (Archivlink). Für Linux heißt es dort:

 curl -fsSL https://get.docker.com | sudo REPO_ONLY=1 sh
 sudo apt-get install docker-sbx
 sbx login

Mein Problem liegt im letzten Schritt. Anders als im restlichen Docker-Ökosystem ist ein Login für das Tool zwingend erforderlich. Aus meiner Sicht gibt es hierfür keinen fachlichen Grund. Im FAQ (Archivlink) zu dem Tool werden, Stand heute, drei Gründe für diese Anforderung genannt:

  • Tie sandboxes to a real person.

  • Enable team features.

  • Authenticate against Docker infrastructure.

— 

Docker Sandbox FAQ

Als jemand, dessen Stack vollkommen auf quelloffene und möglichst freie Tools setzt, klingt das nach dem Beginn eines Vendor-Lock-Ins. Obwohl der Gedanke, Agents an eine natürliche Person knüpfen zu wollen, erstmal nobel klingt, lässt sich die hier geschaffene Lösung einfach umgehen, indem der Agent wieder lokal installiert wird. Team-Features sind für mich nicht relevant. Und bislang ist kein Login nötig, um “normale” Interaktionen mit der Docker-Infrastruktur durchzuführen, wie z. B. Images herunterzuladen.

microsandbox

Auf der Suche nach einer login-freien, einfach zu konfigurierenden Alternative bin ich auf microsandbox gestoßen. Mit microsandbox können Docker-Container (OCI-Images) oder Abbilder bestehender virtueller Maschinen ausgeführt werden. Wie bei Docker Sandbox auch gibt es bereits fertige Werkzeuge, um Verzeichnisse und Secrets mit der VM zu teilen. Die Konfiguration erfolgt entweder direkt per CLI oder programmatisch via einer der verfügbaren Bindings.

Für Nutzer, die kein NixOS verwenden, oder für Teams, die sich eine Agent-Konfiguration teilen wollen, ist das Werkzeug meine klare Empfehlung.

Die Art der Konfiguration einer microsandbox besitzt allerdings aus meiner Sicht drei Schwächen, die ich durch die Nutzung von NixOS beseitigen kann:

Wie bei allen Projekten, die eine Container-Umgebung zur Kapselung von Abhängigkeiten verwenden, können die verwendeten Container degradieren. Dies kann entweder dadurch geschehen, dass das lokal verwendete Tooling und das Tooling im Container auseinanderläuft, oder dadurch, dass die für den Bau des Containers notwendigen Upstream-Ressourcen (z. B. apt-Repositories) nicht mehr verfügbar sind.

Zweitens können, je nach Stack, die Abhängigkeiten für ein Projekt sehr groß werden. Immer eine Kopie auf dem Host (schließlich will ich die Software noch selbst ausführen und bauen können) und im Container vorzuhalten belegt unnötig viel Speicherplatz in Zeiten stetig steigender Kosten für SSDs. Da der /nix/store bereits schreibgeschützt ist, sollte es kein Problem sein, Binärabhängigkeiten sowohl im Host als auch in der VM zu verwenden, um kompakte Abbilder zu produzieren.

Drittens habe ich mein NixOS bereits mit einer Menge von Tools konfiguriert, von denen ich will, dass ein Agent immer Zugriff auf diese hat: Ripgrep, jq und natürlich meine bestehende OpenCode-Konfiguration mit allen Skills und Agents.

microvm.nix

Diese Probleme löst microvm.nix.

microvm.nix ermöglicht es, VMs mit einem Platzbedarf von nur wenigen Megabyte zu erstellen, indem der /nix/store des Hostsystems in der VM verwendet wird. VMs können entweder ad-hoc oder als systemd-Service im Host konfiguriert werden. Für den Hypervisor stehen acht verschiedene Adapter zur Auswahl. Da die gesamte VM mittels nix deklarativ konfiguriert wird, kann ich die bestehenden nix-Abhängigkeitsdeklarationen aus meinen Projekten wiederverwenden, um somit “Container Rot” zu vermeiden.

Für die Frage nach der Einrichtung verweise ich auf Michael Stapelbergs ausgezeichnetes Tutorial (Archiv-Link), zu dem ich allerdings einige Hinweise und Erweiterungen parat habe. Die gesamte Konfiguration findet sich am Ende dieses Kapitels.

WLAN Adapter

Sowohl das oben verlinkte Tutorial als auch die offizielle Dokumentation zum Netzwerksetup verwenden für das Erstellen des Bridgeadapters einen LAN-Port des Hostsystems.

Wird die Verbindung zum Internet durch den Host durch eine WLAN-Strecke hergestellt, ist diese Anleitung aber nicht eins-zu-eins anwendbar. Da die erstellte VM über eine eigene MAC-Adresse verfügt, laufen nun Pakete von zwei verschiedenen MAC-Adressen über den gleichen Adapter im Host. Das ist für WLAN-Verbindungen ein Problem, da Wi-Fi-Router in diesem Fall faules Spiel vermuten und die Verbindung kappen. Damit WLAN-Verbindungen über den Bridge-Adapter weitergeleitet werden können, ist ein zusätzliches NAT auf dem Host erforderlich, welches die Verbindung aus der VM terminiert.

Wie folgt meine finale Netzwerk-Konfiguration:

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

  systemd.network.networks."20-microbr" = {
    matchConfig.Name = "microbr";
    address = ["192.168.83.1/24"];
    networkConfig.ConfigureWithoutCarrier = true;
    linkConfig.RequiredForOnline = false;
  };

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

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

OpenCode Konfiguration

Michael Stapelberg arbeitet in seinem Blogpost mit Claude und erstellt für die Konfigurationsdateien ein geteiltes Verzeichnis für alle VMs, in dem auch die Provider-Credentials leben. Im Falle von OpenCode leben in diesem Verzeichnis auch alle Sessions, die wieder projektspezifische Informationen enthalten.

Ich habe mich daher entschieden, für jede VM ein eigenes Konfigurationsverzeichnis anzulegen. Auf diese Art verhindere ich das Durchsickern von Projektinformationen über die Session-Historie. Die Provider-Credentials linke ich per Hard-Link in das VM-Konfigurationsverzeichnis, sodass ich diese weiterhin zentral pflegen kann.

Zusätzlich wollte ich Claudes “Yolo”-Modus mit OpenCode nachbilden. Hierfür gibt es keine dedizierte Option. Allerdings kann man per Env-Variable die OpenCode-Berechtigungen überschreiben.

home.sessionVariables = { OPENCODE_CONFIG_CONTENT = ''{"permission":{"*":"allow","external_directory":"allow"}}'';};

nix-shell übergeben

In den meisten meiner Projekte gibt es eine eigene shell.nix-Datei mit den Abhängigkeiten des jeweiligen Projektes. Bislang mussten die Abhängigkeiten der Shell nach Betreten der VM heruntergeladen werden. Je nach Projekt kann das ein paar Sekunden bis Minuten in Anspruch nehmen. Gleichzeitig sind Abhängigkeiten der Projekte bereits auf meinem Host-System vorhanden, sodas ich sie mit der VM teilen könnte. Um dieses Verhalten zu implementieren, und so Zeit und Speicherplatz zu sparen, übergebe ich (optional) die nix-shell des Projektes in den VM-Builder:

{
    shellNix ? null
    # ... other params ...
}:
let
    shellDrv =
      if shellNix != null
      then import shellNix {}
      else null;
in
    # ... inside vm definitions
    system.extraDependencies =
      pkgs.lib.optional (shellDrv != null) shellDrv;

extraDependencies fügt die Abhängigkeiten in die VM ein, ohne sie dem Nutzer verfügbar zu machen. Darum kümmert sich weiterhin die nix-shell. Auf diese Art funktionieren auch bestehende Shell-Hooks, die z. B. Environment-Variablen setzen, ohne dass die Abhängigkeiten heruntergeladen werden müssen.

Als weitere Vereinfachung habe ich ein kleines Skript in die zsh-init der VM eingefügt, das das Arbeitsverzeichnis auf das Projektverzeichnis setzt und, falls vorhanden, direkt in die nix-shell springt.

programs.zsh.initContent = ''
  if [[ -t 1 ]]; then
    cd "${workspace}" 2>/dev/null
    ${pkgs.lib.optionalString (shellNix != null) ''
    if [[ -z "''${IN_NIX_SHELL:-}" ]]; then
      exec nix-shell "${shellNix}"
    fi
  ''}
  fi
'';

Finale Konfiguration

Wie Michael Stapelberg auch habe ich meine Konfiguration in zwei Teile aufgespalten: mkVm.nix erstellt eine einzelne VM, während microvm.nix die notwendige Systemkonfiguration sowie die Liste aller VMs definiert.

mkVm.nix orientert sich sehr an der oben genannten Vorlage, mit zwei Unterscheidungen:

# mkVm.nix
{
  home-manager,
  pkgs,
  hostUserName,
  openCodeConfigStubMountSrcBasePath,
  ...
}:
{
  projectName,
  ip,
  mac,
  workspace,
  shellNix ? null,
  vcpu ? 8,
  mem ? 4096,
}: let
  vmName = "vm-${projectName}";
  hostname = "ag-${projectName}";
  tapId = vmName;
  openCodeConfigStubMountSrcPath = "${openCodeConfigStubMountSrcBasePath}/${projectName}";
  shellDrv =
    if shellNix != null
    then import shellNix {}
    else null;
in {
  inherit vmName ip;
  sshConfig = ''
    Host ${vmName}
      HostName ${ip}
      # We disable strict host checking because the host
      # key regenerate on every vm start and
      # pregenerating + mounting them is a pain in the but
      # if we ever switch machines
      StrictHostKeyChecking no
      UserKnownHostsFile /dev/null
  '';
  vm = {
    # do not start any of the vms on host boot
    autostart = false;
    config = {
      microvm = {
        inherit vcpu mem;

        # Enable writable nix store overlay so nix-daemon works.
        # This is required for home-manager activation.
        writableStoreOverlay = "/nix/.rw-store";

        volumes = [
          {
            mountPoint = "/var";
            image = "var.img";
            size = 4096; # MB
          }
          {
            mountPoint = "/nix/.rw-store";
            image = "rw-store.img";
            size = 8192; # MB
          }
        ];

        shares = [
          {
            source = "/nix/store";
            mountPoint = "/nix/.ro-store";
            tag = "ro-store";
            proto = "virtiofs";
          }
          {
            proto = "virtiofs";
            tag = "opencode-auth";
            source = "${openCodeConfigStubMountSrcPath}/config";
            mountPoint = "/home/${hostUserName}/.local/share/opencode";
          }
          {
            proto = "virtiofs";
            tag = "workspace";
            source = workspace;
            mountPoint = workspace;
          }
        ];

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

      #-----------------------
      # system networking
      networking.hostName = hostname;

      systemd.network.enable = true;
      systemd.network.networks."20-lan" = {
        matchConfig.Type = "ether";
        networkConfig = {
          Address = ["${ip}/24"];
          Gateway = "192.168.83.1"; # the host's microbr address
          DNS = ["192.168.83.1"]; # or e.g. 1.1.1.1
          DHCP = "no";
        };
      };

      networking.nameservers = [
        "8.8.8.8"
        "1.1.1.1"
      ];

      networking.firewall.enable = false;

      #---------------------------
      # ssh config
      services.openssh = {
        enable = true;
        settings = {
          PasswordAuthentication = false;
          KbdInteractiveAuthentication = false;
        };
      };

      #-----------------------
      # home manager and home config
      imports = [
        (import "${home-manager}/nixos")
      ];

      home-manager.useGlobalPkgs = true;
      home-manager.useUserPackages = true;

      users.users.${hostUserName} = {
        isNormalUser = true;
        extraGroups = ["wheel"];
        openssh.authorizedKeys.keyFiles = [
          /home/${hostUserName}/.ssh/id_ed25519.pub
        ];

        # this declares zsh as the default shell for this user
        # which allows it to launch immediately when logging in via ssh
        shell = pkgs.zsh;
        # zsh is configured via the included cli module.
        # without this flag, nix complains that zsh is not configured
        # and may not work
        ignoreShellProgramCheck = true;
      };

      home-manager.users.${hostUserName} = {
        home.stateVersion = "26.05";
        # import cli tooling that is also present on my host machine
        imports = [
          ./../home/cli.nix
        ];

        # enable yolo mode inside the vm
        home.sessionVariables = { OPENCODE_CONFIG_CONTENT = ''{"permission":{"*":"allow","external_directory":"allow"}}'';};

        programs.zsh.initContent = ''
          if [[ -t 1 ]]; then
            cd "${workspace}" 2>/dev/null
            ${pkgs.lib.optionalString (shellNix != null) ''
            if [[ -z "''${IN_NIX_SHELL:-}" ]]; then
              exec nix-shell "${shellNix}"
            fi
          ''}
          fi
        '';
      };
      #--------------------------
      #system tweaks

      # Force every store path the workspace's shell.nix needs into this VM's
      # system closure (the toplevel derivation's "extraDependencies"). Since
      # the host shares /nix/store with the VM as a read-only virtiofs mount,
      # those paths are then already present the first time the shell is
      # entered - no fetching or building in the VM.
      system.extraDependencies =
        pkgs.lib.optional (shellDrv != null) shellDrv;

      system.stateVersion = "26.05";
      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;
        }
      ];

      # precreate the path where the opencode config is mounted.
      # without this, the paths leading up to .../share/opencode
      # would be owned by root as they are created when mounting the
      # config dir
      systemd.tmpfiles.rules = [
        "d /home/${hostUserName}                             0755 ${hostUserName} users -"
        "d /home/${hostUserName}/.local                      0755 ${hostUserName} users -"
        "d /home/${hostUserName}/.local/share                0755 ${hostUserName} users -"
        "d /home/${hostUserName}/.local/share/opencode/repos 0755 ${hostUserName} users -"
      ];
    };
  };
}

Meine microvm.nix enthält die oben beschriebene Netwerkkonfiguration für das Hostsystem sowie die Definition der einzelnen VMs:

# microvm.nix
{
  microvm,
  home-manager,
  pkgs,
  hostUserName,
  openCodeConfigStubMountSrcBasePath,
  ...
}: let
  mkVm = import ./mkVm.nix {
    inherit home-manager pkgs hostUserName openCodeConfigStubMountSrcBasePath;
  };

  vmSpecs = [
    {
      projectName = "project-a";
      ip = "192.168.83.2";
      mac = "02:00:00:00:00:01";
      workspace = "/path/to/project/a/";
      shellNix = "/path/to/project/a/shell.nix";
    }
    {
      projectName = "project-b";
      ip = "192.168.83.3";
      mac = "02:00:00:00:00:02";
      workspace = "/path/to/project/b";
    }
    #...
  ];

  builtVms = map mkVm vmSpecs;

  vmAttrs = pkgs.lib.listToAttrs (map (v: pkgs.lib.nameValuePair v.vmName v.vm) builtVms);

  sshExtraConfig = pkgs.lib.concatStringsSep "\n" (map (v: v.sshConfig) builtVms);
in {
  imports = [microvm.nixosModules.host];
  microvm.vms = vmAttrs;

  #----------------------------
  # Host options: network

  # This option enables systemd to manage
  # networking. We use NetworkManager
  # to manage most of the connections,
  # but to define them declaratively we need
  # to enable systemd network managing as well.
  systemd.network.enable = true;

  # By default, systemd waits for all configured
  # networks to come online through the
  # `systemd-networkd-wait-online.service`.
  # All microvms do not automatically start on boot,
  # so the networks do not come online and the wait-online
  # services fails.
  # We can disable waiting for singular interfaces
  # through the use of `linkConfig.RequiredForOnline = false`
  # (see below), but as all other networks are
  # managed through NetworkManager. This leaves
  # systemd with an empty set of networks
  # to wait for - which causes the wait-online service
  # to fail with a timeout when switching to
  # new configurations.
  # Should we ever configure additional networks on the host
  # using system, this option should be re-enabled.
  systemd.network.wait-online.enable = false;

  # the following options are propagated to the host
  # system to provide the needed adapters for
  # the network bridge to work
  systemd.network.netdevs."20-microbr".netdevConfig = {
    Kind = "bridge";
    Name = "microbr";
  };

  systemd.network.networks."20-microbr" = {
    matchConfig.Name = "microbr";
    address = ["192.168.83.1/24"];
    networkConfig.ConfigureWithoutCarrier = true;
    # this option is only here to futureproof the config.
    # see comment on systemd.network.wait-online.enable above
    linkConfig.RequiredForOnline = false;
  };

  systemd.network.networks."21-microvm-tap" = {
    matchConfig.Name = "vm-*";
    networkConfig.Bridge = "microbr";
    # this option is only here to futureproof the config.
    # see comment on systemd.network.wait-online.enable above
    linkConfig.RequiredForOnline = false;
  };

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

  #----------------------------
  # Host options: ssh
  programs.ssh = {
    extraConfig = sshExtraConfig;
  };
}

Zum Starten und Verbinden in eine VM reicht dann

systemctl start microvm@vm-project-a.service
ssh vm-project-a

Zukünftige Verbesserungen

In Zukunft gibt es zwei Verbesserungen, die ich noch an meinem Setup vornehmen möchte. Beide betreffen das Problem der Übertragbarkeit zwischen Systemen: Zum einen sind die VMs abhängig von den manuell erstellten Konfigurationsverzeichnissen, in denen u. a. die LLM-Credentials vorhalte. Das zentrale Problem ist hier, dass die LLM-Provider-Credentials nicht im nix-store existieren (und dies auch nicht sollen). Das zweite Problem betrifft das Networking, in dessen Konfiguration aktuell meine Adapter-Namen hartkodiert sind.

Perspektivisch ließen sich beide Probleme lösen, indem der microvm.nix VM-Start-Service in ein eigenes Skript gewrapped wird, das die notwendigen Adapter sowie Verzeichnisse ad-hoc erstellt.