Sécurité et sandboxing
Sur cette page
Leçon réécrite le 3 novembre 2026 d’après le guide Computer use d’OpenAI, ses Computer use integration recipes et la documentation Docker et réseau de Playwright. Ajouts : les quatre règles de sécurité d’OpenAI, ses niveaux de confirmation et l’acquittement des
pending_safety_checks. Corrections : l’ancienne version coupait le réseau d’un conteneur qui devait appeler l’API, laissait le proxy sans accès sortant, acceptaitnotexample.comdans un filtre censé n’autoriser queexample.com, cassait le formatage des journaux et estimait les coûts avec un tarif unique codé en dur. Sources : Computer use · Integration recipes · Playwright et Docker · Playwright, réseau.
Donner le contrôle à une IA : les risques
Computer Use donne à un modèle d’IA la capacité d’agir sur un ordinateur. C’est puissant mais potentiellement dangereux. Un agent mal configuré pourrait naviguer vers des sites malveillants, exécuter du code arbitraire, ou exposer des données sensibles. Le scénario n’a rien de théorique : il suffit qu’une page visitée contienne des instructions rédigées à l’intention du modèle pour que celui-ci, s’il n’est pas contraint par son environnement, les suive comme s’il s’agissait de votre demande. La sécurité n’est pas optionnelle — c’est le fondement de toute mise en production.
Les quatre règles d’OpenAI
OpenAI résume la sécurité de Computer Use en quatre règles, à appliquer dans votre application et dans l’environnement d’exécution, pas seulement dans les instructions du modèle :
- Restreindre l’environnement. Un navigateur ou une machine virtuelle isolés, une liste blanche de sites et d’actions, des accès limités à ce que la tâche exige.
- Traiter le contenu de l’écran comme non fiable. Le texte d’une page, d’un document ou d’un résultat d’outil ne peut ni accorder une permission ni remplacer les instructions de l’utilisateur. Si une page ressemble à du hameçonnage ou à une injection de prompt, l’agent s’arrête et demande comment continuer.
- Confirmer les actions qui engagent. Achats, transmission de données, modifications destructrices et tout ce qui est difficile à annuler restent sous le contrôle de l’utilisateur. Taper une information sensible dans un formulaire compte déjà comme une transmission.
- Borner et vérifier l’exécution. Des limites de tours, de durée ou de coût, la possibilité d’annuler, et le contrôle du résultat réel plutôt que du seul message final du modèle.
Le point de départ est simple : la demande d’agir que formule le modèle n’est pas une permission de l’utilisateur. C’est votre code qui décide d’exécuter, de suspendre en attendant une approbation, ou de passer la main. Pour un lot d’actions, il s’arrête avant la première qui exige une confirmation.
Trois niveaux de confirmation
OpenAI distingue trois situations, avec des exemples que vous pouvez reprendre dans votre politique :
| Niveau | Exemples cités par OpenAI |
|---|---|
| L’utilisateur reprend la main | Dernière étape d’un changement de mot de passe ; contournement d’une barrière de sécurité, comme un avertissement HTTPS |
| Confirmation au moment de l’action | Suppression de données, changement de permissions ou de partage, CAPTCHA, installation ou exécution d’un logiciel téléchargé, envoi ou publication au nom de l’utilisateur, transaction financière, réglages système |
| Pré-approbation possible dans la demande initiale | Connexion à un site que l’utilisateur a demandé de visiter, envoi de fichiers, déplacement ou renommage de fichiers, transmission d’une donnée sensible dont l’usage précis a été approuvé |
Sans approbation claire dans la demande initiale, on confirme juste avant l’action. Et l’on ne demande pas trop tôt : l’agent avance sur tout ce qui est sûr, puis s’arrête au point de risque en expliquant l’action, le risque et la façon dont la donnée ou le changement sera appliqué. Si l’utilisateur refuse, rien n’est exécuté, et votre code indique au modèle ce qui a tourné et ce qui n’a pas tourné.
Le principe du moindre privilège
Votre agent ne devrait avoir accès qu’au strict minimum nécessaire pour accomplir sa tâche. Cela commence par un navigateur isolé, jamais le navigateur principal de l’utilisateur, car celui-ci porte les sessions ouvertes de la messagerie, de la banque et des outils internes. L’agent n’a pas non plus besoin du système de fichiers, sauf d’un dossier dédié où déposer ses téléchargements, ni d’un accès au réseau interne tant qu’aucune application métier n’y est visée, ni de droits administrateur qui transformeraient une erreur de clic en incident système. Ne montez dans son environnement ni vos fichiers personnels, ni vos identifiants, ni le socket Docker. Enfin, la session doit être éphémère : les données sont supprimées après chaque exécution, ce qui garantit qu’un workflow ne réutilise jamais par accident les cookies ou le cache du précédent.
Sandboxing avec Docker
Deux questions se posent : où tourne le navigateur, et où vivent la boucle et la clé d’API. La recette Docker d’OpenAI isole un bureau dans un conteneur sans réseau, piloté depuis l’hôte par docker exec : seul le SDK, côté hôte, lit la clé. C’est la configuration la plus étanche, mais elle ne convient qu’aux tâches qui n’ont pas besoin d’Internet ; pour les autres, OpenAI demande un environnement dont la sortie réseau n’autorise que les sites de la tâche.
Si vous gardez Playwright et que l’agent doit naviguer, vous pouvez réunir boucle et navigateur dans un même conteneur, à trois conditions : un utilisateur non privilégié, un navigateur qui n’hérite pas des variables d’environnement (donc pas de la clé), et une sortie réseau réduite aux sites de la tâche et à l’API.
# Image Playwright pour Python : navigateurs et dépendances système inclus
FROM mcr.microsoft.com/playwright/python:v1.64.1-noble
WORKDIR /app
# Le paquet playwright n'est pas dans l'image : requirements.txt
# épingle playwright==1.64.1, la même version que l'image
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
# Utilisateur non privilégié fourni par l'image
USER pwuser
CMD ["python", "src/main.py"]
Playwright recommande d’épingler la version de l’image, et l’image fournit les navigateurs, pas la bibliothèque Python : installez la même version du paquet playwright. Ne lancez pas le conteneur en root, ce qui désactive le bac à sable de Chromium. Pour des sites non fiables, Playwright recommande en outre son profil seccomp, qui ajoute au profil Docker par défaut les appels système dont ce bac à sable a besoin.
docker run --rm --init \
--network agent-net \
--security-opt seccomp=seccomp_profile.json \
--ipc=host \
--memory=2g --cpus=1.5 --pids-limit=256 \
-e OPENAI_API_KEY \
computer-use-agent
--init évite les processus zombies et --ipc=host, recommandé par Playwright, empêche Chromium de manquer de mémoire partagée et de planter. Ce dernier réglage fait toutefois partager au conteneur l’espace IPC de l’hôte ; si vous préférez ne pas l’ouvrir, agrandissez plutôt la mémoire partagée avec --shm-size, comme le fait la recette d’OpenAI pour son propre conteneur, et testez sur vos pages les plus lourdes. Enfin, -e OPENAI_API_KEY sans valeur reprend la variable de votre session : la clé n’apparaît pas en clair dans la ligne de commande.
Côté Python, le navigateur est lancé avec les précautions de la recette d’OpenAI — bac à sable actif, environnement vide, extensions et accès aux fichiers désactivés — et passe par le proxy de la section suivante, tout comme le client d’API :
from openai import DefaultHttpx2Client, OpenAI
from playwright.sync_api import sync_playwright
PROXY = "http://agent-proxy:3128"
# Les appels à l'API sortent par le proxy filtrant
client = OpenAI(http_client=DefaultHttpx2Client(proxy=PROXY))
pw = sync_playwright().start()
browser = pw.chromium.launch(
headless=True,
chromium_sandbox=True,
env={}, # le navigateur n'hérite d'aucune variable, donc pas de la clé
args=["--disable-extensions", "--disable-file-system"],
proxy={"server": PROXY},
)
context = browser.new_context(
viewport={"width": 1280, "height": 720},
service_workers="block", # voir le filtrage des URL plus bas
)
page = context.new_page()
Restrictions réseau ciblées
Le conteneur de l’agent est placé sur un réseau Docker interne, sans accès à l’extérieur. Un proxy Squid le rejoint et devient sa seule porte de sortie ; lui seul est aussi branché sur un réseau ordinaire. Tout ce qui n’est pas explicitement autorisé n’existe pas du point de vue de l’agent.
# Réseau interne : aucun accès direct à l'extérieur
docker network create --internal agent-net
# Réseau de sortie, réservé au proxy
docker network create sortie-proxy
# Proxy Squid (image maintenue par Canonical) avec votre liste blanche
docker run -d --name agent-proxy --network sortie-proxy \
-v "$PWD/squid.conf:/etc/squid/squid.conf:ro" \
ubuntu/squid:6.6-24.04_beta
# Le proxy rejoint le réseau interne : c'est la seule sortie de l'agent
docker network connect agent-net agent-proxy
# squid.conf : n'autoriser que les domaines de la tâche et l'API
http_port 3128
acl autorises dstdomain .example.com api.openai.com
http_access allow autorises
http_access deny all
Le point clé : le proxy est branché sur les deux réseaux. Lancé sur le seul réseau interne, il n’aurait lui-même aucune sortie. api.openai.com figure dans la liste parce que, dans cette organisation, le code de l’agent appelle l’API depuis le conteneur. Le navigateur, lui, n’en a pas besoin : le filtre d’URL de la section suivante le lui refuse.
Filtrage des URLs
Une seconde barrière, plus fine, se pose dans le navigateur lui-même. En interceptant les requêtes avec page.route, vous comparez chaque URL à la liste propre à la tâche — souvent plus courte que celle du proxy — et vous abandonnez le reste, avec une trace lisible de chaque blocage. Attention au piège des comparaisons de chaînes, contre lequel OpenAI met en garde : https://example.com.evil.test ne doit pas passer pour https://example.com, et à l’inverse notexample.com se termine bien par example.com. Comparez le schéma, l’hôte exact (ou un sous-domaine précédé d’un point) et le port.
from urllib.parse import urlparse
def setup_url_filter(page, allowed_domains: list[str]):
"""Bloque les requêtes vers des destinations non autorisées."""
def autorisee(url: str) -> bool:
parties = urlparse(url)
hote = parties.hostname or ""
return (
parties.scheme == "https"
and parties.port in (None, 443)
and any(hote == d or hote.endswith("." + d) for d in allowed_domains)
)
def check_navigation(route):
if autorisee(route.request.url):
route.continue_()
else:
print(f" BLOQUÉ : {route.request.url}")
route.abort()
page.route("**/*", check_navigation)
# Le navigateur n'a besoin que des sites de la tâche : les appels
# à l'API partent de votre code Python, pas de la page.
setup_url_filter(page, ["example.com"])
Cette couche complète le proxy, elle ne le remplace pas. Playwright précise que page.route n’intercepte pas les requêtes traitées par un service worker, d’où le service_workers="block" du contexte, et les WebSockets relèvent d’un autre mécanisme (page.route_web_socket). Le filtrage réseau reste votre barrière principale.
Protection des données sensibles
Masquer les informations dans les screenshots
Chaque capture d’écran part vers l’API : tout ce qui est visible à l’écran quitte donc votre infrastructure. OpenAI recommande de garder les identifiants hors du texte des pages et des captures. Si l’écran contient malgré tout des données sensibles, masquez-les avant d’envoyer la capture au modèle. Un rectangle noir appliqué sur les zones connues suffit, à condition de connaître leurs coordonnées — d’où l’intérêt de standardiser la taille du viewport, comme vu plus tôt dans ce cours. Le masquage ne change pas la taille de l’image : les coordonnées renvoyées par le modèle restent valables.
import io
from PIL import Image, ImageDraw
def mask_sensitive_areas(screenshot_bytes: bytes, regions: list) -> bytes:
"""Masque des zones sensibles dans une capture PNG."""
img = Image.open(io.BytesIO(screenshot_bytes))
draw = ImageDraw.Draw(img)
for x, y, w, h in regions:
draw.rectangle([x, y, x + w, y + h], fill="black")
buffer = io.BytesIO()
img.save(buffer, format="PNG")
return buffer.getvalue()
Savoir ce que deviennent les captures
Les réponses de la Responses API sont stockées par défaut : c’est ce qui permet de les chaîner par previous_response_id. Avec store: false, il faut renvoyer soi-même les éléments des tours précédents. OpenAI rappelle aussi que store: false ne supprime pas toute forme de conservation et que les images en entrée ont leurs propres exceptions : lisez ses contrôles de données avant d’envoyer des écrans qui contiennent des données personnelles.
Ne jamais logger les données sensibles
Le même raisonnement vaut pour les journaux, qui survivent souvent bien plus longtemps que les captures. Un filtre de logging réécrit les motifs à risque avant écriture : les affectations de mot de passe, les clés d’API OpenAI, qui commencent par sk-, et les suites de seize chiffres qui ressemblent à un numéro de carte. Le filtre agit sur le message final, il attrape donc aussi ce que vous avez loggé sans y penser.
import logging
import re
class SensitiveFilter(logging.Filter):
"""Masque les données sensibles avant l'écriture des journaux."""
PATTERNS = [
(re.compile(r"password['\"]?\s*[:=]\s*['\"]?[^\s,}]+", re.IGNORECASE), "password=***"),
(re.compile(r"\bsk-[A-Za-z0-9_-]{20,}"), "sk-***"),
(re.compile(r"\b\d{16}\b"), "****-****-****-****"),
]
def filter(self, record):
msg = record.getMessage()
for pattern, replacement in self.PATTERNS:
msg = pattern.sub(replacement, msg)
record.msg = msg
record.args = None # le message est déjà formaté : ne pas le reformater
return True
handler = logging.StreamHandler()
handler.addFilter(SensitiveFilter()) # sur le handler, il voit aussi les sous-loggers
logging.basicConfig(level=logging.INFO, handlers=[handler])
Deux détails font la différence. Remettre record.args à None est indispensable : sans cela, le message déjà formaté serait reformaté avec ses arguments, et le moindre % dans une URL ferait échouer l’écriture. Et un filtre posé sur le handler s’applique à tout ce qu’il écrit, alors qu’un filtre posé sur un logger ignore les messages de ses sous-loggers.
Les vérifications de sécurité de l’API
Chaque computer_call porte un champ pending_safety_checks : la liste des vérifications de sécurité que l’API vous demande de traiter avant d’exécuter les actions, chacune avec un id, un code et un message. L’exemple officiel d’OpenAI s’arrête dès qu’elle n’est pas vide. Si votre application sait faire valider ces cas par un humain, elle les acquitte explicitement en les renvoyant dans acknowledged_safety_checks, avec la capture qui suit. Le modèle ne peut pas donner cette approbation à votre place, et l’acquittement vient avant l’exécution des actions, jamais après.
def valider_verifications(appel) -> list[dict]:
"""Soumet les vérifications en attente à un opérateur ; refuse par défaut."""
if not appel.pending_safety_checks:
return []
for verification in appel.pending_safety_checks:
print(f"Vérification de sécurité [{verification.code}] : {verification.message}")
if input("Autoriser ces actions ? [o/N] ").strip().lower() != "o":
raise RuntimeError("Actions refusées par l'opérateur")
return [
{"id": v.id, "code": v.code, "message": v.message}
for v in appel.pending_safety_checks
]
# Dans la boucle, avant d'exécuter les actions :
acquittees = valider_verifications(appel)
executer_actions(page, appel.actions)
sortie = {
"type": "computer_call_output",
"call_id": appel.call_id,
"output": {
"type": "computer_screenshot",
"image_url": f"data:image/png;base64,{capturer_ecran(page)}",
"detail": "original",
},
}
if acquittees:
sortie["acknowledged_safety_checks"] = acquittees
Limites d’exécution
Reste le risque le plus banal : l’agent qui déraille sans rien casser, mais qui tourne pendant deux heures et brûle votre budget. La classe ExecutionLimits matérialise trois plafonds — tours, durée et coût estimé — et lève une exception dès que l’un d’eux est atteint. Les tarifs ne sont pas codés en dur : vous les reprenez du modèle utilisé sur la page de tarification d’OpenAI, car ils changent d’un modèle à l’autre.
import time
class ExecutionLimits:
"""Impose des plafonds de tours, de durée et de coût à l'agent."""
def __init__(
self,
prix_entree_par_million: float, # tarifs du modèle utilisé
prix_sortie_par_million: float,
max_tours: int = 50,
max_duree_s: int = 300,
max_cout_usd: float = 5.0,
):
self.prix_entree = prix_entree_par_million
self.prix_sortie = prix_sortie_par_million
self.max_tours = max_tours
self.max_duree = max_duree_s
self.max_cout = max_cout_usd
self.debut = time.monotonic()
self.tours = 0
self.cout = 0.0
def verifier(self):
"""À appeler avant chaque requête : lève une erreur si un plafond est atteint."""
if self.tours >= self.max_tours:
raise RuntimeError(f"Limite de tours atteinte ({self.max_tours})")
if time.monotonic() - self.debut >= self.max_duree:
raise RuntimeError(f"Durée maximale dépassée ({self.max_duree} s)")
if self.cout >= self.max_cout:
raise RuntimeError(f"Budget dépassé ({self.cout:.2f} $)")
def compter(self, response):
"""À appeler après chaque réponse : cumule les tours et le coût estimé."""
self.tours += 1
self.cout += response.usage.input_tokens / 1_000_000 * self.prix_entree
self.cout += response.usage.output_tokens / 1_000_000 * self.prix_sortie
Ce n’est qu’une estimation : elle surestime les tokens d’entrée servis depuis le cache de prompts, facturés à prix réduit, et ignore un éventuel environnement hébergé, facturé à part. Comparez-la régulièrement à l’usage réel de votre projet. Réglez les plafonds par workflow : un test E2E de trente secondes et une saisie de factures d’une heure n’appellent pas les mêmes seuils.
Checklist sécurité production
Avant de déployer un agent Computer Use en production, vérifiez :
- Navigateur ou bureau isolé (conteneur ou machine virtuelle), utilisateur non privilégié
- Sortie réseau limitée aux sites de la tâche, et à l’API si la boucle tourne dans le même environnement
- Filtre d’URL qui compare schéma, hôte et port, service workers bloqués
- Clé d’API hors du code et hors de portée du navigateur (
env={}) - Contenu de l’écran traité comme non fiable : arrêt et escalade au moindre signe d’injection
- Confirmation humaine avant achats, envois, suppressions et saisie de données sensibles
pending_safety_checksjamais acquittées sans validation humaine- Limites de tours, de durée et de coût
- Journaux filtrés, captures non conservées (ou chiffrées)
- Aucun fichier personnel, identifiant ni socket Docker monté dans l’environnement
- Résultat vérifié dans l’application avant d’annoncer le succès
- Mécanisme d’arrêt d’urgence (kill switch)
Le dernier point est celui qu’on oublie le plus souvent, et c’est pourtant le seul qui vous serve quand tous les autres ont échoué. Un kill switch est un moyen d’interrompre l’agent immédiatement, sans attendre qu’il atteigne l’une de ses limites : la présence d’un fichier STOP que la boucle vérifie à chaque itération, un docker stop sur le conteneur, ou un drapeau lu dans une variable partagée. Testez-le avant la mise en production, pas le jour où vous en aurez besoin.
Points clés à retenir
- Les quatre règles d’OpenAI : restreindre l’environnement, traiter l’écran comme non fiable, confirmer les actions qui engagent, borner et vérifier l’exécution
- La demande d’agir du modèle n’est pas une permission : votre code exécute, suspend ou passe la main
- Isolez le navigateur (utilisateur non privilégié, profil seccomp) et ne laissez sortir le trafic que vers les sites de la tâche
- Filtrez les URL en comparant schéma, hôte et port, jamais par un simple préfixe ou suffixe de chaîne
- N’acquittez les
pending_safety_checksqu’après validation humaine - Imposez des limites de tours, de durée et de coût, et vérifiez le résultat réel