
This blog post is also available in a language that matches your settings. Click here to go to the translated version.
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.
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.
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.
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.
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"];
};
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"}}'';};
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
'';
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 gibt zwei Attribute zurück: Die VM Konfiguration sowie eine
SSH-Host-Konfiguration für den Host. Dies erlaubt es mir, die VM später mit
einem menschen-lesbaren Namen statt per IP zu betretenimports = [./../home/cli.nix]; hereingeladen.# 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
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.