Sicherheit
Coding-Agent im Container: zum Selberbauen
Wie wir Claude Code für KIberschutz in einen Container ohne freien Netzzugang sperren: internes Netz, Proxy mit Whitelist, ein Mount und ein Nachweis.
Für KIberschutz lassen wir Agenten fremden Code lesen, Scanner darüber laufen und Proof-of-Concepts ausführen. Der Code stammt aus Projekten, die wir nicht kennen, und der Agent liest dabei README-Dateien, Kommentare und Issues, die jemand anderes geschrieben hat. Das ist das Szenario der Clean-Repo-Falle. Bis Anfang Oktober lief das trotzdem direkt auf dem Host, neben Zugangsdaten und einem angemeldeten gh.
Die eingebaute Sandbox von Claude Code hilft hier nur zum Teil. Sie umschließt Shell-Befehle, aber nicht die Datei-Werkzeuge, MCP-Server und Hooks. Was sie abdeckt und was nicht, steht im Artikel Agenten-Sandbox: Was sie schützt, was nicht. Anthropic empfiehlt für unbeaufsichtigte Läufe deshalb, den ganzen Prozess in einen Container, eine VM oder die Sandbox-Runtime zu stecken. Das haben wir gebaut, und dieser Artikel zeigt, wie.
Gegen Prompt Injection schützt das nicht. Der Agent im Container bleibt manipulierbar, begrenzt wird nur, was eine Manipulation anrichten kann.
Mounts und Netz sind die Grenze
Im Container läuft Claude Code mit --dangerously-skip-permissions, also ohne jede Rückfrage. Das ist Absicht. Die Grenze zieht das, was der Container sehen und erreichen kann, und kein Berechtigungsdialog. Drei Regeln tragen das ganze Setup:
- Keine Geheimnisse drinnen. Kein Workspace, keine
.env, kein SSH-Schlüssel, keingh, keine Git-Identität. Nur ein eigenes Claude-Token. - Ein einziger Schreibweg nach draußen. Ein einziges Verzeichnis des Hosts ist eingehängt, dort landen die Ergebnisse.
- Kein Netz außer über einen Proxy. Der Container hat keine Route ins Internet. Der einzige Ausgang ist ein zweiter Container mit einer Liste erlaubter Hostnamen.
Gebaut und getestet ist das mit Podman auf einem Mac. Dort laufen Container ohnehin in einer Linux-VM, das ist eine zweite Schicht unter dem Container.
Zu jedem Schritt steht der Aufruf für Podman und für Docker. Die Docker-Aufrufe haben wir aus der Docker-Doku abgeleitet und nicht selbst ausgeführt. Sie können im Detail abweichen, deshalb gilt dort erst recht: den Nachweis aus Schritt 6 laufen lassen, bevor ein Agent hineinkommt.
1. Ein Netz ohne Ausgang
Zuerst kommt das Netz, ohne Verbindung nach außen und ohne DNS-Dienst.
Podman:
podman network create --internal --disable-dns \
--subnet 10.89.77.0/24 agent-int
Docker:
docker network create --internal \
--subnet 10.89.77.0/24 agent-int
--internal sperrt bei Podman wie bei Docker den Zugriff nach außen. --disable-dns haben wir nach einem Fehlversuch ergänzt: Mit eingeschaltetem DNS hat Podmans eigener Resolver dem Proxy, der in beiden Netzen hängt, externe Namen mit "gibt es nicht" beantwortet, und danach wurde kein anderer Resolver mehr gefragt. Ohne DNS im internen Netz kann der Analyse-Container gar keine Namen auflösen. Das stört nicht, denn die Namensauflösung übernimmt der Proxy. Deshalb bekommt der Proxy eine feste Adresse.
Docker kennt den Schalter --disable-dns nicht. Dort läuft in jedem eigenen Netz ein eingebauter DNS-Dienst. Seit Docker Engine 26.0 leitet er für Container, die nur an einem internen Netz hängen, keine Anfragen mehr an externe DNS-Server weiter. Davor ließen sich über DNS Daten aus einem internen Netz schleusen. Mit einer älteren Version ist das Setup also nicht dicht.
2. Der Proxy mit Whitelist
Als Proxy reicht tinyproxy. Die Konfiguration ist kurz:
Port 8888
Listen 0.0.0.0
LogLevel Connect
ConnectPort 443
Filter "/etc/tinyproxy/allow.txt"
FilterDefaultDeny Yes
FilterType ere
FilterDefaultDeny Yes dreht die Logik um: Erlaubt ist nur, was in der Filterdatei steht. ConnectPort 443 erlaubt Tunnel nur zu Port 443. Unverschlüsseltes HTTP an Hosts der Whitelist geht weiter durch, das braucht apt. Die Filterdatei enthält pro Zeile einen regulären Ausdruck:
^api\.anthropic\.com$
^github\.com$
^api\.github\.com$
^codeload\.github\.com$
^raw\.githubusercontent\.com$
Die Anker ^ und $ sind wichtig, sonst passt github.com auch auf github.com.boese.example. Unsere Liste ist länger: Dazu kommen die Schwachstellen-Datenbanken und die Paketquellen von Debian, npm, PyPI und Packagist, weil der Lauf sich die Laufzeit des Zielprojekts selbst nachinstallieren darf. Das -d im Startbefehl des Images unten hält tinyproxy im Vordergrund. Nur so bleibt der Container am Leben und das Protokoll landet in den Container-Logs.
Beide Dateien kommen in ein kleines Image. Die Datei funktioniert für Podman und Docker gleich:
FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
tinyproxy ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY tinyproxy.conf /etc/tinyproxy/tinyproxy.conf
COPY allow.txt /etc/tinyproxy/allow.txt
CMD ["tinyproxy", "-d"]
Als eigener Container hängt der Proxy dann in beiden Netzen, im normalen und im internen.
Podman:
podman build -t agent-proxy -f Containerfile.proxy .
podman run -d --name agent-proxy \
--network podman --network agent-int:ip=10.89.77.2 \
--dns 1.1.1.1 \
--security-opt no-new-privileges \
agent-proxy
Docker:
docker build -t agent-proxy -f Containerfile.proxy .
docker run -d --name agent-proxy \
--network bridge --network name=agent-int,ip=10.89.77.2 \
--dns 1.1.1.1 \
--security-opt no-new-privileges \
agent-proxy
Der Unterschied liegt in den Netznamen und in der Schreibweise der festen Adresse. Das Standardnetz heißt bei Podman podman, bei Docker bridge.
Vor jedem Lauf bauen und starten wir ihn neu. So wirkt eine geänderte Whitelist sicher, und das Protokoll beginnt leer.
3. Das Image
Das Image bringt Claude Code in einer festen Version mit und alles, was der Agent an Werkzeug braucht. Gekürzt auf das Wesentliche:
FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl ca-certificates jq ripgrep nodejs npm \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code@2.1.291
ENV DISABLE_UPDATES=1 \
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 \
DISABLE_TELEMETRY=1
WORKDIR /work
Podman:
podman build -t agent-image -f Containerfile.analyse .
Docker:
docker build -t agent-image -f Containerfile.analyse .
Die drei Umgebungsvariablen haben einen praktischen Grund. DISABLE_UPDATES verhindert, dass sich Claude Code selbst aktualisiert und damit die feste Version aushebelt. Die anderen beiden schalten Telemetrie und Fehlerberichte ab. Ohne sie versucht Claude Code, optionale Hosts zu erreichen. Der Proxy würde das ablehnen, und das Protokoll wäre voller Einträge, die mit dem Lauf nichts zu tun haben.
Alles, was der Agent an Vorlagen und Skripten braucht, kopieren wir ins Image, statt es einzuhängen. Jeder zusätzliche Mount wäre ein weiterer Host-Pfad, den der Container sieht.
4. Ein eigenes Token
Das ~/.claude des Hosts bekommt der Container nicht, denn darin liegt die Historie aller Projekte. Stattdessen erzeugt claude setup-token ein langlebiges Token nur für diesen Zweck. Wir legen es im macOS-Schlüsselbund ab und reichen es beim Start als Umgebungsvariable CLAUDE_CODE_OAUTH_TOKEN durch.
Lesen kann ein übernommener Agent dieses Token durchaus. Abziehen kann er es nur dorthin, wohin die Whitelist reicht. Das ist weniger beruhigend, als es klingt: Auf unserer Liste stehen mit GitHub und den Paket-Registries Dienste, bei denen jeder ein Konto anlegen kann. Liefert ein präpariertes Repository eigene Zugangsdaten mit, ist ein Abfluss darüber denkbar. Deshalb ist das Token vom Host-Login getrennt und lässt sich einzeln widerrufen.
5. Der Start
Den Auftrag schreibt der Host als Datei ins Run-Verzeichnis, dann startet er den Container:
export CLAUDE_CODE_OAUTH_TOKEN="$(security find-generic-password \
-s agent-claude-token -w)"
FLAGS=(
--network agent-int
-e https_proxy=http://10.89.77.2:8888
-e http_proxy=http://10.89.77.2:8888
-e CLAUDE_CODE_OAUTH_TOKEN
-e CLAUDE_CONFIG_DIR=/work/claude
-e IS_SANDBOX=1
-v "$PWD/runs/lauf-1":/work/run:rw
-v agent-repo:/work/repo
--cap-drop NET_ADMIN --cap-drop NET_RAW
--cap-drop SYS_ADMIN --cap-drop SYS_PTRACE
--security-opt no-new-privileges
--memory 6g --cpus 3 --pids-limit 512
)
AUFTRAG='mkdir -p /work/claude && claude -p --dangerously-skip-permissions < /work/run/auftrag.md'
Podman:
podman run --name agent-lauf "${FLAGS[@]}" agent-image sh -c "$AUFTRAG"
Docker:
docker run --name agent-lauf "${FLAGS[@]}" agent-image sh -c "$AUFTRAG"
Die Flags heißen bei beiden gleich. Sie stehen hier in einer Variablen, weil Schritt 6 sie noch einmal braucht.
-e CLAUDE_CODE_OAUTH_TOKENohne Wert übernimmt die Variable aus der Umgebung. Der Wert taucht so nicht in der Befehlszeile und nicht in der Shell-Historie auf.- Zwei Mounts, mehr nicht. Das Run-Verzeichnis für Auftrag und Ergebnisse, dazu ein flüchtiges Volume für den Klon des Zielprojekts. Der Klon liegt bewusst nicht in einem Host-Verzeichnis.
- Die Limits haben einen Zweck. Eine Archivbombe im Zielcode soll den Lauf treffen und nicht den Rechner. Ein Zeitlimit kommt bei uns aus dem Start-Skript, das den Container nach 90 Minuten entfernt.
- Nach dem Lauf wird alles verworfen: Container und Volume löschen, nichts wiederverwenden. Das geht mit
podman rm -f agent-laufundpodman volume rm agent-repo, bei Docker mit denselben Unterbefehlen.
Die Zeile IS_SANDBOX=1 weicht von der Empfehlung ab. Laut Doku lehnt Claude Code --dangerously-skip-permissions als Root ab, der Agent soll also als normaler Nutzer laufen. Unser Lauf ist Root im Container, weil er per apt nachinstallieren darf. Gebraucht hat das bisher noch kein Lauf. Ohne die Variable verweigert Claude Code in diesem Setup den Start, in der Doku der Umgebungsvariablen steht sie aber nicht. Der dokumentierte Weg ist ein eigener Nutzer im Image. Den haben wir nicht getestet, und bei einem eingehängten Verzeichnis kommen dann die Dateirechte als eigenes Thema dazu. Mit Docker unter Linux ist das mehr als eine Stilfrage: Läuft der Docker-Daemon als Root und ohne User-Namespaces, ist Root im Container auch Root auf dem Host.
6. Die Grenze nachweisen
Ein Container, von dem man annimmt, er sei dicht, ist wenig wert. Deshalb läuft bei uns vor und nach jedem Lauf ein Skript mit exakt denselben Flags wie der echte Lauf und prüft von innen, was fehlt. Die Flags kommen aus einer einzigen Datei, die Lauf und Prüfung beide einlesen. Sonst prüft man irgendwann etwas anderes, als man startet. Der Prüfcontainer bekommt dabei ein Platzhalter-Token und ein leeres Verzeichnis.
Podman:
podman run --rm "${FLAGS[@]}" agent-image sh /work/run/grenze.sh
Docker:
docker run --rm "${FLAGS[@]}" agent-image sh /work/run/grenze.sh
Die wichtigsten Prüfungen lassen sich mit curl und ein paar Dateitests nachbauen:
| Prüfung | Erwartung |
|---|---|
curl https://example.com über den Proxy | scheitert, der Proxy lehnt ab |
curl --noproxy '*' https://example.com | scheitert, kein DNS |
curl --noproxy '*' https://1.1.1.1/ | scheitert, keine Route |
.env, ~/.ssh, gh, Git-Identität | nicht vorhanden |
| Mounts außer Run-Verzeichnis und Volume | keine |
curl https://api.github.com/ über den Proxy | gelingt |
Die letzte Zeile ist eine Gegenprobe. Ohne sie besteht auch ein Container den Test, in dem curl schlicht kaputt ist.
Zwei Dinge holt der Host selbst ab, statt sie dem Container zu glauben: gleich nach dem Start die tatsächlichen Mounts und Capabilities des Containers, nach dem Lauf das Protokoll des Proxys. Der Analyse-Container schreibt seine eigenen Spuren nicht.
Podman:
# Solange der Container existiert
podman inspect agent-lauf > inspect.json
# Nach dem Lauf
podman logs agent-proxy > runs/lauf-1/proxy.log 2>&1
Docker:
# Solange der Container existiert
docker inspect agent-lauf > inspect.json
# Nach dem Lauf
docker logs agent-proxy > runs/lauf-1/proxy.log 2>&1
Die Ausgabe von inspect ist bei beiden JSON, die Feldnamen unterscheiden sich aber an einigen Stellen. Und sie enthält die Umgebungsvariablen des Containers, also auch das Token. Wer sie aufbewahrt, filtert vorher auf die Felder zu Mounts, Netzen, Capabilities und Limits und legt nur das ins Run-Verzeichnis.
So sieht das Proxy-Protokoll eines echten Laufs vom 07.10.2026 aus, verdichtet:
6x CONNECT api.anthropic.com:443
1x CONNECT github.com:443
1x CONNECT api.github.com:443
Proxying refused on filtered domain "example.com"
Proxying refused on filtered domain "api.osv.dev"
example.com und api.github.com stammen aus unserem Grenztest vor dem Start. api.osv.dev kam aus dem Lauf selbst: Er wollte eine Schwachstellen-Datenbank abfragen, die nicht auf der Liste stand. Harmlos, aber so sieht es aus, wenn die Whitelist greift.
Was der Container zurückgibt, bleibt fremd
Der Lauf schreibt Berichte, Fundlisten und Entwürfe ins Run-Verzeichnis. Diese Dateien sind von fremdem Code beeinflusst, und das ändert sich nicht dadurch, dass sie jetzt auf dem Host liegen. Zwei Regeln folgen daraus:
- Der Container schlägt vor, der Host schreibt. Einträge in unser öffentliches Ledger entstehen nicht im Container. Der Lauf liefert Vorschläge, ein Skript auf dem Host prüft sie und übernimmt sie erst nach der Freigabe durch einen Menschen.
- Zwischen Lesen und Außenwirkung steht ein Mensch. Kein Bericht, kein Advisory und kein Pull Request verlässt den Rechner ohne Review.
Dazu prüft das Start-Skript den Rückkanal selbst: Es durchsucht das Run-Verzeichnis nach dem Token und nach symbolischen Links, bevor ein Lauf als fertig gilt.
Was wir dabei gelernt haben
Hostnamen-Filter sind grob. Bei HTTPS sieht der Proxy nur, wohin die Verbindung geht, nicht was darin steht. Das ist dieselbe Grenze wie bei den eingebauten Sandboxes. Über github.com lässt sich grundsätzlich etwas hinausschaffen. Dem Lauf fehlt dafür ein Token zum Schreiben, ein präpariertes Repository könnte aber eines mitbringen.
Jeder Eintrag auf der Whitelist ist ein Preis. Die Paketquellen stehen bei uns offen, damit der Lauf PHP oder MariaDB nachinstallieren kann. Das vergrößert die Fläche, und wir nehmen es in Kauf, weil der Container außer dem Claude-Token nichts Wertvolles enthält.
Ein Plattenlimit fehlt. Speicher, CPU und Prozesszahl sind begrenzt, der Plattenplatz nicht. Eine Archivbombe kann das Volume füllen, bis die Platte der Podman-VM voll ist.
Ein Agent, der zum Ausbruch verleitet wird, ist noch nicht getestet. Der Nachweis zeigt, dass dem Container die Mittel fehlen. Wie ein Agent auf ein präpariertes Repository reagiert, haben wir noch nicht gemessen.
Für fremden Code nennt Anthropic eigentlich eine VM. Die Doku empfiehlt für nicht vertrauenswürdige Repositories eine eigene virtuelle Maschine oder eine Cloud-Sitzung. Unser Container teilt sich den Kernel mit der Podman-VM, und die hat üblicherweise das Home-Verzeichnis des Macs eingehängt. Ein Ausbruch aus dem Container in die VM wäre also folgenreich. Der Nachweis belegt die konfigurierte Grenze, nicht die Festigkeit des Kernels.
Das Abo-Kontingent ist geteilt. Ein Lauf im Container konkurriert mit der normalen Arbeit am selben Konto.
Anthropics Firewall-Skript passte nicht. Der Referenz-Devcontainer von Anthropic begrenzt das Netz mit einem iptables-Skript im Container selbst und braucht dafür die Capabilities NET_ADMIN und NET_RAW. Die wollten wir dem Analyse-Container nicht geben. Der Proxy im Nachbarcontainer kommt ohne aus.
Wann sich der Eigenbau lohnt
Für die meisten Teams ist der Eigenbau nicht der erste Schritt. Die eingebaute Sandbox kostet einen Befehl, und für den ganzen Agenten gibt es fertige Lösungen: den Devcontainer von Anthropic oder die Docker Sandboxes, die in KI-Tools sicher nutzen beschrieben sind.
Der Eigenbau lohnt sich, wenn ein Agent unbeaufsichtigt mit Code arbeitet, dem man nicht traut, und wenn man hinterher belegen muss, womit er gesprochen hat. Das Proxy-Protokoll und die gemessene Mount-Liste sind der Nachweis, den eine Revision sehen will. Bei uns waren das wenige Tage Arbeit. Den größeren Teil davon hat die Prüfung gekostet, dass der Container hält.
Quellen8
- Claude Code Docs: Choose a sandbox environment (abgerufen 08.10.2026)code.claude.com
- Claude Code Docs: Development containers (abgerufen 08.10.2026)code.claude.com
- Claude Code Docs: Environment variables (abgerufen 08.10.2026)code.claude.com
- Podman Docs: podman-network-create (abgerufen 08.10.2026)docs.podman.io
- Tinyproxy: Dokumentation (abgerufen 08.10.2026)tinyproxy.github.io
- Docker Docs: docker network create (abgerufen 08.10.2026)docs.docker.com
- Docker Docs: docker container run (abgerufen 08.10.2026)docs.docker.com
- Docker Engine 26.0 Release Notes (abgerufen 08.10.2026)docs.docker.com