Agentes de código con IA en VPS de la UE: setup reforzado
Respuesta rápida: un agente de código en un VPS necesita cómputo normal, no una GPU, porque el modelo se ejecuta en el proveedor. El riesgo es lo que el agente puede alcanzar, así que límitelo: un contenedor desechable por tarea, sin root, sistema de archivos raíz de solo lectura, tráfico saliente solo hacia una lista permitida, un token acotado como archivo, un límite de tiempo y una persona antes de producción. Abajo: un runner probado, un agente falso que lo ataca (salida real) y el precio. Estos ajustes limitan lo que alcanza un agente secuestrado; no frenan la inyección de prompts. Comprobado el 2026-10-14.
Qué aloja y qué no
Tareas típicas: un repositorio en un servidor, revisión de pull requests, pruebas tras cada cambio, trabajos programados. Todo es cómputo del agente: la CLI ejecuta comandos en su VPS y solo envía prompts a una API de modelo, así que no hace falta GPU (alojar un modelo es otro trabajo). Lo básico: Ejecuta Claude Code, Codex y Grok CLI en tu propio servidor cloud; prompts: hoja de trucos.
Modelo de amenazas: hasta dónde llega una instrucción inyectada
Todo lo que lee el agente (archivos del repositorio, issues, pull requests, páginas web, salida de herramientas) es una entrada a la que puede obedecer. OWASP recoge la inyección de prompts como LLM01:2025 y la agencia excesiva como LLM06:2025; ningún modelo es inmune, así que limite lo que puede tocar un agente secuestrado.
| Una instrucción inyectada quiere... | En una instalación ingenua alcanza | Límite en este diseño |
|---|---|---|
| Leer secretos | env, ~/.ssh, otros repositorios | uid dedicado, directorio personal vacío y de solo lectura, un repositorio, un archivo de token acotado |
| Enviar datos fuera | cualquier host de internet | sin ruta de salida, proxy con lista permitida, 403 para el resto |
| Subir o fusionar código malicioso | cualquier rama que cubra el token | token para un repositorio, protección de ramas, revisión humana |
| Llegar a servicios cloud, de la LAN o del host | dirección de metadatos, bases de datos, servicios del host | sin ruta de salida, sin dirección del host en el puente del sandbox |
Los proveedores coinciden: la seguridad de Claude Code recomienda VM o un dev container para contenido no fiable, su guía de refuerzo pide salida denegada por defecto y un contenedor por sesión, y la documentación de Codex deja la red apagada por defecto. Comprobado el 2026-10-14.
La arquitectura
Una tarea es un proyecto de Docker Compose: un contenedor de agente más un pequeño proxy de salida. El agente está en una red internal donde el host no tiene dirección IPv4, así que su única salida es el proxy, que solo responde por hosts de la lista permitida. Al terminar la tarea, todo se destruye.

El runner reforzado (probado)
Seis archivos pequeños; solo hace falta Docker con Compose. La imagen contiene un agente falso e inofensivo; para uso real, instale la CLI de su agente en el Dockerfile y añada a allowlist.txt los hosts necesarios (host git, registro de paquetes, API del modelo). Una herramienta que ignore HTTPS_PROXY simplemente falla.
Dockerfile: usuario sin privilegios, nada escribible
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: límites, raíz de solo lectura, dos redes, secreto como archivo
# 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: todo lo demás se rechaza
# one host name per line; everything else is refused
example.com
egress_proxy.py: proxy CONNECT con denegación por defecto, solo biblioteca estándar
"""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: crear, ejecutar, ocultar, comprobar, 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: intenta lo que intentaría un agente secuestrado
"""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)
Límites honestos y riesgo residual:
- El agente puede leer su token y enviarlo a cualquier host permitido (un gist o un issue en github.com), así que el alcance del token sigue siendo el control principal; más sólido es un proxy que inyecte la credencial fuera del contenedor, como describe la página Agent runners; aquí no está construido. El registro JSON del demonio también guarda el token en bruto (el agente falso lo imprime) hasta
compose down; solo el registro conservado está ocultado. - Los contenedores comparten el kernel del host: para código hostil o multiinquilino use una VM por tarea o gVisor (no probado aquí).
- El proxy es un punto único y mínimo (resolución de nombres bloqueante, sin timeout de inactividad ni tope de conexiones, imagen fijada por etiqueta): si se elude o cae, el diseño se debilita. Filtra por nombre, así que una CDN compartida o el domain fronting sobre un nombre permitido pueden sacar datos, y está en un puente normal que alcanza servicios del host: protéjalo con el cortafuegos (DOCKER-USER) si hay servicios en todas las direcciones.
- Mantenga el demonio de Docker lejos del agente: acceder a él equivale a controlar el host (seguridad de Docker).
Recorrido con un token desechable (con salida real)
Entorno: Docker 29.7.2 con Compose, Linux arm64, python:3.13-alpine (Python 3.13.16), ejecutado el 2026-10-14. El token es un marcador evidentemente falso; el HTTPS saliente permitido necesita acceso a internet desde el host. El demonio de Docker debe poder ver el directorio del proyecto y $TMPDIR (los bind mounts desde /tmp fallaron aquí).
Ponga los seis archivos en un directorio nuevo, cree un token falso y ejecute una tarea:
printf 'fake_token_0000_do_not_use\n' > fake.token && chmod 600 fake.token
./run-task.sh fake.token
BLOCKED en una acción prohibida es el resultado correcto; las líneas ALLOWED son las legítimas. La línea posture muestra los valores por defecto de Docker: seccomp, no-new-privileges, sin capabilities, AppArmor y un tmpfs /work con noexec,nosuid,nodev. El token impreso sale oculto (prefijos del registro de compose recortados):
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
El desmontaje es automático: desaparecen contenedores, redes y directorios temporales; solo quedan la imagen compartida y los registros ocultados. El script rechaza un token que no puede ocultar con seguridad.
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
Una ejecución de control sin el refuerzo (root, bridge por defecto, memoria limitada solo para proteger esta máquina) muestra que las comprobaciones detectan problemas reales: entre 8 y 10 de las 14 comprobaciones prohibidas tienen éxito (las dos pruebas de internet dependen de la conectividad del host). El resto se bloquea igualmente (sin proxy, sin socket de Docker, sin dirección de metadatos aquí, el tope de memoria), así que esas filas son comprobaciones de configuración, no una prueba:
# 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)
La opción de red importa. En una red --internal simple el host conserva la dirección de la puerta de enlace en el puente, y el contenedor alcanzó un servicio del host que escuchaba en todas las direcciones (aquí sshd, puerto 22). Con inhibit_ipv4 (documentado, sin versión mínima indicada, probado en 29.7.2) el host no tiene dirección en el puente, la red no tiene puerta de enlace y la tabla de rutas solo contiene la subred propia del contenedor. El primer contenedor toma .1, así que una prueba de .1 llega a ese contenedor, no al host: la prueba es la salida de direcciones y rutas, no el rechazo:
# 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
El token no debe aparecer en los argumentos de los procesos, por eso sed y grep lo leen mediante -f <(printf ...). Un muestreo de ps -eo args cada 100 ms durante una ejecución no encontró ninguna línea con el token; el mismo muestreador sí detecta uno puesto a propósito en los 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
Un límite de tiempo detiene a un agente atascado: timeout --foreground (900 s por defecto, TASK_TIMEOUT) termina la ejecución y la trampa de desmontaje limpia. Con un agente falso que se cuelga a propósito y un límite 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
¿Quiere este runner en una máquina siempre encendida? Conozca el hosting de runners de agentes en la UE.
Lista de seguridad: secretos, registros y la aprobación humana
Revise esto antes de que un runner toque un repositorio real.
| Control | Ajuste | Cómo verificarlo |
|---|---|---|
| Refuerzo | USER 10001, read_only: true, seccomp, AppArmor, cap_drop: [ALL], no-new-privileges |
línea posture: Seccomp=2, NoNewPrivs=1, CapEff=0; escribir en /etc falla |
| tmpfs del espacio de trabajo | /work, 256 MB, noexec,nosuid,nodev por defecto |
No space left on device en el límite. Las herramientas que ejecutan archivos del espacio de trabajo (scripts .bin de npm, binarios compilados, virtualenvs) necesitan exec en /work: un compromiso consciente |
| Límites de recursos y de tiempo | 64 pids, 512 MB sin swap, 1 CPU, timeout 900 s |
fork falla, el devorador de memoria muere por OOM, un agente colgado termina con exit 124 |
| Salida de red | red interna, sin dirección del host, proxy con lista permitida | la conexión directa falla, ip no muestra dirección del host en el puente, otros hosts reciben 403 |
| Token y registros | de grano fino, un repositorio, caducidad corta (por ejemplo 7 días), solo contents y pull requests; archivo en /run/secrets, nunca en la imagen, el entorno ni los argumentos; registros ocultados y luego revisados |
docker history y ps -eo args no muestran el token; el script termina con código 3 si un registro lo contiene |
| Producción y merge | sin claves de despliegue ni credenciales cloud aquí; el agente abre un pull request y deciden la protección de ramas y la revisión | env contiene un token; no puede subir a la rama protegida |
Condiciones de los proveedores: lo que no debe hacer
Use sus propias credenciales para su propio trabajo. La página legal de Claude Code de Anthropic dice que el inicio de sesión OAuth es para titulares de planes de suscripción, que quienes desarrollan productos deben usar claves de API y que no se permite canalizar solicitudes con credenciales de suscripción ajenas; su política de uso prohíbe la creación automatizada de cuentas (la página de la política indica como fecha de entrada en vigor el 2026-11-12). Los términos de uso de OpenAI, en las versiones que pudimos leer (extractos de búsqueda; la página bloquea las descargas automáticas), no permiten compartir las credenciales de la cuenta; confírmelo en un navegador. Una clave o acceso por persona, sin agrupar ni revender. Comprobado el 2026-10-14.
Dimensionamiento, coste y qué configuración elegir
Los precios son planes DCXV Cloud en Praga con una copia de seguridad, calculados con la fórmula de la calculadora y contrastados el 2026-10-14 con la tabla en vivo de /cloud-server.
| Si... | Empiece con | Nota |
|---|---|---|
| una persona, un agente, edición y pruebas unitarias | Small (2 vCPU / 4 GB / 40 GB), EUR 15.00 | suele limitar la memoria, no la CPU |
| un navegador o pruebas de extremo a extremo | Medium (4 / 8 GB / 80 GB), EUR 18.43 | los navegadores necesitan gigabytes; mida el pico |
| varios agentes en paralelo | Large (8 / 16 GB / 160 GB), EUR 36.88, o varios runners | un contenedor por tarea; pico por concurrencia |
| compilaciones largas, CI, claves de despliegue | un servidor de compilación aparte | el host del agente nunca guarda credenciales de despliegue |
| usted aloja el modelo | un trabajo de GPU: vea el artículo de inferencia | un agente que llama a la API de un proveedor no la necesita |
Mida, no adivine: lea el pico del cgroup del contenedor antes de que termine, por ejemplo cat /sys/fs/cgroup/memory.peak (una reserva de 100 MB en un contenedor de 512 MB dio 105263104 bytes), y use docker stats --no-stream para la CPU. El tamaño del host es la memoria pico por tarea por tareas simultáneas, más margen para el sistema.
Dos facturas: el VPS es un precio mensual fijo de DCXV; el uso del modelo, los tokens y las suscripciones los factura el proveedor del modelo, no DCXV, y suelen decidir el total. Ubicaciones: Praga (CZ) o Covilhã (PT).
Conclusión
Un runner de agente más seguro son unos pocos ajustes aburridos aplicados siempre. Limitan lo que alcanza un agente secuestrado; no frenan la inyección de prompts.
Conozca el hosting de runners de agentes en la UE o configure un servidor cloud en la UE.
