Agentes de código con IA en VPS de la UE: setup reforzado

Agentes de código con IA en VPS de la UE: setup reforzado

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.

Runner de agente de código: entrada no fiable, contenedor sandbox, proxy con lista permitida y aprobación humana

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.

Cómo dimensionar un servidor cloud para SaaS: CPU, RAM, NVMe
cloudinfrastructurepricing

Cómo dimensionar un servidor cloud para SaaS: CPU, RAM, NVMe

Dimensiona un servidor cloud para SaaS con Docker y Node.js: presupuesto de memoria, límites de CPU, NVMe y copias, prueba de carga k6, precio mensual y hoja.

Agentes de código con IA en VPS de la UE: setup reforzado
aicloudhosting

Agentes de código con IA en VPS de la UE: setup reforzado

Aloja agentes de código con IA en un VPS de la UE con límites: espacios desechables, salida permitida, tokens acotados, topes, código probado, riesgos y coste.

Cómo migrar un sitio web o app a un nuevo servidor cloud
cloudhostinginfrastructure

Cómo migrar un sitio web o app a un nuevo servidor cloud

Plan seguro para migrar a un servidor cloud: checklists previa, de cambio y de reversión, comandos rsync y de base de datos, TTL del DNS y prueba con hosts.

Cómo elegir la ubicación de un servidor cloud en Europa 2026
clouddatacenternetworkinginfrastructure

Cómo elegir la ubicación de un servidor cloud en Europa 2026

Elige la ubicación de tu servidor cloud en Europa según audiencia, calidad de ruta y RGPD. Compara Praga y Covilha y prueba tú mismo la ruta con ping y mtr.

¿Cuánto cuesta un servidor cloud en Europa en 2026?
cloudhostingpricing

¿Cuánto cuesta un servidor cloud en Europa en 2026?

Coste de un servidor cloud en Europa en 2026: presets Small, Medium y Large desde 15 EUR/mes, ejemplo con Windows, por qué el plan barato sale caro, estimación.