Agenty AI do kodu na VPS w UE: utwardzona konfiguracja

Agenty AI do kodu na VPS w UE: utwardzona konfiguracja

Agenty AI do kodu na VPS w UE: utwardzona konfiguracja

Krótko: agent do kodu na VPS potrzebuje zwykłej mocy obliczeniowej, a nie GPU, bo model działa u dostawcy. Ryzykiem jest to, do czego agent może sięgnąć, więc to ogranicz: jednorazowy kontener na zadanie, bez roota, główny system plików tylko do odczytu, ruch wychodzący tylko do hostów z listy dozwolonych, jeden wąsko ograniczony token jako plik, limit czasu i człowiek przed produkcją. Poniżej: przetestowany runner, fałszywy agent, który go atakuje (prawdziwy wynik), i cena. Te ustawienia ograniczają to, do czego dotrze przejęty agent; nie powstrzymują prompt injection. Sprawdzono 2026-10-14.

Co hostujesz, a czego nie

Typowe zadania: repozytorium na serwerze, przegląd pull requestów, testy po każdej zmianie, zadania cykliczne. Wszystko to jest mocą obliczeniową agenta: CLI wykonuje polecenia na Twoim VPS i tylko wysyła prompty do API modelu, więc GPU nie jest potrzebne (hostowanie modelu to inna praca). Podstawy: Uruchom Claude Code, Codex i Grok CLI na własnym serwerze cloud; prompty: ściąga.

Model zagrożeń: do czego sięgnie wstrzyknięta instrukcja

Wszystko, co czyta agent (pliki repozytorium, issues, pull requesty, strony WWW, wyjście narzędzi), to dane wejściowe, którym może się podporządkować. OWASP wymienia prompt injection jako LLM01:2025 i nadmierną sprawczość jako LLM06:2025; żaden model nie jest odporny, więc ogranicz to, czego może dotknąć przejęty agent.

Wstrzyknięta instrukcja chce... W naiwnej konfiguracji dosięgnie Ograniczenie w tej architekturze
Odczytać sekrety env, ~/.ssh, inne repozytoria osobny uid, pusty katalog domowy tylko do odczytu, jedno repozytorium, jeden plik tokenu o ograniczonych uprawnieniach
Wysłać dane na zewnątrz dowolny host w internecie brak trasy na zewnątrz, proxy z listą dozwolonych, 403 dla reszty
Wypchnąć lub scalić złośliwy kod każda gałąź objęta tokenem token dla jednego repozytorium, ochrona gałęzi, przegląd przez człowieka
Dotrzeć do usług chmurowych, LAN lub hosta adres metadanych, bazy danych, usługi hosta brak trasy na zewnątrz, brak adresu hosta na moście piaskownicy

Dostawcy mówią to samo: bezpieczeństwo Claude Code zaleca VM lub kontener deweloperski dla niezaufanych treści, przewodnik utwardzania wymaga domyślnej blokady ruchu wychodzącego i kontenera na sesję, a dokumentacja Codex domyślnie wyłącza sieć. Sprawdzono 2026-10-14.

Architektura

Jedno zadanie to jeden projekt Docker Compose: kontener agenta plus mały serwer proxy. Agent znajduje się w sieci internal, w której host nie ma adresu IPv4, więc jego jedyna droga na zewnątrz to proxy, które obsługuje tylko hosty z listy dozwolonych. Po zakończeniu zadania wszystko jest niszczone.

Runner agenta do kodu: niezaufane dane, kontener piaskownicy, proxy z listą dozwolonych, zatwierdzenie człowieka

Utwardzony runner (przetestowany)

Sześć małych plików; potrzebny jest tylko Docker z Compose. Obraz zawiera nieszkodliwego fałszywego agenta; w realnym użyciu zainstaluj w Dockerfile CLI swojego agenta i dopisz do allowlist.txt potrzebne hosty (host git, rejestr pakietów, API modelu). Narzędzie, które ignoruje HTTPS_PROXY, zostanie zablokowane (fail closed), zamiast obchodzić proxy.

Dockerfile: użytkownik bez uprawnień roota, nic do zapisu

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: limity, główny system plików tylko do odczytu, dwie sieci, sekret jako plik

# 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: wszystko inne jest odrzucane

# one host name per line; everything else is refused
example.com

egress_proxy.py: proxy CONNECT z domyślną odmową, tylko biblioteka standardowa

"""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: utwórz, uruchom, zamaskuj, sprawdź, zniszcz

#!/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: próbuje tego, czego próbowałby przejęty agent

"""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)

Uczciwe ograniczenia i ryzyko resztkowe:

  • Agent może odczytać swój token i wysłać go na dowolny dozwolony host (gist lub issue na github.com), więc zakres tokenu pozostaje główną kontrolą; mocniejszy jest serwer proxy wstrzykujący poświadczenia poza kontenerem, jak opisuje strona Agent runners; tu go nie zbudowano. Dziennik JSON demona też przechowuje surowy token (fałszywy agent go wypisuje) do compose down; zamaskowany jest tylko zachowany log.
  • Kontenery współdzielą jądro z hostem: dla wrogiego lub wielodzierżawnego kodu użyj VM na zadanie albo gVisor (nie testowano tutaj).
  • Serwer proxy to pojedynczy, minimalny punkt (synchroniczne rozwiązywanie nazw blokuje pętlę zdarzeń, brak limitu bezczynności i liczby połączeń, obraz przypięty tagiem): obejście lub awaria osłabia konstrukcję. Filtruje po nazwie, więc wspólny CDN lub domain fronting na dozwolonej nazwie może wynieść dane, a sam stoi na zwykłym moście, który widzi nasłuchujące usługi hosta: zabezpiecz go zaporą (DOCKER-USER), jeśli usługi nasłuchują na wszystkich adresach.
  • Trzymaj demona Dockera z dala od agenta: dostęp do niego to kontrola nad hostem (bezpieczeństwo Dockera).

Przebieg na jednorazowym tokenie (z prawdziwym wynikiem)

Środowisko: Docker 29.7.2 z Compose, Linux arm64, python:3.13-alpine (Python 3.13.16), uruchomione 2026-10-14. Token to oczywista atrapa; dozwolone wychodzące HTTPS wymaga dostępu hosta do internetu. Demon Dockera musi widzieć katalog projektu i $TMPDIR (montowania bind z /tmp zawiodły tutaj).

Umieść sześć plików w nowym katalogu, utwórz fałszywy token i uruchom jedno zadanie:

printf 'fake_token_0000_do_not_use\n' > fake.token && chmod 600 fake.token
./run-task.sh fake.token

BLOCKED przy zabronionej akcji oznacza zaliczenie; linie ALLOWED są legalne. Linia posture pokazuje domyślne ustawienia Dockera: seccomp, no-new-privileges, brak capabilities, AppArmor i tmpfs /work z noexec,nosuid,nodev. Wypisany token wychodzi zamaskowany (prefiksy logu compose usunięte):

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

Sprzątanie jest automatyczne: znikają kontenery, sieci i katalogi tymczasowe, zostają tylko wspólny obraz i zamaskowane logi. Skrypt odrzuca token, którego nie umie bezpiecznie zamaskować.

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

Przebieg kontrolny bez utwardzenia (root, domyślny most, pamięć ograniczona tylko dla ochrony tej maszyny) pokazuje, że testy działają: od 8 do 10 z 14 zabronionych sprawdzeń się udaje (dwie sondy internetowe zależą od łączności hosta). Reszta jest blokowana i tak (brak proxy, brak gniazda Dockera, brak adresu metadanych tutaj, limit pamięci), więc te wiersze to kontrole konfiguracji, nie dowód:

# 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)

Opcja sieci ma znaczenie. W zwykłej sieci --internal host zachowuje adres bramy na moście, a kontener dotarł do usługi hosta nasłuchującej na wszystkich adresach (tu sshd, port 22). Przy inhibit_ipv4 (udokumentowane, bez podanej wersji minimalnej, testowane na 29.7.2) host nie ma adresu na moście, sieć nie ma bramy, a tablica tras zawiera tylko własną podsieć kontenera. Pierwszy kontener dostaje .1, więc test .1 trafia w ten kontener, a nie w hosta: dowodem jest wynik dla adresów i tras, nie odmowa:

# 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

Token nie może trafiać do argumentów procesów, dlatego sed i grep czytają go przez -f <(printf ...). Próbkowanie ps -eo args co 100 ms podczas przebiegu nie znalazło linii z tokenem; ten sam próbnik wyłapuje token podany celowo w argumentach:

( 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

Limit czasu zatrzymuje zawieszonego agenta: timeout --foreground (domyślnie 900 s, TASK_TIMEOUT) kończy przebieg, a pułapka sprzątająca czyści wszystko. Z celowo zawieszonym fałszywym agentem i limitem 20 s:

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

Chcesz ten runner na maszynie, która działa non stop? Poznaj hosting runnerów agentów w UE.

Lista kontrolna bezpieczeństwa: sekrety, logi i zatwierdzenie przez człowieka

Przejrzyj to, zanim runner dotknie prawdziwego repozytorium.

Zabezpieczenie Ustawienie Jak sprawdzić
Utwardzenie USER 10001, read_only: true, seccomp, AppArmor, cap_drop: [ALL], no-new-privileges linia posture: Seccomp=2, NoNewPrivs=1, CapEff=0; zapis do /etc się nie udaje
tmpfs katalogu roboczego /work, 256 MB, domyślnie noexec,nosuid,nodev No space left on device na limicie. Narzędzia uruchamiające pliki z katalogu roboczego (skrypty npm .bin, skompilowane binaria, virtualenvy) potrzebują exec na /work: świadomy kompromis
Limity zasobów i czasu 64 pids, 512 MB bez swapu, 1 CPU, timeout 900 s fork zawodzi, pożeracz pamięci ginie przez OOM, zawieszony agent kończy się kodem 124
Ruch wychodzący sieć wewnętrzna, brak adresu hosta, proxy z listą dozwolonych bezpośrednie połączenie zawodzi, ip nie pokazuje adresu hosta na moście, inne hosty dostają 403
Token i logi token o szczegółowych uprawnieniach (fine-grained), jedno repozytorium, krótka ważność (na przykład 7 dni), tylko contents i pull requests; plik w /run/secrets, nigdy w obrazie, env ani argumentach; logi maskowane, potem przeszukiwane docker history i ps -eo args nie pokazują tokenu; skrypt kończy się kodem 3, jeśli log go zawiera
Produkcja i scalanie żadnych kluczy wdrożeniowych ani poświadczeń chmurowych tutaj; agent otwiera pull request, decydują ochrona gałęzi i przegląd env zawiera jeden token; nie może pushować do chronionej gałęzi

Warunki dostawców: czego nie robić

Używaj własnych danych uwierzytelniających do własnej pracy. Na stronie prawnej Claude Code firmy Anthropic napisano, że logowanie OAuth jest dla posiadaczy planów subskrypcji, twórcy produktów powinni używać kluczy API, a przepuszczanie żądań przez cudze poświadczenia subskrypcji jest niedozwolone; polityka użytkowania zabrania automatycznego zakładania kont (strona polityki podaje datę wejścia w życie 2026-11-12). Warunki korzystania OpenAI w wersjach, które mogliśmy przeczytać (fragmenty z wyszukiwarki; strona blokuje automatyczne pobieranie), nie pozwalają udostępniać danych logowania do konta; potwierdź to w przeglądarce. Jeden klucz lub login na osobę, bez łączenia i odsprzedaży. Sprawdzono 2026-10-14.

Dobór rozmiaru, koszt i wybór konfiguracji

Ceny to plany DCXV Cloud w Pradze z jedną kopią zapasową, obliczone wzorem kalkulatora i porównane 2026-10-14 z aktualną tabelą na /cloud-server.

Jeśli... Zacznij od Uwaga
jedna osoba, jeden agent, edycja i testy jednostkowe Small (2 vCPU / 4 GB / 40 GB), EUR 15.00 zwykle ogranicza pamięć, nie CPU
przeglądarka lub testy end-to-end Medium (4 / 8 GB / 80 GB), EUR 18.43 przeglądarki potrzebują gigabajtów; zmierz szczyt
kilku agentów równolegle Large (8 / 16 GB / 160 GB), EUR 36.88, lub kilka runnerów kontener na zadanie; szczyt razy współbieżność
długie buildy, CI, klucze wdrożeniowe osobny serwer buildów host agenta nigdy nie trzyma poświadczeń wdrożeniowych
model uruchamiasz sam zadanie dla GPU: zobacz artykuł o inferencji agent wywołujący API dostawcy go nie potrzebuje

Mierz, nie zgaduj: odczytaj szczyt z cgroup kontenera, zanim się zakończy, na przykład cat /sys/fs/cgroup/memory.peak (alokacja 100 MB w kontenerze 512 MB dała 105263104 bajtów), a CPU sprawdź przez docker stats --no-stream. Rozmiar hosta to szczytowa pamięć na zadanie razy liczba równoległych zadań plus zapas na system.

Dwa rachunki: VPS to stała miesięczna cena DCXV; użycie modelu, tokeny i subskrypcje rozlicza dostawca modelu, nie DCXV, i zwykle one decydują o sumie. Lokalizacje: Praga (CZ) lub Covilhã (PT).

Podsumowanie

Bezpieczniejszy runner agenta to kilka nudnych ustawień stosowanych za każdym razem. Ograniczają to, do czego dotrze przejęty agent; nie powstrzymują prompt injection.

Poznaj hosting runnerów agentów w UE lub skonfiguruj serwer cloud w UE.

Jak dobrać serwer cloud do SaaS: CPU, RAM, NVMe i backupy
cloudinfrastructurepricing

Jak dobrać serwer cloud do SaaS: CPU, RAM, NVMe i backupy

Dobierz serwer cloud do SaaS w Dockerze i Node.js: budżet pamięci, limity CPU, NVMe i backupy, test obciążeniowy k6, pełna cena miesięczna i arkusz doboru.

Agenty AI do kodu na VPS w UE: utwardzona konfiguracja
aicloudhosting

Agenty AI do kodu na VPS w UE: utwardzona konfiguracja

Uruchom agentów AI do kodu na VPS w UE z ograniczeniami: jednorazowe katalogi, lista dozwolonego ruchu, tokeny, limity, sprawdzony kod, ryzyka i koszt serwera.

Jak przenieść stronę lub aplikację na nowy serwer cloud
cloudhostinginfrastructure

Jak przenieść stronę lub aplikację na nowy serwer cloud

Bezpieczny plan migracji na serwer cloud: checklisty przed, przełączenie i wycofanie, polecenia rsync i bazy danych, TTL DNS, test przez plik hosts i granice.

Jak wybrać lokalizację serwera cloud w Europie w 2026 roku
clouddatacenternetworkinginfrastructure

Jak wybrać lokalizację serwera cloud w Europie w 2026 roku

Wybierz lokalizację serwera cloud w Europie według odbiorców, jakości trasy i RODO. Porównaj Pragę i Covilha oraz sam przetestuj trasę za pomocą ping i mtr.

Ile kosztuje serwer w chmurze w Europie w 2026 roku?
cloudhostingpricing

Ile kosztuje serwer w chmurze w Europie w 2026 roku?

Koszt serwera w chmurze w Europie 2026: presety Small, Medium i Large od 15 EUR/mies., przykład z Windows, czemu najtańszy plan kosztuje więcej i jak liczyć.