AI-агенти для коду на VPS у ЄС: посилене налаштування
Коротка відповідь: агенту для коду на VPS потрібні звичайні обчислення, а не GPU, бо модель працює на боці провайдера. Ризик - у тому, до чого агент може дотягнутися, тож обмежуйте це: одноразовий контейнер на завдання, без root, коренева файлова система лише для читання, вихідний трафік тільки до білого списку, один обмежений токен як файл, ліміт часу і людина перед продакшеном. Нижче: перевірений раннер, фейковий агент, що його атакує (справжній вивід), і ціна. Ці налаштування обмежують, чого може досягти захоплений агент; від prompt injection вони не захищають. Перевірено 2026-10-14.
Що саме ви хостите, а що ні
Типові задачі: репозиторій на сервері, рев'ю pull request, тести після кожної зміни, планові завдання. Усе це обчислення агента: CLI виконує команди на вашому VPS і лише надсилає запити до API моделі, тому GPU не потрібен (хостинг моделі - інша задача). Основи: Запускайте Claude Code, Codex та Grok CLI на власному хмарному сервері; промпти: шпаргалка.
Модель загроз: до чого дотягнеться ін'єктована інструкція
Усе, що читає агент (файли репозиторію, issue, pull request, веб-сторінки, вивід інструментів), - це вхідні дані, яким він може підкоритися. OWASP називає prompt injection як LLM01:2025 і надмірну самостійність як LLM06:2025; невразливих моделей немає, тому обмежуйте, чого може торкнутися захоплений агент.
| Ін'єктована інструкція хоче... | У наївному налаштуванні доступно | Обмеження в цій архітектурі |
|---|---|---|
| Прочитати секрети | env, ~/.ssh, інші репозиторії | окремий uid, порожній домашній каталог лише для читання, один репозиторій, один токен-файл з обмеженими правами |
| Надіслати дані назовні | будь-який хост в інтернеті | виходу немає, проксі з білим списком, 403 для решти |
| Запушити чи злити шкідливий код | будь-яка гілка, яку покриває токен | токен на один репозиторій, захист гілок, рев'ю людиною |
| Дістатися до хмарних сервісів, LAN чи сервісів хоста | адреса метаданих, бази даних, сервіси хоста | виходу немає, на мосту пісочниці немає адреси хоста |
Вендори згодні: безпека Claude Code радить VM або dev-контейнер для недовіреного вмісту, посібник із посилення захисту вимагає заборони вихідного трафіку за замовчуванням і контейнера на сесію, а документація Codex за замовчуванням вимикає мережу. Перевірено 2026-10-14.
Архітектура
Одне завдання - один проєкт Docker Compose: контейнер агента і невеликий проксі для вихідного трафіку. Агент розташований у мережі internal, де в хоста немає адреси IPv4, тож єдиний вихід - проксі, який відповідає лише за хости з білого списку. Коли завдання завершується, усе знищується.

Посилений раннер (перевірений)
Шість невеликих файлів; потрібен лише Docker із Compose. В образі лежить нешкідливий фейковий агент; для реального використання встановіть CLI свого агента в Dockerfile і додайте в allowlist.txt потрібні хости (git-хост, реєстр пакетів, API моделі). Інструмент, що ігнорує HTTPS_PROXY, просто не працюватиме.
Dockerfile: звичайний користувач, нічого для запису
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: ліміти, коренева ФС лише для читання, дві мережі, секрет як файл
# 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: усе інше відхиляється
# one host name per line; everything else is refused
example.com
egress_proxy.py: CONNECT-проксі з забороною за замовчуванням, лише стандартна бібліотека
"""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: створити, запустити, замаскувати, перевірити, знищити
#!/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: пробує те, що зробив би захоплений агент
"""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)
Чесні обмеження та залишковий ризик:
- Агент може прочитати свій токен і надіслати його на будь-який дозволений хост (gist чи issue на github.com), тож обмеження токена лишається головним захистом; сильніше - проксі, що підставляє облікові дані поза контейнером, як описує сторінка Agent runners; тут його не збудовано. Журнал JSON демона теж зберігає сирий токен (фейковий агент його друкує) до
compose down; замаскований лише збережений лог. - Контейнери ділять ядро з хостом: для зловмисного чи мультитенантного коду беріть VM на завдання або gVisor (тут не перевірялося).
- Проксі - єдина мінімальна точка (синхронне розв'язання імен блокує цикл подій, без таймауту простою й ліміту з'єднань, образ зафіксовано тегом): якщо його обійти або він упаде, захист слабшає. Він фільтрує за іменем, тож спільний CDN або domain fronting на дозволеному імені можуть винести дані, а сам він стоїть на звичайному мосту, який бачить слухачів хоста: закрийте його файрволом (DOCKER-USER), якщо сервіси слухають усі адреси.
- Тримайте демон Docker подалі від агента: доступ до нього - це контроль над хостом (безпека Docker).
Покроковий приклад на тестовому токені (зі справжнім виводом)
Середовище: Docker 29.7.2 з Compose, Linux arm64, python:3.13-alpine (Python 3.13.16), запуск 2026-10-14. Токен - очевидна заглушка; дозволеному вихідному HTTPS потрібен доступ хоста до інтернету. Демон Docker має бачити каталог проєкту й $TMPDIR (монтування з /tmp тут не спрацювали).
Покладіть шість файлів у новий каталог, створіть фальшивий токен і запустіть одне завдання:
printf 'fake_token_0000_do_not_use\n' > fake.token && chmod 600 fake.token
./run-task.sh fake.token
BLOCKED для забороненої дії - успіх; рядки ALLOWED - легітимні. Рядок posture показує типові налаштування Docker: seccomp, no-new-privileges, жодних capabilities, AppArmor і tmpfs /work з noexec,nosuid,nodev. Надрукований токен виходить замаскованим (префікси журналу compose прибрано):
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
Прибирання автоматичне: контейнерів, мереж і тимчасових каталогів не лишається, залишаються лише спільний образ і замасковані логи. Обгортка відмовляється від токена, який не можна безпечно замаскувати.
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
Контрольний запуск без захисту (root, типова bridge-мережа, пам'ять обмежено лише щоб захистити цю машину) показує, що перевірки працюють: від 8 до 10 із 14 заборонених перевірок вдаються (дві інтернет-проби залежать від з'єднання хоста). Решта блокуються й так (немає проксі, немає сокета Docker, немає адреси метаданих, ліміт пам'яті), тож ці рядки - перевірка конфігурації, а не доказ:
# 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)
Параметр мережі важливий. У звичайній мережі --internal хост лишає адресу шлюзу на мосту, і контейнер дістався сервісу хоста, що слухає всі адреси (тут - sshd, порт 22). З inhibit_ipv4 (документовано, мінімальну версію не вказано, перевірено на 29.7.2) у хоста немає адреси на мосту, у мережі немає шлюзу, а таблиця маршрутів містить лише власну підмережу контейнера. Перший контейнер отримує .1, тож перевірка .1 потрапляє в цей контейнер, а не в хост: доказ - вивід адреси й маршрутів, а не відмова:
# 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
Токен не повинен потрапляти в аргументи процесів, тому sed і grep читають його через -f <(printf ...). Вибірка ps -eo args кожні 100 мс протягом запуску не знайшла жодного рядка з токеном; той самий засіб вибірки ловить токен, навмисно поданий в аргументах:
( 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
Ліміт часу зупиняє завислого агента: timeout --foreground (типово 900 с, TASK_TIMEOUT) завершує запуск, а пастка прибирання все чистить. Із завислим фейковим агентом і лімітом 20 с:
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
Потрібен такий раннер на машині, що працює постійно? Дізнатися про хостинг агент-раннерів у ЄС.
Чек-лист безпеки: секрети, логи і підтвердження людиною
Перегляньте, перш ніж раннер торкнеться реального репозиторію.
| Захід | Налаштування | Як перевірити |
|---|---|---|
| Посилення | USER 10001, read_only: true, seccomp, AppArmor, cap_drop: [ALL], no-new-privileges |
рядок posture: Seccomp=2, NoNewPrivs=1, CapEff=0; запис у /etc не вдається |
| tmpfs для робочого каталогу | /work, 256 МБ, типово noexec,nosuid,nodev |
No space left on device на межі. Інструменти, що запускають файли з робочого каталогу (скрипти npm .bin, скомпільовані бінарники, virtualenv), потребують exec на /work: свідомий компроміс |
| Ліміти ресурсів і часу | 64 pids, 512 МБ без swap, 1 CPU, timeout 900 с |
fork не вдається, пожирача пам'яті вбиває OOM, завислий агент завершується з кодом 124 |
| Вихідний трафік | внутрішня мережа, без адреси хоста, проксі з білим списком | пряме з'єднання не вдається, ip не показує адреси хоста на мосту, інші хости отримують 403 |
| Токен і логи | fine-grained, один репозиторій, короткий строк дії (наприклад, 7 днів), лише contents і pull requests; файл у /run/secrets, ніколи в образі, env чи аргументах; логи маскуються, потім перевіряються |
docker history і ps -eo args не показують токена; обгортка завершується з кодом 3, якщо лог його містить |
| Продакшен і злиття | жодних deploy-ключів чи хмарних облікових даних тут; агент відкриває pull request, вирішують захист гілок і рев'ю | env містить один токен; він не може пушити в захищену гілку |
Умови вендорів: чого не робити
Використовуйте власні облікові дані для власної роботи. На юридичній сторінці Claude Code Anthropic сказано, що OAuth-вхід призначений для власників планів підписки, розробники продуктів мають використовувати API-ключі, а пропускати запити через чужі облікові дані підписки не дозволено; політика використання забороняє автоматичне створення акаунтів (на сторінці політики вказано дату набрання чинності 2026-11-12). Умови використання OpenAI у версіях, які ми змогли прочитати (уривки з пошуку; сторінка блокує автоматичне завантаження), не дозволяють ділитися обліковими даними акаунта; підтвердьте в браузері. Один ключ або вхід на людину, без об'єднання й перепродажу. Перевірено 2026-10-14.
Розмір, ціна і вибір налаштування
Ціни - плани DCXV Cloud у Празі з одним бекапом, розраховані за формулою калькулятора й звірені з живою таблицею на /cloud-server 2026-10-14.
| Якщо... | Почніть із | Примітка |
|---|---|---|
| одна людина, один агент, редагування й юніт-тести | Small (2 vCPU / 4 ГБ / 40 ГБ), EUR 15.00 | зазвичай обмежує пам'ять, а не CPU |
| браузер або наскрізні тести | Medium (4 / 8 ГБ / 80 ГБ), EUR 18.43 | браузерам потрібні гігабайти; виміряйте пік |
| кілька агентів паралельно | Large (8 / 16 ГБ / 160 ГБ), EUR 36.88, або кілька раннерів | контейнер на завдання; пік помножити на паралельність |
| довгі збірки, CI, deploy-ключі | окремий сервер збірки | хост агента не тримає облікових даних для деплою |
| модель ви запускаєте самі | завдання для GPU: див. статтю про інференс | агенту, що звертається до API провайдера, GPU не потрібен |
Міряйте, а не вгадуйте: прочитайте пік із cgroup контейнера до його завершення, наприклад cat /sys/fs/cgroup/memory.peak (виділення 100 МБ у контейнері на 512 МБ дало 105263104 байтів), а CPU дивіться через docker stats --no-stream. Розмір хоста - пікова пам'ять завдання, помножена на кількість одночасних завдань, плюс запас для ОС.
Два рахунки: VPS - фіксована місячна ціна DCXV; використання моделі, токени й підписки оплачуються провайдеру моделі, а не DCXV, і зазвичай саме вони визначають підсумок. Сервери: Прага (CZ) або Ковільян (PT).
Підсумок
Безпечніший раннер агента - це кілька нудних налаштувань, що застосовуються щоразу. Вони обмежують, чого може досягти захоплений агент; від prompt injection вони не захищають.
Дізнатися про хостинг агент-раннерів у ЄС або Налаштувати хмарний сервер у ЄС.
