Agentes de código com IA em VPS da UE: setup reforçado

Agentes de código com IA em VPS da UE: setup reforçado

Agentes de código com IA em VPS da UE: setup reforçado

Resposta rápida: um agente de código num VPS precisa de computação normal, não de GPU, porque o modelo corre no fornecedor. O risco é aquilo a que o agente consegue chegar, por isso limite-o: um contentor descartável por tarefa, sem root, sistema de ficheiros raiz só de leitura, tráfego de saída apenas para uma lista permitida, um token restrito como ficheiro, um limite de tempo e uma pessoa antes da produção. Abaixo: um runner testado, um agente falso que o ataca (saída real) e o preço. Estas definições limitam o que um agente sequestrado alcança; não travam a injeção de prompts. Verificado em 2026-10-14.

O que está a hospedar e o que não está

Tarefas típicas: um repositório num servidor, revisão de pull requests, testes após cada alteração, tarefas agendadas. Tudo isto é computação do agente: a CLI executa comandos no seu VPS e só envia prompts a uma API de modelo, por isso não é preciso GPU (hospedar um modelo é outro trabalho). O básico: Execute Claude Code, Codex e Grok CLI no seu próprio servidor cloud; prompts: folha de atalhos.

Modelo de ameaças: até onde chega uma instrução injetada

Tudo o que o agente lê (ficheiros do repositório, issues, pull requests, páginas web, saída de ferramentas) é uma entrada a que pode obedecer. A OWASP lista a injeção de prompts como LLM01:2025 e a agência excessiva como LLM06:2025; nenhum modelo é imune, por isso limite o que um agente sequestrado pode tocar.

Uma instrução injetada quer... Numa configuração ingénua alcança Limite neste desenho
Ler segredos env, ~/.ssh, outros repositórios uid dedicado, diretório pessoal vazio e só de leitura, um repositório, um ficheiro de token restrito
Enviar dados para fora qualquer host da internet sem rota de saída, proxy com lista permitida, 403 para o resto
Enviar ou integrar código malicioso qualquer ramo que o token cubra token para um repositório, proteção de ramos, revisão humana
Chegar a serviços cloud, da LAN ou do host endereço de metadados, bases de dados, serviços do host sem rota de saída, sem endereço do host na ponte da sandbox

Os fornecedores concordam: a segurança do Claude Code recomenda VMs ou um dev container para conteúdo não fiável, o seu guia de reforço pede saída negada por defeito e um contentor por sessão, e a documentação do Codex deixa a rede desligada por defeito. Verificado em 2026-10-14.

A arquitetura

Uma tarefa é um projeto Docker Compose: um contentor de agente mais um pequeno proxy de saída. O agente está numa rede internal onde o host não tem endereço IPv4, por isso a sua única saída é o proxy, que só responde por hosts da lista permitida. Quando a tarefa termina, tudo é destruído.

Runner de agente de código: entrada não fiável, contentor sandbox, proxy com lista permitida e aprovação humana

O runner reforçado (testado)

Seis ficheiros pequenos; só é preciso Docker com Compose. A imagem contém um agente falso e inofensivo; para uso real, instale a CLI do seu agente no Dockerfile e acrescente a allowlist.txt os hosts de que precisa (host git, registo de pacotes, API do modelo). Uma ferramenta que ignore HTTPS_PROXY simplesmente falha.

Dockerfile: utilizador sem privilégios, nada gravável

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: limites, raiz só de leitura, duas redes, segredo como ficheiro

# 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: tudo o resto é recusado

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

egress_proxy.py: proxy CONNECT com recusa por defeito, só biblioteca padrão

"""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: criar, executar, ocultar, verificar, destruir

#!/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: tenta o que um agente sequestrado tentaria

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

Limites honestos e risco residual:

  • O agente pode ler o seu token e enviá-lo a qualquer host permitido (um gist ou um issue no github.com), por isso o âmbito do token continua a ser o controlo principal; mais forte é um proxy que injete a credencial fora do contentor, como descreve a página Agent runners; aqui não está construído. O registo JSON do daemon também guarda o token em bruto (o agente falso imprime-o) até compose down; só o registo conservado está ocultado.
  • Os contentores partilham o kernel do host: para código hostil ou multilocatário use uma VM por tarefa ou o gVisor (não testado aqui).
  • O proxy é um ponto único e mínimo (resolução de nomes bloqueante, sem timeout de inatividade nem limite de ligações, imagem fixada por etiqueta): contornado ou em baixo, o desenho enfraquece. Filtra por nome, por isso uma CDN partilhada ou domain fronting num nome permitido podem fazer sair dados, e está numa ponte normal que alcança serviços do host: proteja-o com a firewall (DOCKER-USER) se houver serviços em todos os endereços.
  • Mantenha o daemon do Docker longe do agente: aceder-lhe equivale a controlar o host (segurança do Docker).

Passo a passo com um token descartável (com saída real)

Ambiente: Docker 29.7.2 com Compose, Linux arm64, python:3.13-alpine (Python 3.13.16), executado em 2026-10-14. O token é um marcador obviamente falso; o HTTPS de saída permitido precisa de acesso à internet a partir do host. O daemon do Docker tem de conseguir ver o diretório do projeto e $TMPDIR (os bind mounts a partir de /tmp falharam aqui).

Ponha os seis ficheiros num diretório novo, crie um token falso e execute uma tarefa:

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

BLOCKED numa ação proibida é a aprovação; as linhas ALLOWED são as legítimas. A linha posture mostra os valores por defeito do Docker: seccomp, no-new-privileges, sem capabilities, AppArmor e um tmpfs /work com noexec,nosuid,nodev. O token impresso sai oculto (prefixos do registo do compose removidos):

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

A remoção é automática: desaparecem contentores, redes e diretórios temporários; ficam apenas a imagem partilhada e os registos ocultados. O script recusa um token que não consegue ocultar com segurança.

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

Uma execução de controlo sem o reforço (root, bridge por defeito, memória limitada só para proteger esta máquina) mostra que as verificações apanham problemas reais: entre 8 e 10 das 14 verificações proibidas têm êxito (as duas sondas de internet dependem da conectividade do host). O resto é bloqueado de qualquer modo (sem proxy, sem socket do Docker, sem endereço de metadados aqui, o limite de memória), por isso essas linhas são verificações de configuração, não prova:

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

A opção de rede importa. Numa rede --internal simples o host mantém o endereço do gateway na ponte e o contentor alcançou um serviço do host que escutava em todos os endereços (aqui o sshd, porta 22). Com inhibit_ipv4 (documentado, sem versão mínima indicada, testado em 29.7.2) o host não tem endereço na ponte, a rede não tem gateway e a tabela de rotas só contém a sub-rede do próprio contentor. O primeiro contentor fica com .1, por isso um teste a .1 chega a esse contentor, não ao host: a prova é a saída de endereços e rotas, não a recusa:

# 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

O token não pode aparecer nos argumentos dos processos, por isso sed e grep leem-no através de -f <(printf ...). Uma amostragem de ps -eo args a cada 100 ms durante uma execução não encontrou nenhuma linha com o token; o mesmo amostrador apanha um token posto de propósito nos argumentos:

( 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

Um limite de tempo pára um agente preso: timeout --foreground (900 s por defeito, TASK_TIMEOUT) termina a execução e a armadilha de desmontagem limpa. Com um agente falso que bloqueia de propósito e um limite de 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

Quer este runner numa máquina sempre ligada? Conheça o alojamento de runners de agentes na UE.

Lista de segurança: segredos, registos e a aprovação humana

Reveja isto antes de um runner tocar num repositório real.

Controlo Definição Como verificar
Reforço USER 10001, read_only: true, seccomp, AppArmor, cap_drop: [ALL], no-new-privileges linha posture: Seccomp=2, NoNewPrivs=1, CapEff=0; escrever em /etc falha
tmpfs do espaço de trabalho /work, 256 MB, noexec,nosuid,nodev por defeito No space left on device no limite. Ferramentas que executam ficheiros do espaço de trabalho (scripts .bin do npm, binários compilados, virtualenvs) precisam de exec em /work: um compromisso consciente
Limites de recursos e de tempo 64 pids, 512 MB sem swap, 1 CPU, timeout 900 s fork falha, o devorador de memória é morto pelo OOM, um agente preso termina com exit 124
Saída de rede rede interna, sem endereço do host, proxy com lista permitida a ligação direta falha, ip não mostra endereço do host na ponte, outros hosts recebem 403
Token e registos de granularidade fina, um repositório, validade curta (por exemplo 7 dias), só contents e pull requests; ficheiro em /run/secrets, nunca na imagem, no ambiente nem nos argumentos; registos ocultados e depois pesquisados docker history e ps -eo args não mostram o token; o script termina com o código 3 se um registo o contiver
Produção e merge sem chaves de deploy nem credenciais cloud aqui; o agente abre um pull request e decidem a proteção de ramos e a revisão env contém um token; não consegue enviar para o ramo protegido

Termos dos fornecedores: o que não fazer

Use as suas próprias credenciais para o seu próprio trabalho. A página legal do Claude Code da Anthropic diz que o início de sessão OAuth se destina a titulares de planos de subscrição, que quem desenvolve produtos deve usar chaves de API e que não é permitido encaminhar pedidos através de credenciais de subscrição de terceiros; a política de utilização proíbe a criação automatizada de contas (a página da política indica como data de entrada em vigor 2026-11-12). Os termos de utilização da OpenAI, nas versões que conseguimos ler (excertos de pesquisa; a página bloqueia obtenções automáticas), não permitem partilhar as credenciais da conta; confirme num navegador. Uma chave ou login por pessoa, nunca agrupados nem revendidos. Verificado em 2026-10-14.

Dimensionamento, custo e que configuração escolher

Os preços são planos DCXV Cloud em Praga com uma cópia de segurança, calculados pela fórmula da calculadora e confrontados em 2026-10-14 com a tabela em direto de /cloud-server.

Se... Comece com Nota
uma pessoa, um agente, edição e testes unitários Small (2 vCPU / 4 GB / 40 GB), EUR 15.00 costuma limitar a memória, não o CPU
um navegador ou testes ponta a ponta Medium (4 / 8 GB / 80 GB), EUR 18.43 os navegadores precisam de gigabytes; meça o pico
vários agentes em paralelo Large (8 / 16 GB / 160 GB), EUR 36.88, ou vários runners um contentor por tarefa; pico vezes simultaneidade
builds longos, CI, chaves de deploy um servidor de build separado o host do agente nunca guarda credenciais de deploy
serve o modelo por si um trabalho de GPU: veja o artigo sobre inferência um agente que chama a API de um fornecedor não precisa dela

Meça, não adivinhe: leia o pico a partir do cgroup do contentor antes de ele terminar, por exemplo cat /sys/fs/cgroup/memory.peak (uma alocação de 100 MB num contentor de 512 MB deu 105263104 bytes), e use docker stats --no-stream para o CPU. O tamanho do host é a memória de pico por tarefa vezes as tarefas em simultâneo, mais folga para o sistema.

Duas faturas: o VPS é um preço mensal fixo da DCXV; o uso do modelo, os tokens e as subscrições são faturados pelo fornecedor do modelo, não pela DCXV, e costumam decidir o total. Localizações: Praga (CZ) ou Covilhã (PT).

Em resumo

Um runner de agente mais seguro são umas definições pouco vistosas aplicadas sempre. Limitam o que um agente sequestrado alcança; não travam a injeção de prompts.

Conheça o alojamento de runners de agentes na UE ou configure um servidor cloud na UE.

Como dimensionar um servidor cloud para SaaS: CPU, RAM, NVMe
cloudinfrastructurepricing

Como dimensionar um servidor cloud para SaaS: CPU, RAM, NVMe

Dimensione um servidor cloud para SaaS em Docker e Node.js: memória, limites de CPU, NVMe e cópias, teste de carga k6, preço mensal total e folha de cálculo.

Agentes de código com IA em VPS da UE: setup reforçado
aicloudhosting

Agentes de código com IA em VPS da UE: setup reforçado

Hospede agentes de código com IA num VPS da UE com limites: espaços descartáveis, saída permitida, tokens restritos, tetos, código testado, riscos e custo.

Como migrar um site ou app para um novo servidor cloud
cloudhostinginfrastructure

Como migrar um site ou app para um novo servidor cloud

Plano seguro para migrar para um servidor cloud: checklists antes, comutação e reversão, comandos rsync e de base de dados, TTL do DNS, teste com hosts.

Como escolher a localização de um servidor cloud na Europa
clouddatacenternetworkinginfrastructure

Como escolher a localização de um servidor cloud na Europa

Escolha a localização do servidor cloud na Europa pelo público, qualidade da rota e RGPD. Compare Praga e Covilha e teste o caminho você mesmo com ping e mtr.

Quanto custa um servidor cloud na Europa em 2026? Guia
cloudhostingpricing

Quanto custa um servidor cloud na Europa em 2026? Guia

Custo de um servidor cloud na Europa em 2026: presets Small, Medium e Large desde 15 EUR/mês, exemplo com Windows, porque o plano barato sai caro, estimativa.