Agents de code IA sur un VPS UE : configuration durcie
Réponse rapide : un agent de code sur un VPS demande de la puissance ordinaire, pas de GPU, car le modèle tourne chez le fournisseur. Le risque, c'est ce que l'agent peut atteindre, donc limitez-le : un conteneur jetable par tâche, sans root, système de fichiers racine en lecture seule, trafic sortant uniquement vers une liste autorisée, un jeton restreint en fichier, une limite de temps et un humain avant la production. Ci-dessous : un runner testé, un faux agent qui l'attaque (sortie réelle) et le prix. Ces réglages limitent ce qu'un agent détourné peut atteindre ; ils n'arrêtent pas l'injection de prompt. Vérifié le 2026-10-14.
Ce que vous hébergez, et ce que vous n'hébergez pas
Cas typiques : un dépôt sur un serveur, la revue de pull requests, des tests après chaque modification, des tâches planifiées. Tout cela relève du calcul de l'agent : la CLI exécute des commandes sur votre VPS et n'envoie que des prompts à une API de modèle, donc pas de GPU (héberger un modèle est un autre métier). Bases : Exécutez Claude Code, Codex et Grok CLI sur votre propre serveur cloud ; prompts : aide-mémoire.
Modèle de menace : jusqu'où va une instruction injectée
Tout ce que lit l'agent (fichiers du dépôt, issues, pull requests, pages web, sorties d'outils) est une entrée à laquelle il peut obéir. L'OWASP classe l'injection de prompt en LLM01:2025 et l'autonomie excessive en LLM06:2025 ; aucun modèle n'est immunisé, limitez donc ce qu'un agent détourné peut toucher.
| Une instruction injectée veut... | Atteignable dans une config naïve | Limite dans cette conception |
|---|---|---|
| Lire des secrets | env, ~/.ssh, autres dépôts | uid dédié, répertoire personnel vide en lecture seule, un dépôt, un fichier de jeton restreint |
| Exfiltrer des données | n'importe quel hôte d'Internet | aucune route sortante, proxy à liste autorisée, 403 pour le reste |
| Pousser ou fusionner du code malveillant | toute branche couverte par le jeton | jeton pour un dépôt, protection de branche, revue humaine |
| Atteindre des services cloud, du LAN ou de l'hôte | adresse de métadonnées, bases de données, services de l'hôte | aucune route sortante, aucune adresse de l'hôte sur le pont du bac à sable |
Les éditeurs s'accordent : la sécurité de Claude Code recommande des VM ou un dev container pour le contenu non fiable, son guide de durcissement demande un egress refusé par défaut et un conteneur par session, et la documentation de Codex coupe le réseau par défaut. Vérifié le 2026-10-14.
L'architecture
Une tâche est un projet Docker Compose : un conteneur d'agent et un petit proxy de sortie. L'agent est sur un réseau internal où l'hôte n'a pas d'adresse IPv4, sa seule issue est donc le proxy, qui ne répond que pour les hôtes de la liste autorisée. À la fin de la tâche, tout est détruit.

Le runner durci (testé)
Six petits fichiers ; seul Docker avec Compose est nécessaire. L'image contient un faux agent inoffensif ; en usage réel, installez la CLI de votre agent dans le Dockerfile et ajoutez à allowlist.txt les hôtes nécessaires (hôte git, registre de paquets, API du modèle). Un outil qui ignore HTTPS_PROXY échoue simplement.
Dockerfile : utilisateur non root, rien d'inscriptible
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, racine en lecture seule, deux réseaux, secret en fichier
# 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 : tout le reste est refusé
# one host name per line; everything else is refused
example.com
egress_proxy.py : proxy CONNECT à refus par défaut, bibliothèque standard seule
"""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 : créer, exécuter, masquer, vérifier, détruire
#!/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 : tente ce que tenterait un agent détourné
"""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 honnêtes et risque résiduel :
- L'agent peut lire son jeton et l'envoyer à n'importe quel hôte autorisé (un gist ou une issue sur github.com) : la portée du jeton reste le contrôle principal ; un proxy qui injecte l'identifiant hors du conteneur est plus solide, comme le décrit la page Agent runners ; non construit ici. Le journal JSON du démon garde aussi le jeton en clair (le faux agent l'affiche) jusqu'à
compose down; seul le journal conservé est masqué. - Les conteneurs partagent le noyau de l'hôte : pour du code hostile ou multi-locataire, prenez une VM par tâche ou gVisor (non testé ici).
- Le proxy est un point unique et minimal (résolution de noms bloquante, pas de délai d'inactivité ni de plafond de connexions, image fixée par étiquette) : contourné ou en panne, la conception s'affaiblit. Il filtre par nom, donc un CDN partagé ou du domain fronting sur un nom autorisé peut faire sortir des données, et il est sur un pont ordinaire qui atteint les services de l'hôte : protégez-le par le pare-feu (DOCKER-USER) si des services écoutent sur toutes les adresses.
- Gardez le démon Docker hors de portée de l'agent : y accéder revient à contrôler l'hôte (sécurité de Docker).
Pas à pas avec un jeton jetable (sortie réelle)
Environnement : Docker 29.7.2 avec Compose, Linux arm64, python:3.13-alpine (Python 3.13.16), exécuté le 2026-10-14. Le jeton est un faux évident ; le HTTPS sortant autorisé suppose un accès Internet depuis l'hôte. Le démon Docker doit voir le répertoire du projet et $TMPDIR (les bind mounts depuis /tmp ont échoué ici).
Placez les six fichiers dans un nouveau répertoire, créez un faux jeton et lancez une tâche :
printf 'fake_token_0000_do_not_use\n' > fake.token && chmod 600 fake.token
./run-task.sh fake.token
BLOCKED sur une action interdite est la réussite ; les lignes ALLOWED sont les légitimes. La ligne posture montre les valeurs par défaut de Docker : seccomp, no-new-privileges, aucune capability, AppArmor et un tmpfs /work monté noexec,nosuid,nodev. Le jeton affiché ressort masqué (préfixes de journal compose retirés) :
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
Le démontage est automatique : conteneurs, réseaux et répertoires temporaires disparaissent, seuls l'image partagée et les journaux masqués restent. Le script refuse un jeton qu'il ne peut pas masquer sans risque.
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
Une exécution témoin sans durcissement (root, bridge par défaut, mémoire plafonnée seulement pour protéger cette machine) montre que les contrôles détectent de vrais problèmes : 8 à 10 des 14 contrôles interdits réussissent (les deux tests Internet dépendent de la connectivité de l'hôte). Le reste est bloqué de toute façon (pas de proxy, pas de socket Docker, pas d'adresse de métadonnées ici, le plafond mémoire), ces lignes sont donc des contrôles de configuration, pas une preuve :
# 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)
L'option réseau compte. Sur un réseau --internal simple, l'hôte garde l'adresse de passerelle sur le pont et le conteneur a joint un service de l'hôte à l'écoute sur toutes les adresses (ici sshd, port 22). Avec inhibit_ipv4 (documenté, sans version minimale indiquée, testé sur 29.7.2), l'hôte n'a aucune adresse sur le pont, le réseau n'a pas de passerelle et la table de routage ne contient que le sous-réseau propre du conteneur. Le premier conteneur prend .1 : un test de .1 atteint donc ce conteneur, pas l'hôte. La preuve, ce sont les adresses et les routes, pas le refus :
# 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
Le jeton ne doit pas apparaître dans les arguments des processus, donc sed et grep le lisent via -f <(printf ...). Un échantillonnage de ps -eo args toutes les 100 ms pendant une exécution n'a trouvé aucune ligne avec le jeton ; le même échantillonneur en détecte un placé exprès dans les arguments :
( 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
Une limite de temps arrête un agent bloqué : timeout --foreground (900 s par défaut, TASK_TIMEOUT) termine l'exécution et le piège de démontage nettoie. Avec un faux agent qui se bloque exprès et une 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
Vous voulez ce runner sur une machine toujours en ligne ? Découvrir l'hébergement de runners d'agents en UE.
Checklist de sécurité : secrets, journaux et validation humaine
À relire avant qu'un runner touche un vrai dépôt.
| Contrôle | Réglage | Comment vérifier |
|---|---|---|
| Durcissement | USER 10001, read_only: true, seccomp, AppArmor, cap_drop: [ALL], no-new-privileges |
ligne posture : Seccomp=2, NoNewPrivs=1, CapEff=0 ; l'écriture dans /etc échoue |
| tmpfs de l'espace de travail | /work, 256 Mo, noexec,nosuid,nodev par défaut |
No space left on device à la limite. Les outils qui exécutent des fichiers de l'espace de travail (scripts .bin de npm, binaires compilés, virtualenvs) ont besoin de exec sur /work : un compromis assumé |
| Limites de ressources et de temps | 64 pids, 512 Mo sans swap, 1 CPU, timeout 900 s |
fork échoue, le glouton de mémoire est tué par l'OOM, un agent bloqué se termine avec le code 124 |
| Sortie réseau | réseau interne, pas d'adresse de l'hôte, proxy à liste autorisée | la connexion directe échoue, ip ne montre aucune adresse de l'hôte sur le pont, les autres hôtes reçoivent 403 |
| Jeton et journaux | à granularité fine, un dépôt, expiration courte (par exemple 7 jours), contents et pull requests seulement ; fichier dans /run/secrets, jamais dans l'image, l'environnement ni les arguments ; journaux masqués puis passés au crible |
docker history et ps -eo args ne montrent aucun jeton ; le script se termine avec le code 3 si un journal en contient un |
| Production et fusion | aucune clé de déploiement ni identifiant cloud ici ; l'agent ouvre une pull request, la protection de branche et la revue décident | env contient un jeton ; il ne peut pas pousser vers la branche protégée |
Conditions des éditeurs : ce qu'il ne faut pas faire
Utilisez vos propres identifiants pour votre propre travail. La page juridique de Claude Code d'Anthropic indique que la connexion OAuth est destinée aux titulaires d'un abonnement, que les développeurs de produits doivent utiliser des clés d'API et que faire transiter des requêtes par les identifiants d'abonnement d'autrui n'est pas permis ; sa politique d'utilisation interdit la création automatisée de comptes (la page de la politique indique une date d'entrée en vigueur au 2026-11-12). Les conditions d'utilisation d'OpenAI, dans les versions que nous avons pu lire (extraits de recherche ; la page bloque les récupérations automatiques), n'autorisent pas le partage des identifiants d'un compte ; confirmez-le dans un navigateur. Une clé ou une connexion par personne, jamais mutualisée ni revendue. Vérifié le 2026-10-14.
Dimensionnement, coût et configuration à choisir
Les prix sont ceux des offres DCXV Cloud à Prague avec une sauvegarde, calculés avec la formule du calculateur et comparés le 2026-10-14 au tableau en ligne de /cloud-server.
| Si... | Commencez par | Remarque |
|---|---|---|
| une personne, un agent, édition et tests unitaires | Small (2 vCPU / 4 Go / 40 Go), EUR 15.00 | c'est souvent la mémoire, pas le CPU, qui limite |
| un navigateur ou des tests de bout en bout | Medium (4 / 8 Go / 80 Go), EUR 18.43 | les navigateurs réclament des gigaoctets ; mesurez le pic |
| plusieurs agents en parallèle | Large (8 / 16 Go / 160 Go), EUR 36.88, ou plusieurs runners | un conteneur par tâche ; pic fois concurrence |
| longs builds, CI, clés de déploiement | un serveur de build séparé | l'hôte de l'agent ne détient jamais d'identifiants de déploiement |
| vous servez le modèle vous-même | un sujet GPU : voir l'article sur l'inférence | un agent qui appelle l'API d'un fournisseur n'en a pas besoin |
Mesurez, ne devinez pas : lisez le pic dans le cgroup du conteneur avant qu'il s'arrête, par exemple avec cat /sys/fs/cgroup/memory.peak (une allocation de 100 Mo dans un conteneur de 512 Mo a donné 105263104 octets), et docker stats --no-stream pour le CPU. La taille de l'hôte, c'est la mémoire de pointe par tâche fois les tâches simultanées, plus une marge pour le système.
Deux factures : le VPS est un prix mensuel fixe chez DCXV ; l'usage du modèle, les jetons et les abonnements sont facturés par le fournisseur du modèle, pas par DCXV, et décident souvent du total. Emplacements : Prague (CZ) ou Covilhã (PT).
En résumé
Un runner d'agent plus sûr, ce sont quelques réglages sans éclat appliqués à chaque fois. Ils limitent ce qu'un agent détourné peut atteindre ; ils n'arrêtent pas l'injection de prompt.
Découvrir l'hébergement de runners d'agents en UE ou configurer un serveur cloud en UE.
