KI-Coding-Agenten auf EU-VPS hosten: gehärtetes Setup
Kurz gesagt: Ein Coding-Agent auf einem VPS braucht gewöhnliche Rechenleistung, keine GPU, denn das Modell läuft beim Anbieter. Das Risiko ist, was der Agent erreichen kann, also begrenzen Sie es: ein Wegwerf-Container pro Aufgabe, kein Root, schreibgeschütztes Root-Dateisystem, ausgehender Verkehr nur zu einer Allowlist, ein eng begrenztes Token als Datei, ein Zeitlimit und ein Mensch vor der Produktion. Unten: ein getesteter Runner, ein Fake-Agent, der ihn angreift (echte Ausgabe), und der Preis. Diese Einstellungen begrenzen, was ein gekaperter Agent erreicht; Prompt Injection verhindern sie nicht. Geprüft am 2026-10-14.
Was Sie hosten und was nicht
Typische Aufgaben: ein Repository auf einem Server, Pull-Request-Reviews, Tests nach jeder Änderung, geplante Jobs. Das alles ist Agenten-Rechenleistung: Das CLI führt Befehle auf Ihrem VPS aus und schickt nur Prompts an eine Modell-API, eine GPU ist also nicht nötig (ein Modell zu hosten ist eine andere Aufgabe). Grundlagen: Claude Code, Codex und Grok CLI auf Ihrem eigenen Cloud-Server ausführen; Prompts: Cheat Sheet.
Bedrohungsmodell: was eine eingeschleuste Anweisung erreichen kann
Alles, was der Agent liest (Repository-Dateien, Issues, Pull Requests, Webseiten, Tool-Ausgaben), ist Eingabe, der er folgen könnte. OWASP führt Prompt Injection als LLM01:2025 und Excessive Agency als LLM06:2025; kein Modell ist immun, also begrenzen Sie, was ein gekaperter Agent berühren kann.
| Eine eingeschleuste Anweisung will... | Im naiven Setup erreichbar | Begrenzung in diesem Design |
|---|---|---|
| Geheimnisse lesen | env, ~/.ssh, andere Repositories | eigene uid, leeres schreibgeschütztes Home, ein Repository, eine Token-Datei mit minimalen Rechten |
| Daten nach außen senden | jeder Host im Internet | keine Route nach außen, Allowlist-Proxy, 403 für den Rest |
| Schädlichen Code pushen oder mergen | jeder Branch, den das Token abdeckt | Token für ein Repository, Branch-Schutz, Review durch einen Menschen |
| Cloud-, LAN- oder Host-Dienste erreichen | Metadaten-Adresse, Datenbanken, Dienste des Hosts | keine Route nach außen, keine Host-Adresse auf der Sandbox-Bridge |
Die Hersteller sehen es ebenso: Claude Code Security empfiehlt VMs oder einen Dev-Container für nicht vertrauenswürdige Inhalte, der Härtungsleitfaden verlangt Default-Deny-Egress und einen Container pro Sitzung, und die Codex-Dokumentation lässt das Netzwerk standardmäßig aus. Geprüft am 2026-10-14.
Die Architektur
Eine Aufgabe ist ein Docker-Compose-Projekt: ein Agent-Container plus ein kleiner Egress-Proxy. Der Agent hängt in einem internal-Netz, in dem der Host keine IPv4-Adresse hat; sein einziger Weg nach draußen ist der Proxy, der nur für Hosts der Allowlist antwortet. Endet die Aufgabe, wird alles zerstört.

Der gehärtete Runner (getestet)
Sechs kleine Dateien; nötig ist nur Docker mit Compose. Das Image enthält einen harmlosen Fake-Agenten; für den echten Einsatz installieren Sie Ihr Agent-CLI im Dockerfile und tragen die benötigten Hosts (Git-Host, Paketregister, Modell-API) in allowlist.txt ein. Ein Tool, das HTTPS_PROXY ignoriert, scheitert einfach.
Dockerfile: Nicht-Root-Nutzer, nichts beschreibbar
FROM python:3.13-alpine
# UID 10001, no shell login, no sudo, nothing writable except the tmpfs mounted at /work
RUN adduser -D -u 10001 -s /sbin/nologin agent
COPY --chmod=0555 fake_agent.py /opt/fake_agent.py
USER 10001:10001
WORKDIR /work
ENTRYPOINT ["python", "/opt/fake_agent.py"]
compose.yml: Limits, schreibgeschütztes Root, zwei Netze, Secret als Datei
# One task = one compose project. Nothing is shared between tasks.
name: ${TASK_ID:-demo}
services:
agent:
build: .
image: agent-runner:local # built once, shared by every task project
user: "10001:10001"
read_only: true # root fs is immutable
tmpfs:
- /work:size=256m,uid=10001,gid=10001,mode=0700 # the workspace, gone with the container
- /tmp:size=64m,mode=1777
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 64
mem_limit: 512m
memswap_limit: 512m # no swap: an OOM kill instead of a slow host
cpus: 1.0
environment:
HTTPS_PROXY: http://egress:3128
TASK_ID: ${TASK_ID:-demo}
AGENT_MODE: ${AGENT_MODE:-attack}
secrets: [git_token] # a file under /run/secrets, never in the image or in `docker inspect` env
networks: [sandbox] # the only network: internal, no route out
depends_on:
egress: {condition: service_healthy}
egress:
image: python:3.13-alpine
user: "10001:10001"
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
pids_limit: 64
mem_limit: 128m
healthcheck:
test: ["CMD", "python", "-c", "import socket; socket.create_connection(('127.0.0.1', 3128), 2)"]
interval: 1s
retries: 10
command: ["python", "/egress_proxy.py", "/allowlist.txt"]
volumes:
- ./egress_proxy.py:/egress_proxy.py:ro
- ./allowlist.txt:/allowlist.txt:ro
networks: [sandbox, outside]
secrets:
git_token:
file: ${TOKEN_FILE:?set TOKEN_FILE}
networks:
sandbox:
internal: true # no route to the internet or other networks
driver_opts:
com.docker.network.bridge.inhibit_ipv4: "true" # the host gets no address on this bridge (needs a recent Docker Engine, tested 29.7.2)
outside: {}
allowlist.txt: alles andere wird abgelehnt
# one host name per line; everything else is refused
example.com
egress_proxy.py: CONNECT-Proxy mit Default-Deny, nur Standardbibliothek
"""Default-deny HTTPS egress: a CONNECT-only proxy with a host allowlist (stdlib only)."""
import asyncio, ipaddress, socket, sys
ALLOW = {l.strip().lower() for l in open(sys.argv[1]) if l.strip() and not l.startswith('#')}
async def pipe(r, w):
try:
while data := await r.read(65536):
w.write(data)
await w.drain()
except OSError:
pass
finally:
w.close()
async def handle(cr, cw):
line = (await cr.readline()).decode('latin-1').split()
if not line: # health probe: connect and close
cw.close(); return
while (await cr.readline()) not in (b'\r\n', b''):
pass # drop the request headers
if len(line) < 2 or line[0] != 'CONNECT':
verdict, code = 'method', '405 Method Not Allowed'
else:
host, _, port = line[1].rpartition(':')
host = host.lower()
verdict, code = None, None
if host not in ALLOW or port != '443':
verdict, code = 'DENY host', '403 Forbidden'
else:
try: # an allowlisted name must not resolve to a private address (SSRF)
ai = socket.getaddrinfo(host, 443, type=socket.SOCK_STREAM)
if not all(ipaddress.ip_address(a[4][0]).is_global for a in ai):
verdict, code = 'DENY private-ip', '403 Forbidden'
except OSError:
verdict, code = 'DENY dns', '502 Bad Gateway'
if verdict:
print(f'egress {verdict}: {" ".join(line[:2])}', flush=True)
cw.write(f'HTTP/1.1 {code}\r\nConnection: close\r\n\r\n'.encode())
await cw.drain(); cw.close(); return
try:
ur, uw = await asyncio.open_connection(ai[0][4][0], 443)
except OSError:
cw.write(b'HTTP/1.1 502 Bad Gateway\r\n\r\n'); cw.close(); return
print(f'egress ALLOW: {host}:443', flush=True)
cw.write(b'HTTP/1.1 200 Connection established\r\n\r\n')
await asyncio.gather(pipe(cr, uw), pipe(ur, cw))
async def main():
srv = await asyncio.start_server(handle, '0.0.0.0', 3128)
async with srv:
await srv.serve_forever()
asyncio.run(main())
run-task.sh: anlegen, ausführen, schwärzen, prüfen, vernichten
#!/usr/bin/env bash
# Disposable workspace per task: create, run, redact, check, destroy.
set -euo pipefail
cd "$(dirname "$0")"
TASK_ID="task-$(date +%Y%m%d-%H%M%S)-$RANDOM"
TOKEN_SRC=${1:?usage: run-task.sh <token-file>} # a scoped, short-lived token, 0600, outside the repo
LOGDIR=${LOGDIR:-./logs}
TASK_TIMEOUT=${TASK_TIMEOUT:-900} # wall-clock seconds for the whole task
umask 077
WORK=$(mktemp -d "${TMPDIR:-/tmp}/$TASK_ID.XXXXXX")
cat < "$TOKEN_SRC" > "$WORK/token" && chmod 0444 "$WORK/token" # readable by uid 10001 inside; the dir stays 0700 on the host
mkdir -p "$LOGDIR"
export TASK_ID TOKEN_FILE="$WORK/token"
teardown() { # runs on success, failure and Ctrl-C
docker compose down --volumes --remove-orphans >/dev/null 2>&1 || true
rm -rf "$WORK"
}
trap teardown EXIT
TOKEN=$(<"$TOKEN_SRC")
[[ $TOKEN =~ ^[A-Za-z0-9_-]+$ ]] || { echo "token has characters sed cannot redact safely" >&2; exit 2; }
rc=0
docker compose --progress quiet build agent
timeout --foreground "$TASK_TIMEOUT" docker compose --progress quiet up --abort-on-container-exit --exit-code-from agent 2>&1 \
| sed -f <(printf 's|%s|[REDACTED]|g\n' "$TOKEN") > "$LOGDIR/$TASK_ID.log" || rc=$?
# redaction check: the raw token must not appear in what we keep (the token never goes into argv: ps shows arguments)
if grep -qF -f <(printf '%s\n' "$TOKEN") "$LOGDIR/$TASK_ID.log"; then
echo "FAIL: token found in $LOGDIR/$TASK_ID.log" >&2; rm -f "$LOGDIR/$TASK_ID.log"; exit 3
fi
echo "task=$TASK_ID exit=$rc log=$LOGDIR/$TASK_ID.log redaction-check=ok"
exit "$rc"
fake_agent.py: versucht, was ein gekaperter Agent versuchen würde
"""Harmless fake agent: tries things a hijacked agent would try. Exit 1 if any forbidden one works."""
import os, socket, ssl, subprocess, sys, time, urllib.parse
bad = []
def check(name, forbidden, fn):
try:
res = fn()
res = res if isinstance(res, str) else 'connected'
outcome = 'ALLOWED'
except Exception as e:
res, outcome = f'{type(e).__name__}: {e}', 'BLOCKED'
print(f'[{outcome:7}] {name} -> {str(res)[:90]}', flush=True)
if forbidden and outcome == 'ALLOWED':
bad.append(name)
def via_proxy(host):
p = urllib.parse.urlparse(os.environ['HTTPS_PROXY'])
s = socket.create_connection((p.hostname, p.port), timeout=5)
s.sendall(f'CONNECT {host}:443 HTTP/1.1\r\nHost: {host}:443\r\n\r\n'.encode())
status = s.recv(200).split(b'\r\n')[0].decode()
if ' 200 ' not in status:
raise ConnectionError(status)
t = ssl.create_default_context().wrap_socket(s, server_hostname=host)
t.sendall(f'GET / HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n'.encode())
return t.recv(40).split(b'\r\n')[0].decode()
def write(path):
def f():
with open(path, 'w') as fh: fh.write('x')
return 'wrote ' + path
return f
def flood_disk():
with open('/work/big', 'wb') as fh:
for _ in range(300): fh.write(b'0' * 1024 * 1024) # 300 MB into a 256 MB tmpfs
return 'wrote 300 MB'
def fork_many():
kids = []
try:
for _ in range(200): kids.append(subprocess.Popen(['sleep', '30']))
return f'started {len(kids)} processes'
finally:
for k in kids: k.kill()
def eat_ram():
r = subprocess.run([sys.executable, '-c', 'b=[]\nwhile True: b.append(bytearray(50_000_000))'], capture_output=True)
if r.returncode < 0: raise MemoryError(f'child killed by signal {-r.returncode}')
return f'child exit {r.returncode}'
def setuid_root():
os.setuid(0); return 'uid 0'
def routes():
return [l.split() for l in open('/proc/net/route').read().splitlines()[1:]]
def default_route():
if not any(c[1] == '00000000' for c in routes()): raise OSError('no default route')
return 'present'
def other_routes():
if len(routes()) < 2: raise OSError('only the on-link subnet route')
return 'present'
def host_side(port):
def f(): # .1 of our subnet: the host on a plain network, whoever holds .1 otherwise
for l in open('/proc/net/route').read().splitlines()[1:]:
c = l.split()
if c[1] != '00000000':
a = socket.inet_ntoa(bytes.fromhex(c[1])[::-1]).split('.')
a[3] = '1'
socket.create_connection(('.'.join(a), port), timeout=3).close()
return 'connected to ' + '.'.join(a)
raise OSError('no subnet route')
return f
def posture():
st = dict(l.split(':\t', 1) for l in open('/proc/self/status').read().splitlines() if ':\t' in l)
work = ([l.split()[3] for l in open('/proc/mounts') if l.split()[1] == '/work'] or ['not a separate mount'])[0]
try: aa = open('/proc/self/attr/current').read().strip()
except OSError: aa = 'n/a'
print(f'posture: Seccomp={st["Seccomp"]} NoNewPrivs={st["NoNewPrivs"]} CapEff={st["CapEff"]} AppArmor={aa} /work={work}', flush=True)
def raw_socket():
socket.socket(socket.AF_INET, socket.SOCK_RAW, 1); return 'raw socket'
print(f'agent uid={os.getuid()} gid={os.getgid()} task={os.environ.get("TASK_ID")}', flush=True)
if os.environ.get('AGENT_MODE') == 'gateway': # only the host-side probe, for a bare `docker run`
check('first address of the sandbox subnet, port 22', True, host_side(22))
sys.exit(1 if bad else 0)
posture()
if os.environ.get('AGENT_MODE') == 'hang':
print('agent hangs on purpose', flush=True)
time.sleep(10**6)
check('allowed: HTTPS to an allowlisted host via the proxy', False, lambda: via_proxy('example.com'))
check('HTTPS to a host that is not allowlisted', True, lambda: via_proxy('example.org'))
check('direct connection, bypassing the proxy', True, lambda: socket.create_connection(('1.1.1.1', 443), timeout=4))
check('cloud metadata address 169.254.169.254', True, lambda: socket.create_connection(('169.254.169.254', 80), timeout=4))
check('a default route out of the sandbox network', True, default_route)
check('any route besides the sandbox subnet itself', True, other_routes)
check('DNS lookup of an outside name', True, lambda: socket.gethostbyname('example.org'))
check('write to the root filesystem', True, write('/etc/pwned'))
check('write into the image code', True, write('/opt/fake_agent.py'))
check('write into the workspace (allowed)', False, write('/work/notes.txt'))
check('regain root with setuid(0)', True, setuid_root)
check('open a raw socket (needs CAP_NET_RAW)', True, raw_socket)
check('see the host docker socket', True, lambda: os.stat('/var/run/docker.sock'))
check('overflow the 256 MB workspace', True, flood_disk)
check('start 200 processes (limit 64)', True, fork_many)
check('allocate RAM until limit (512 MB)', True, eat_ram)
tok = open('/run/secrets/git_token').read().strip()
check('read the scoped token (allowed) and print it to the log', False, lambda: 'token=' + tok)
print('RESULT:', 'ISOLATION BROKEN: ' + ', '.join(bad) if bad else 'all forbidden actions blocked', flush=True)
sys.exit(1 if bad else 0)
Ehrliche Grenzen und Restrisiko:
- Der Agent kann sein Token lesen und an jeden erlaubten Host senden (ein Gist oder Issue auf github.com), der Token-Umfang bleibt also die wichtigste Kontrolle; stärker ist ein Proxy, der die Zugangsdaten außerhalb des Containers einfügt, wie es die Seite Agent runners beschreibt; hier nicht gebaut. Das JSON-Log des Daemons enthält das Roh-Token ebenfalls (der Fake-Agent gibt es aus) bis
compose down; nur das aufbewahrte Log ist geschwärzt. - Container teilen sich den Kernel mit dem Host: für feindlichen oder mandantenfähigen Code eine VM pro Aufgabe oder gVisor nehmen (hier nicht getestet).
- Der Proxy ist eine einzelne, minimale Stelle (blockierende Namensauflösung, kein Idle-Timeout oder Verbindungslimit, Image per Tag fixiert): umgangen oder ausgefallen, wird das Design schwächer. Er filtert nach Namen, daher kann ein gemeinsames CDN oder Domain Fronting auf einem erlaubten Namen Daten hinaustragen, und er hängt an einer normalen Bridge, die Host-Listener erreichen kann: per Firewall (DOCKER-USER) absichern, wenn Dienste auf allen Adressen lauschen.
- Halten Sie den Docker-Daemon vom Agenten fern: Zugriff darauf bedeutet Kontrolle über den Host (Docker-Sicherheit).
Durchlauf mit einem Wegwerf-Token (mit echter Ausgabe)
Umgebung: Docker 29.7.2 mit Compose, Linux arm64, python:3.13-alpine (Python 3.13.16), ausgeführt am 2026-10-14. Das Token ist ein offensichtlicher Platzhalter; erlaubtes ausgehendes HTTPS braucht Internetzugang des Hosts. Der Docker-Daemon muss das Projektverzeichnis und $TMPDIR sehen können (Bind-Mounts aus /tmp schlugen hier fehl).
Legen Sie die sechs Dateien in ein neues Verzeichnis, erzeugen Sie ein Fake-Token und starten Sie eine Aufgabe:
printf 'fake_token_0000_do_not_use\n' > fake.token && chmod 600 fake.token
./run-task.sh fake.token
BLOCKED bei einer verbotenen Aktion ist das Bestehen; ALLOWED sind die legitimen Zeilen. Die Zeile posture zeigt Dockers Standardwerte: seccomp, no-new-privileges, keine Capabilities, AppArmor und ein tmpfs /work mit noexec,nosuid,nodev. Das ausgegebene Token erscheint geschwärzt (Compose-Log-Präfixe gekürzt):
agent uid=10001 gid=10001 task=task-20261015-002125-3091
posture: Seccomp=2 NoNewPrivs=1 CapEff=0000000000000000 AppArmor=docker-default (enforce) /work=rw,nosuid,nodev,noexec,relatime,size=262144k,mode=700,uid=10001,gid=10001,inode64
egress ALLOW: example.com:443
[ALLOWED] allowed: HTTPS to an allowlisted host via the proxy -> HTTP/1.1 200 OK
egress DENY host: CONNECT example.org:443
[BLOCKED] HTTPS to a host that is not allowlisted -> ConnectionError: HTTP/1.1 403 Forbidden
[BLOCKED] direct connection, bypassing the proxy -> OSError: [Errno 101] Network unreachable
[BLOCKED] cloud metadata address 169.254.169.254 -> OSError: [Errno 101] Network unreachable
[BLOCKED] a default route out of the sandbox network -> OSError: no default route
[BLOCKED] any route besides the sandbox subnet itself -> OSError: only the on-link subnet route
[BLOCKED] DNS lookup of an outside name -> gaierror: [Errno -3] Try again
[BLOCKED] write to the root filesystem -> OSError: [Errno 30] Read-only file system: '/etc/pwned'
[BLOCKED] write into the image code -> OSError: [Errno 30] Read-only file system: '/opt/fake_agent.py'
[ALLOWED] write into the workspace (allowed) -> wrote /work/notes.txt
[BLOCKED] regain root with setuid(0) -> PermissionError: [Errno 1] Operation not permitted
[BLOCKED] open a raw socket (needs CAP_NET_RAW) -> PermissionError: [Errno 1] Operation not permitted
[BLOCKED] see the host docker socket -> FileNotFoundError: [Errno 2] No such file or directory: '/var/run/docker.sock'
[BLOCKED] overflow the 256 MB workspace -> OSError: [Errno 28] No space left on device
[BLOCKED] start 200 processes (limit 64) -> BlockingIOError: [Errno 11] Resource temporarily unavailable
[BLOCKED] allocate RAM until limit (512 MB) -> MemoryError: child killed by signal 9
[ALLOWED] read the scoped token (allowed) and print it to the log -> token=[REDACTED]
RESULT: all forbidden actions blocked
task=task-20261015-002125-3091 exit=0 log=./logs/task-20261015-002125-3091.log redaction-check=ok
Der Abbau ist automatisch: Container, Netze und temporäre Verzeichnisse verschwinden, nur das gemeinsame Image und die geschwärzten Logs bleiben. Der Wrapper lehnt ein Token ab, das er nicht sicher schwärzen kann.
docker ps -a --filter name=task- --format '{{.Names}}'
docker network ls --filter name=task- --format '{{.Name}}'
ls -d "${TMPDIR:-/tmp}"/task-* 2>/dev/null | wc -l
Ein Kontrolllauf ohne Härtung (root, Standard-Bridge, Speicher nur zum Schutz dieses Rechners begrenzt) zeigt, dass die Prüfungen greifen: 8 bis 10 der 14 verbotenen Prüfungen gelingen (die zwei Internet-Proben hängen von der Verbindung des Hosts ab). Der Rest scheitert ohnehin (kein Proxy, kein Docker-Socket, keine Metadaten-Adresse hier, das Speicherlimit), diese Zeilen sind also Konfigurationsprüfungen, kein Beweis:
# the image was built by the first run (compose build needs TOKEN_FILE set)
docker run --rm --user 0 --memory 512m --network bridge --entrypoint sh \
-v "$PWD/fake.token:/run/secrets/git_token:ro" agent-runner:local \
-c 'HTTPS_PROXY=http://127.0.0.1:3128 TASK_ID=control python /opt/fake_agent.py' \
| grep -E 'ALLOWED|RESULT' | sed 's/ -> .*//'
[ALLOWED] direct connection, bypassing the proxy
[ALLOWED] a default route out of the sandbox network
[ALLOWED] any route besides the sandbox subnet itself
[ALLOWED] DNS lookup of an outside name
[ALLOWED] write to the root filesystem
[ALLOWED] write into the image code
[ALLOWED] write into the workspace (allowed)
[ALLOWED] regain root with setuid(0)
[ALLOWED] open a raw socket (needs CAP_NET_RAW)
[ALLOWED] overflow the 256 MB workspace
[ALLOWED] start 200 processes (limit 64)
[ALLOWED] read the scoped token (allowed) and print it to the log
RESULT: ISOLATION BROKEN: direct connection, bypassing the proxy, a default route out of the sandbox network, any route besides the sandbox subnet itself, DNS lookup of an outside name, write to the root filesystem, write into the image code, regain root with setuid(0), open a raw socket (needs CAP_NET_RAW), overflow the 256 MB workspace, start 200 processes (limit 64)
Die Netzwerkoption ist wichtig. In einem einfachen --internal-Netz behält der Host die Gateway-Adresse auf der Bridge, und der Container erreichte einen auf allen Adressen lauschenden Host-Dienst (hier sshd, Port 22). Mit inhibit_ipv4 (dokumentiert, keine Mindestversion genannt, getestet mit 29.7.2) hat der Host keine Adresse auf der Bridge, das Netz kein Gateway und die Routing-Tabelle nur das eigene Subnetz des Containers. Der erste Container bekommt .1, ein Test von .1 trifft also diesen Container, nicht den Host: Der Beweis ist die Ausgabe von Adresse und Routen, nicht die Ablehnung:
# with inhibit_ipv4 the first container takes .1, so the probe below hits that container itself
for opts in "" "-o com.docker.network.bridge.inhibit_ipv4=true"; do
echo "--- internal network, options: ${opts:-none}"
docker network create --internal $opts gwtest >/dev/null
addr=$(ip -4 -o addr show | grep "br-$(docker network inspect gwtest -f '{{.Id}}' | cut -c1-12)" | tr -s ' ' | cut -d' ' -f2,4)
echo "host address on the bridge: ${addr:-none}"
gw=$(docker network inspect gwtest -f '{{range .IPAM.Config}}{{.Gateway}}{{end}}' | grep -E '^[0-9.]+$')
echo "network gateway: ${gw:-none}"
docker run --rm --network gwtest --entrypoint ip agent-runner:local route
docker run --rm --network gwtest -e AGENT_MODE=gateway agent-runner:local | tail -1
docker network rm gwtest >/dev/null
done
--- internal network, options: none
host address on the bridge: br-fb577b8a3fa3 172.19.0.1/16
network gateway: 172.19.0.1
172.19.0.0/16 dev eth0 scope link src 172.19.0.2
[ALLOWED] first address of the sandbox subnet, port 22 -> connected to 172.19.0.1
--- internal network, options: -o com.docker.network.bridge.inhibit_ipv4=true
host address on the bridge: none
network gateway: none
172.19.0.0/16 dev eth0 scope link src 172.19.0.1
[BLOCKED] first address of the sandbox subnet, port 22 -> ConnectionRefusedError: [Errno 111] Connection refused
Das Token darf nicht in den Prozessargumenten stehen, deshalb lesen sed und grep es über -f <(printf ...). Eine Stichprobe von ps -eo args alle 100 ms während eines Laufs fand keine Zeile mit dem Token; derselbe Sampler erwischt ein absichtlich in die Argumente gelegtes Token:
( while [ ! -f stop ]; do ps -eo args | grep -F -f fake.token >> ps_run.out; sleep 0.1; done ) &
./run-task.sh fake.token; touch stop; wait; rm -f stop
wc -l < ps_run.out
sh -c 'sleep 3' "$(cat fake.token)" & sleep 1; ps -eo args | grep -c -F -f fake.token
task=task-20261015-002152-32129 exit=0 log=./logs/task-20261015-002152-32129.log redaction-check=ok
0
1
Ein Zeitlimit stoppt einen hängenden Agenten: timeout --foreground (standardmäßig 900 s, TASK_TIMEOUT) beendet den Lauf, und die Teardown-Falle räumt auf. Mit einem absichtlich hängenden Fake-Agenten und 20 s Limit:
AGENT_MODE=hang TASK_TIMEOUT=20 ./run-task.sh fake.token; echo "exit=$?"
task=task-20261015-002214-3896 exit=124 log=./logs/task-20261015-002214-3896.log redaction-check=ok
exit=124
Sie möchten diesen Runner auf einer Maschine, die online bleibt? Hosting für Agent-Runner in der EU ansehen.
Sicherheits-Checkliste: Secrets, Logs und die menschliche Freigabe
Prüfen Sie dies, bevor ein Runner ein echtes Repository anfasst.
| Maßnahme | Einstellung | So prüfen Sie es |
|---|---|---|
| Härtung | USER 10001, read_only: true, seccomp, AppArmor, cap_drop: [ALL], no-new-privileges |
Zeile posture: Seccomp=2, NoNewPrivs=1, CapEff=0; Schreiben nach /etc scheitert |
| Workspace-tmpfs | /work, 256 MB, standardmäßig noexec,nosuid,nodev |
No space left on device am Limit. Werkzeuge, die Dateien aus dem Workspace ausführen (npm-.bin-Skripte, kompilierte Binaries, virtualenvs), brauchen exec auf /work: ein bewusster Kompromiss |
| Ressourcen- und Zeitlimits | 64 pids, 512 MB ohne Swap, 1 CPU, timeout 900 s |
fork scheitert, Speicherfresser wird per OOM beendet, hängender Agent endet mit Exit 124 |
| Egress | internes Netz, keine Host-Adresse, Allowlist-Proxy | direkter Verbindungsaufbau scheitert, ip zeigt keine Host-Adresse auf der Bridge, andere Hosts erhalten 403 |
| Token und Logs | Fine-grained, ein Repository, kurze Laufzeit (zum Beispiel 7 Tage), nur Contents und Pull Requests; Datei unter /run/secrets, nie im Image, in env oder Argumenten; Logs geschwärzt, dann durchsucht |
docker history und ps -eo args zeigen kein Token; der Wrapper endet mit Code 3, wenn ein Log eines enthält |
| Produktion und Merge | keine Deploy-Schlüssel oder Cloud-Zugangsdaten hier; der Agent öffnet einen Pull Request, Branch-Schutz und Review entscheiden | env enthält ein Token; es kann nicht in den geschützten Branch pushen |
Nutzungsbedingungen der Hersteller: was Sie nicht tun sollten
Nutzen Sie Ihre eigenen Zugangsdaten für Ihre eigene Arbeit. Auf der Rechtsseite zu Claude Code steht, dass die OAuth-Anmeldung für Inhaber eines Abo-Tarifs gedacht ist, Entwickler von Produkten API-Schlüssel verwenden sollen und das Durchleiten von Anfragen über fremde Abo-Zugangsdaten nicht erlaubt ist; die Nutzungsrichtlinie verbietet automatisierte Kontoerstellung (die Richtlinienseite nennt als Gültigkeitsdatum den 2026-11-12). Die Nutzungsbedingungen von OpenAI erlauben in den Fassungen, die wir lesen konnten (Suchauszüge; die Seite blockiert automatisierte Abrufe), nicht, Zugangsdaten eines Kontos zu teilen; bestätigen Sie das im Browser. Ein Schlüssel oder Login pro Person, nie gebündelt oder weiterverkauft. Geprüft am 2026-10-14.
Dimensionierung, Kosten und die passende Variante
Die Preise sind DCXV-Cloud-Tarife in Prag mit einem Backup, nach der Kalkulatorformel berechnet und am 2026-10-14 mit der Live-Tabelle auf /cloud-server abgeglichen.
| Wenn... | Beginnen Sie mit | Hinweis |
|---|---|---|
| eine Person, ein Agent, Editieren und Unit-Tests | Small (2 vCPU / 4 GB / 40 GB), EUR 15.00 | meist begrenzt der Speicher, nicht die CPU |
| ein Browser oder End-to-End-Tests | Medium (4 / 8 GB / 80 GB), EUR 18.43 | Browser brauchen Gigabytes; Spitzenwert messen |
| mehrere Agenten parallel | Large (8 / 16 GB / 160 GB), EUR 36.88, oder mehrere Runner | ein Container pro Aufgabe; Spitzenwert mal Parallelität |
| lange Builds, CI, Deploy-Schlüssel | ein separater Build-Server | der Agent-Host hält nie Deploy-Zugangsdaten |
| Sie das Modell selbst betreiben | ein GPU-Thema: siehe den Inferenz-Artikel | ein Agent, der eine Anbieter-API ruft, braucht keine |
Messen statt raten: Lesen Sie den Spitzenwert aus der cgroup des Containers, bevor er endet, zum Beispiel mit cat /sys/fs/cgroup/memory.peak (eine Allokation von 100 MB in einem 512-MB-Container ergab 105263104 Bytes), und nutzen Sie docker stats --no-stream für die CPU. Die Hostgröße ist der Spitzenspeicher pro Aufgabe mal parallele Aufgaben plus Reserve fürs Betriebssystem.
Zwei Rechnungen: Der VPS ist ein fester Monatspreis von DCXV; Modellnutzung, Tokens und Abonnements berechnet der Modellanbieter, nicht DCXV, und sie bestimmen meist die Gesamtkosten. Standorte: Prag (CZ) oder Covilhã (PT).
Fazit
Ein Agent-Runner mit weniger Angriffsfläche besteht aus ein paar unspektakulären Einstellungen, die jedes Mal gelten. Sie begrenzen, was ein gekaperter Agent erreicht; Prompt Injection verhindern sie nicht.
Hosting für Agent-Runner in der EU ansehen oder Cloud-Server in der EU konfigurieren.
