Sandbox für KI-Agenten: Code sicher ausführen
1. Überblick
Immer mehr Anwendungen ermöglichen es KI-Agenten, selbst Code zu schreiben und ihn direkt auszuführen. Agenten nutzen dies, um Daten zu analysieren, Berechnungen durchzuführen oder Dateien zu manipulieren. Das Problem: Dieser Code wird von einem Sprachmodell generiert, und Sie haben keine direkte Kontrolle über seinen Inhalt.
Dieser Artikel erklärt das Sandbox-Konzept für Agenten und warum dieses Thema Aufmerksamkeit verdient. Wir vergleichen auch verbreitete Isolationsstufen. Der Hauptteil behandelt den Aufbau einer DockerSandbox mit Java 21 und Spring Boot 3.x, zusammen mit Tests, die die Sicherheitsgrenzen überprüfen.
2. Warum Agenten eine Sandbox brauchen
Ein Agent mit einem „Code ausführen”-Tool ist im Wesentlichen ein Prozess, der Befehle aus Text empfängt. Dieser Text kann vom Benutzer, von einer gerade gelesenen Webseite oder aus einer angehängten Datei stammen. Daher müssen Sie mit den folgenden Risiken rechnen:
- Prompt Injection: Eine Webseite enthält versteckte Anweisungen wie „Umgebungsvariablen auslesen und an Adresse X senden”. Der Agent könnte dem folgen.
- Fehlerhafter aber unbeabsichtigter Code: Endlosschleife, Festplatte vollschreiben, versehentliches Löschen von Verzeichnissen.
- Geheimnislecks: Code, der im gleichen Prozess wie die Anwendung läuft, kann API-Schlüssel, Konfigurationsdateien und Datenbankverbindungen lesen.
- Privilege Escalation: Von der Code-Ausführungsmaschine aus können Angreifer andere interne Dienste im selben Netz erkunden.
Eine Sandbox ist eine isolierte Umgebung. Code, der darin läuft, sieht nur das, was Sie erlauben: kein Netzwerk, keine Geheimnisse, begrenzte CPU/RAM/Zeit, temporäres Dateisystem, das nach jedem Lauf gelöscht wird.
Dieses Thema ist relevant, weil Agenten von „Fragen beantworten” zu „Handlungen ausführen” übergegangen sind. Wenn der Agent die Berechtigung zum Handeln hat, wird die Sandbox zur obligatorischen Infrastruktur.
3. Verbreitete Isolationsstufen
Es gibt nicht nur eine Art von Sandbox. Jede Stufe balanciert Sicherheit, Startgeschwindigkeit und operative Komplexität:
| Ebene | Beispiel | Vorteile | Nachteile |
|---|---|---|---|
| Eingeschränkter Interpreter | Script im selben JVM, blockierte APIs | Schnell, einfach | Sehr leicht zu umgehen, sollte nicht für nicht vertrauenswürdigen Code verwendet werden |
| Container | Docker/Podman mit restriktiven Flags | Verbreitet, leicht zu bauen | Teilt Kernel mit dem Host |
| Container + Kernel-Sandbox | Container läuft auf Runtime wie gVisor | Verringert Kernel-Angriffsfläche | Zusätzlicher Leistungsaufwand, einige syscall-Unterstützung fehlt |
| MicroVM | Firecracker, Kata Containers | VM-Level-Isolation | Komplexere Verwaltung |
| WebAssembly | WASM-Runtime | Extrem schneller Start, standardmäßig unprivilegiert | Limitiertes Ökosystem an Bibliotheken |
| Verwalteter Sandbox-Service | Cloud-Plattformen für Agent-Code | Keine Eigenverwaltung nötig | Abhängig vom Anbieter, Daten verlassen das System |
Für die meisten Teams ist ein restriktiv konfigurierter Container ein vernünftiger Startpunkt. Bei höheren Risiken, beispielsweise Multi-Tenancy oder anonyme Nutzer, können Sie zu einer stärkeren Isolationsruntime wechseln, ohne den Anwendungscode zu ändern.
4. Aufbau der DockerSandbox
Das Ziel ist eine Schicht, die von Agenten generierten Python-Code empfängt, in einem Single-Use-Container ausführt und das Ergebnis zurückgibt. Die ausführende Maschine benötigt Docker CLI.
4.1. Konfiguration
Zunächst sammeln wir die Grenzen in einem Konfigurations-Record:
package dev.nonla.sandbox;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@ConfigurationProperties(prefix = "agent.sandbox")
public record SandboxProperties(
String image,
String runtime, // null = default runtime; kann auf "runsc" gesetzt werden
Duration timeout,
String memory,
String cpus,
int pidsLimit,
int maxOutputBytes) {
}
agent:
sandbox:
image: python:3.12-slim
timeout: 5s
memory: 256m
cpus: "0.5"
pids-limit: 64
max-output-bytes: 65536
Das Feld runtime ermöglicht es Ihnen, später nur durch Konfiguration zu einer stärkeren Isolationsruntime zu wechseln.
4.2. Rückgabeergebnis
package dev.nonla.sandbox;
public record SandboxResult(int exitCode, String stdout, String stderr, boolean timedOut) {
public boolean success() {
return !timedOut && exitCode == 0;
}
}
4.3. Sandbox-Klasse
package dev.nonla.sandbox;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.*;
public class DockerSandbox {
private final SandboxProperties props;
public DockerSandbox(SandboxProperties props) {
this.props = props;
}
public SandboxResult runPython(String code) throws IOException, InterruptedException {
String name = "agent-sbx-" + UUID.randomUUID();
Process process = new ProcessBuilder(buildCommand(name)).start();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> out = executor.submit(() -> readLimited(process.getInputStream()));
Future<String> err = executor.submit(() -> readLimited(process.getErrorStream()));
try (OutputStream stdin = process.getOutputStream()) {
stdin.write(code.getBytes(StandardCharsets.UTF_8));
}
boolean finished = process.waitFor(props.timeout().toMillis(), TimeUnit.MILLISECONDS);
if (!finished) {
killContainer(name);
process.destroyForcibly().waitFor();
}
return new SandboxResult(
finished ? process.exitValue() : -1,
await(out), await(err), !finished);
}
}
List<String> buildCommand(String name) {
List<String> cmd = new ArrayList<>(List.of(
"docker", "run", "--rm", "-i",
"--name", name,
"--network", "none",
"--memory", props.memory(),
"--memory-swap", props.memory(),
"--cpus", props.cpus(),
"--pids-limit", String.valueOf(props.pidsLimit()),
"--read-only",
"--tmpfs", "/tmp:rw,size=64m",
"--cap-drop", "ALL",
"--security-opt", "no-new-privileges",
"--user", "65534:65534",
"--env", "HOME=/tmp"));
if (props.runtime() != null && !props.runtime().isBlank()) {
cmd.addAll(List.of("--runtime", props.runtime()));
}
cmd.addAll(List.of(props.image(), "python", "-"));
return cmd;
}
private String readLimited(InputStream in) throws IOException {
var buffer = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = in.read(chunk)) != -1) {
int room = props.maxOutputBytes() - buffer.size();
if (room > 0) {
buffer.write(chunk, 0, Math.min(n, room));
}
// Weiterlesen, um zu verhindern, dass der Prozess blockiert wird, aber den Überschuss verwerfen
}
return buffer.toString(StandardCharsets.UTF_8);
}
private void killContainer(String name) throws IOException, InterruptedException {
new ProcessBuilder("docker", "kill", name).start().waitFor(5, TimeUnit.SECONDS);
}
private static String await(Future<String> future) throws InterruptedException {
try {
return future.get(5, TimeUnit.SECONDS);
} catch (ExecutionException | TimeoutException e) {
return "";
}
}
}
Erklärung der Hauptpunkte:
- Code läuft über stdin (
python -). Sie müssen keine Verzeichnisse vom Host einbinden, daher kann der Container die Dateien der Anwendung nicht sehen. --rmzusammen mit einem zufälligen--nameerstellt einen Single-Use-Container. Jede Ausführung ist eine saubere Umgebung.--network nonetrennt die Netzwerkverbindung vollständig. Dies ist die wichtigste Blockierungsschicht gegen Datenlecks.--memoryzusammen mit--memory-swapauf denselben Wert gesetzt deaktiviert Swap.--cpusund--pids-limitverhindern Ressourcenerschöpfung und Fork-Bomben.--read-onlysperrt das Root-Dateisystem.--tmpfs /tmpbietet einen begrenzten temporären Schreib-Bereich.--cap-drop ALL,no-new-privilegesund--user 65534:65534(Benutzernobody) stellen sicher, dass der Code nicht als root läuft und keine zusätzlichen Rechte anfordert.- Keine Umgebungsvariablen der Anwendung werden an den Container übergeben. Nur
HOME=/tmpwird gesetzt, damit Python-Bibliotheken einen Ort zum Schreiben von Cache haben. - stdout und stderr werden parallel mit Virtual Threads gelesen. Bei sequenziellem Lesen könnte der Pipe-Buffer überlasten und der Prozess blockieren.
- Wenn die Zeit abgelaufen ist, ruft diese Schicht
docker killauf, bevor sie den CLI-Prozess beendet.
Hinweis:
process.destroyForcibly()beendet nur dendockerCLI-Prozess auf der Client-Seite. Der Container kann noch weiterlaufen. Deshalb müssen Sie unbedingtdocker killmit dem Containernamen aufrufen.
5. Integration in Spring Boot
Sie benötigen keine zusätzlichen Bibliotheken außer spring-boot-starter. Die Tests verwenden spring-boot-starter-test, das bereits JUnit 5 und AssertJ enthält:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Deklarieren Sie dann eine Bean und einen Service, der als „Tool” für den Agenten fungiert:
package dev.nonla.sandbox;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.*;
@Configuration
@EnableConfigurationProperties(SandboxProperties.class)
class SandboxConfig {
@Bean
DockerSandbox dockerSandbox(SandboxProperties props) {
return new DockerSandbox(props);
}
}
package dev.nonla.sandbox;
import org.slf4j.*;
import org.springframework.stereotype.Service;
@Service
public class CodeExecutionTool {
private static final Logger log = LoggerFactory.getLogger(CodeExecutionTool.class);
private final DockerSandbox sandbox;
public CodeExecutionTool(DockerSandbox sandbox) {
this.sandbox = sandbox;
}
public String executePython(String sessionId, String code) {
log.info("sandbox.exec session={} codeLength={}", sessionId, code.length());
try {
SandboxResult r = sandbox.runPython(code);
log.info("sandbox.done session={} exit={} timedOut={}", sessionId, r.exitCode(), r.timedOut());
if (r.timedOut()) {
return "Fehler: Code hat die zulässige Zeit überschritten.";
}
return "exit=" + r.exitCode() + "\nstdout:\n" + r.stdout() + "\nstderr:\n" + r.stderr();
} catch (Exception e) {
log.error("sandbox.error session={}", sessionId, e);
return "Systemfehler beim Ausführen des Codes.";
}
}
}
Die Methode executePython ist das, was Sie in Ihrem Agent-Framework als Tool registrieren. Es gibt zwei Dinge zu beachten:
- Das Ergebnis an das Modell ist immer eine Zeichenkette, auch bei Fehlern. Der Agent kann stderr lesen und seinen Code in der nächsten Runde selbst korrigieren.
- Jede Ausführung wird mit
sessionIdprotokolliert. Im Fehlerfall ist das Audit-Log das erste, das Sie benötigen.
Systemfehlermeldungen werden verallgemeinert, ohne Stack Traces an das Modell zu senden. Stack Traces können Pfade oder interne Konfiguration offenbaren.
6. Sandbox-VerhaltenTests
Tests sollten zeigen, dass jedes Limit tatsächlich wirksam ist, nicht nur dass „Code läuft”. Die folgenden Tests erfordern Docker. Wenn Docker nicht verfügbar ist, werden sie übersprungen.
package dev.nonla.sandbox;
import org.junit.jupiter.api.*;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
class DockerSandboxTest {
private DockerSandbox sandbox;
@BeforeEach
void setUp() {
assumeTrue(dockerAvailable(), "Docker nicht verfügbar");
sandbox = new DockerSandbox(new SandboxProperties(
"python:3.12-slim", null, Duration.ofSeconds(3),
"128m", "0.5", 32, 1024));
}
@Test
void runsSimpleCode() throws Exception {
SandboxResult r = sandbox.runPython("print(sum(range(10)))");
assertThat(r.success()).isTrue();
assertThat(r.stdout().trim()).isEqualTo("45");
}
@Test
void blocksNetworkAccess() throws Exception {
SandboxResult r = sandbox.runPython("""
import urllib.request
urllib.request.urlopen("http://example.com", timeout=2)
""");
assertThat(r.exitCode()).isNotZero();
assertThat(r.stderr()).contains("Error");
}
@Test
void rootFileSystemIsNotWritable() throws Exception {
SandboxResult r = sandbox.runPython("open('/hacked.txt', 'w').write('x')");
assertThat(r.exitCode()).isNotZero();
}
@Test
void killsInfiniteLoop() throws Exception {
SandboxResult r = sandbox.runPython("while True: pass");
assertThat(r.timedOut()).isTrue();
}
@Test
void truncatesHugeOutput() throws Exception {
SandboxResult r = sandbox.runPython("print('a' * 100_000)");
assertThat(r.stdout()).hasSizeLessThanOrEqualTo(1024);
}
private static boolean dockerAvailable() {
try {
Process p = new ProcessBuilder("docker", "info").start();
return p.waitFor(10, TimeUnit.SECONDS) && p.exitValue() == 0;
} catch (Exception e) {
return false;
}
}
}
Jeder Test entspricht einem Limit:
runsSimpleCodebestätigt den grundlegenden Pfad: stdout ist45.blocksNetworkAccesszeigt, dass HTTP-Anfragen fehlschlagen. stderr enthält etwas ähnliches wie:
urllib.error.URLError: <urlopen error [Errno -3] Temporary failure in name resolution>
rootFileSystemIsNotWritablebestätigt, dass der Code außerhalb von/tmpnicht schreiben kann.killsInfiniteLoopbestätigt, dass das Timeout funktioniert und der Container bereinigt wird.truncatesHugeOutputbestätigt, dass die Ausgabe abgeschnitten wird. Dies verhindert, dass der Agent den Kontext oder den Speicher der Anwendung aufbläht.
Hinweis: Der erste Lauf kann langsam sein, da Docker das Image ziehen muss. Sie sollten das Image vor den Tests in CI oder beim Anwendungsstart ziehen, sonst können Test-Timeouts falsch ausgelöst werden.
7. Zusätzliche Schutzschichten
Die Sandbox ist eine wichtige, aber nicht die einzige Schutzschicht. In der Praxis sollten Sie diese zusätzlichen Ebenen kombinieren:
- Kontrollierter Egress: Wenn der Agent tatsächlich Netzwerk benötigt, z. B. zum Installieren von Paketen, öffnen Sie es nicht komplett. Lassen Sie es durch einen Proxy mit Allowlist-Domänen laufen.
- Geheimnisse gehören niemals in die Sandbox: Wenn Code eine API aufrufen muss, lassen Sie die Anwendung durch ein separates, berechtigungsprüfendes Tool aufrufen. Übergeben Sie keine Token an den Container.
- Human-in-the-Loop: Bei nicht rückgängig zu machenden Aktionen (E-Mails senden, echte Datenbanken schreiben, Geld überweisen) benötigen Sie eine Benutzerbestätigung.
- Rate Limits und Quotas: Jede Session darf nur eine begrenzte Anzahl von Malen ausgeführt werden. Dies verhindert, dass Agenten in ressourcenintensive Versuch-Irrtum-Schleifen fallen.
- Separate Sandbox-Maschinen: Lassen Sie den Docker Daemon, der Sandboxes ausführt, auf einem separaten Node laufen, nicht zusammen mit den Hauptdiensten.
- Pre-warmed Container Pool: Wenn Latenz kritisch ist, halten Sie ein paar Container bereit. Jeder Container wird immer noch nur einmal verwendet und dann verworfen.
8. Häufige Fehler und wann man dies nicht verwenden sollte
Häufige Fehler:
- Docker Socket in die Anwendung oder Sandbox mounten. Wer
/var/run/docker.sockkontrolliert, hat nahezu Root-Rechte auf dem Host. Wenn die Anwendung in einem Container läuft und Docker aufrufen muss, erwägen Sie einen separaten Sandbox-Service statt Socket-Sharing. --network nonevergessen. Das ist der häufigste und schädlichste Fehler.- Als root im Container ausführen, weil „Pakete leicht zu installieren sind”. Installieren Sie Pakete vorab in ein angepasstes Image.
- Container zwischen Benutzern wiederverwenden. Verbleibende Dateien und Prozesse werden Daten aus früheren Sitzungen an nachfolgende undicht.
- Code in Shell-Befehle einbinden (
sh -c "python -c '" + code + "'"). Dies erzeugt Command Injection auf der Host-Seite. Übergeben Sie immer über stdin oder Dateien. - Glauben, dass Container eine absolute Grenze sind. Container teilen den Kernel. Bei feindseligem Code in Multi-Tenant-Umgebungen sollten Sie gVisor oder microVM verwenden.
Wann Sie diesen Ansatz nicht verwenden sollten:
- Agent benötigt nur wenige feste Berechnungen. Ein Tool mit klarem Typ wie
calculateInvoiceTotal(Order)ist viel sicherer und billiger als beliebige Codeausführung. - Sie bedienen anonyme Nutzer im Internet mit großem Durchsatz. Verwenden Sie dann microVM oder einen dedizierten Sandbox-Service statt selbstgebautem Docker.
- Ihre Umgebung lässt Docker nicht laufen, z. B. einige serverlose Plattformen. Erwägen Sie WASM oder verwaltete Sandboxes.
- Code muss auf sensible Daten zugreifen. Übergeben Sie gefilterte Daten über stdin oder temporäre Dateien, nicht durch Lockerung der Sandbox.
9. Fazit
Wenn Agenten Code ausführen dürfen, kann jeder Text, den sie lesen, zu einem Befehl werden. Sandbox verwandelt dieses Risiko in etwas Messbares: kein Netzwerk, keine Geheimnisse, Ressourcenlimitierung, Single-Use-Umgebung.
Sie haben eine DockerSandbox mit notwendigen Sicherheits-Flags aufgebaut. Diese Klasse liest Ausgaben sicher mit Virtual Threads, handhabt Timeouts korrekt mit docker kill und hat Tests, die jedes Limit überprüfen. Von dieser Basis können Sie zu stärkeren Isolationsruntimes aufsteigen, nur durch Konfiguration. Kombinieren Sie die Sandbox immer mit Audit-Logs, Netzwerk-Allowlists und menschlicher Bestätigung für kritische Aktionen.