# Les frontières de sécurité de MCP stdio pour les agents IA locaux

Les serveurs MCP locaux sont souvent considérés comme inoffensifs parce qu'ils communiquent avec stdio et fonctionnent sur le même Mac que l'agent. Cette conclusion ne tient plus dès que le serveur peut appeler une API externe, utiliser SSH, lire un fichier d'identifiants ou lancer une commande shell avec les droits du développeur. Le transport local supprime un saut réseau. Il ne réduit pas les droits du processus qui reçoit les requêtes.

La frontière importante est simple : un serveur MCP doit exposer du contexte et des opérations strictement limitées ; une passerelle d'actions de confiance doit détenir les identifiants et effectuer les actions qui sortent de la machine. Mélanger ces rôles donne à un modèle de langage un chemin pratique entre des instructions non fiables et une autorité durable. J'ai vu cette erreur se présenter sous la forme d'un outil bien rangé dans un seul fichier, puis devenir un fourre-tout de jetons, d'appels de sous-processus et d'exceptions que personne ne sait expliquer pendant un incident.

## Stdio est un transport, pas une décision de confiance

La sécurité MCP stdio commence par un constat : l'entrée et la sortie standard n'authentifient pas l'intention. Le client MCP lance le serveur et échange des messages JSON-RPC par des tubes. La spécification du Model Context Protocol décrit stdio comme un transport : le serveur lit les messages sur l'entrée standard et écrit les réponses sur la sortie standard. Elle ne prétend pas que ce transport prouve qu'une requête est sûre, approuvée par une personne ou même produite par le modèle attendu.

Un client local peut envoyer directement une requête `tools/call`. Il peut contourner la boucle habituelle du modèle, répéter les appels à la vitesse de la machine, choisir des arguments déconseillés dans la description de l'outil et conserver chaque résultat reçu. Si une extension d'éditeur compromise lance le client, le serveur n'a aucun moyen magique de savoir qu'il ne s'agit pas de Claude Code ou d'un autre appelant attendu, à moins que l'architecture environnante lui fournisse cette information.

L'arbre des processus habituel offre également moins d'isolation qu'on ne le pense. Si votre agent démarre un serveur MCP sous votre compte, ce serveur hérite généralement de votre identité utilisateur, de son répertoire de travail, de son environnement, de ses permissions sur les fichiers, de son accès réseau et des secrets éventuellement laissés dans les variables d'environnement. Un tube ne diminue pas cette autorité.

Traitez chaque appel d'outil comme une requête non fiable provenant d'un processus qui a convaincu le modèle, ou usurpé son identité, de l'effectuer. Cela paraît strict parce que ça l'est. C'est aussi l'hypothèse qui résiste à l'injection de prompt, aux clients défectueux, aux configurations copiées et au développeur qui teste une requête avec un script JSON-RPC brut.

## Le serveur doit s'arrêter avant l'autorité réutilisable

Un serveur MCP doit s'arrêter avant d'avoir besoin de révéler ou de gérer un identifiant réutilisable. Ses bons usages consistent notamment à rechercher dans un dépôt indexé, analyser une sortie de compilation, mettre en forme une charge utile, lire un fichier de projet délibérément exposé et produire une commande à soumettre à approbation. Ces tâches peuvent encore causer des dommages si elles sont mal écrites, mais elles n'exigent pas de secret qui resterait utile après la fin de la session.

Les actions externes ont besoin d'un autre responsable. Une requête API authentifiée, une connexion SSH, une publication de paquet, une requête en production ou une mise à jour d'incident associe une entrée non fiable à une identité qui peut avoir des conséquences. Placez l'identifiant dans un composant qui exécute lui-même la requête, puis renvoyez un résultat limité au serveur MCP ou à l'agent.

Cette distinction est souvent brouillée parce que les deux composants peuvent être des exécutables locaux. Ils ne sont pourtant pas interchangeables :

- Un serveur MCP transforme une requête de l'agent en opération limitée, ou en demande d'opération.
- Une passerelle d'actions détient l'identifiant, décide si ce processus peut l'utiliser, exécute l'appel externe et consigne ce qui s'est passé.
- L'agent reçoit le résultat, pas le moyen de répéter l'action authentifiée en dehors de cette passerelle.

N'envoyez pas `API_TOKEN=...` comme résultat d'un outil. N'envoyez pas une référence de coffre en prétendant que cela suffit à assurer la sécurité. N'exposez pas une commande qui affiche une clé privée sur la sortie standard en espérant que le modèle détournera le regard. Dès que le client obtient un secret, tous les contrôles ultérieurs deviennent consultatifs.

Une passerelle ne doit pas non plus devenir une API shell universelle. `run(command)` est un raccourci tentant parce qu'il évite de concevoir des outils. Il confie aussi l'analyse des arguments, l'accès aux fichiers, les destinations réseau et souvent l'accès aux secrets à une seule chaîne opaque. Concevez plutôt des actions précises : `get_deployment_status`, `create_issue`, `run_readonly_query` ou `ssh_exec` avec un hôte nommé et une famille de commandes limitée. Des actions étroites rendent la validation et la revue possibles.

## Les schémas d'outils décrivent les appels, mais ne les confinent pas

Un schéma JSON pour un outil est utile pour valider les entrées, mais ce n'est pas une autorisation. La spécification MCP exige que les outils publient des schémas d'entrée, que les clients peuvent utiliser pour former les appels. Un modèle peut toujours choisir toute valeur valide selon le schéma. Plus grave encore, des implémentations négligentes acceptent une chaîne conforme au schéma, puis l'insèrent dans une commande shell ou une URL où sa signification change.

Prenons un outil destiné à récupérer l'état d'un déploiement :

```json
{
  "name": "deployment_status",
  "inputSchema": {
    "type": "object",
    "properties": {
      "environment": {"enum": ["staging", "production"]},
      "service": {"type": "string", "pattern": "^[a-z0-9-]{1,48}$"}
    },
    "required": ["environment", "service"],
    "additionalProperties": false
  }
}
```

Ce schéma empêche l'ajout d'un champ de premier niveau inattendu et rejette les signes de ponctuation shell évidents dans `service`. Il n'autorise pas l'appelant à consulter la production, ne prouve pas que `service` appartient au dépôt actuel et ne limite pas la destination HTTP une fois que le serveur a construit une URL. Un validateur de schéma répond à la question « La forme est-elle correcte ? » L'autorisation répond à la question « Cet appelant peut-il effectuer cette action maintenant avec cette identité ? » Gardez ces questions séparées dans le code comme dans la revue.

Une mauvaise implémentation ressemble souvent à ceci :

```python
subprocess.run(
    f"ssh {host} systemctl status {service}",
    shell=True,
    check=True,
)
```

Même si `host` et `service` ont passé un schéma peu strict, l'analyse shell crée un autre langage avec une autre surface d'attaque. Utilisez des vecteurs d'arguments, refusez les hôtes inconnus avant d'ouvrir une connexion et faites choisir l'identifiant par la passerelle à partir d'un identifiant fixe, plutôt que d'accepter un chemin ou un nom de jeton fourni par l'agent.

Pour HTTP, analysez l'URL avant la connexion, exigez `https`, comparez le nom d'hôte normalisé à une liste exacte d'hôtes approuvés et désactivez les redirections ou validez-les à nouveau. Une redirection depuis un hôte autorisé vers une adresse interne ou un point de terminaison contrôlé par un attaquant peut transformer une requête apparemment inoffensive en divulgation d'identifiants. Ne vous fiez pas à un contrôle de préfixe comme `url.startswith("https://api.example.com")` : les informations utilisateur, les ports et les noms d'hôte ressemblants rendent les vérifications textuelles peu fiables.

## L'identité du processus doit être visible au moment de l'approbation

Un bouton d'approbation humaine n'est utile que s'il indique qui demande l'accès et quelle autorité cette approbation accorde. « Autoriser l'accès de l'agent » est une formulation faible, car elle cache l'exécutable qui recevra la permission et la durée de cette permission. Elle habitue les utilisateurs à approuver une catégorie d'activité vague.

Une meilleure conception identifie le processus appelant par son autorité de signature, sa relation avec le processus parent, son chemin d'exécutable et sa durée de vie. La personne peut alors approuver une exécution d'un client connu au lieu de bénir définitivement une étiquette. Lorsque le processus se termine, son approbation doit prendre fin. Un nouveau processus nécessite une nouvelle décision.

La signature du code ne prouve pas que chaque prompt ou extension du client est inoffensif. Elle répond à une question plus étroite, mais toujours utile : quel exécutable signé a demandé cette autorité ? Cette distinction compte lorsqu'un programme local malveillant ou modifié tente de reprendre un nom rassurant. Sur macOS, le système d'exploitation fournit les informations de signature que la passerelle peut afficher avant d'autoriser une action.

Sallyport utilise cette identité de processus pour l'autorisation par session, tandis que son coffre verrouillé refuse toute action jusqu'à ce que l'utilisateur l'ouvre au moyen des contrôles matériels de sécurité du Mac. Ce modèle reste volontairement limité : un coffre verrouillé, une approbation pour chaque nouveau processus et, en option, une approbation pour chaque utilisation d'un identifiant donné.

N'essayez pas de résoudre la lassitude liée aux approbations avec une politique complexe en langage naturel. Les utilisateurs ne peuvent pas évaluer de manière fiable un ensemble de règles dense après l'ajout de dizaines d'exceptions. Préférez quelques décisions correspondant à des éléments visibles pour un développeur : les secrets sont-ils disponibles, quel processus peut agir pour cette exécution et quels identifiants nécessitent une confirmation à chaque utilisation.

## La portée de l'approbation doit suivre les dommages possibles

Une approbation par session convient aux tâches répétitives et peu risquées, comme la lecture d'un outil de suivi des incidents ou la vérification de l'état d'un service de développement. Elle devient dangereuse lorsque cette même approbation couvre silencieusement des modifications destructrices de base de données, la publication de paquets, des mouvements d'argent, des communications avec des clients ou un accès SSH à la production.

Donnez à chaque identifiant son propre niveau de sensibilité pour les approbations. Un jeton en lecture seule peut fonctionner après l'approbation du processus pour la session. Un jeton d'écriture en production ou une clé SSH doit demander une confirmation à chaque utilisation. La passerelle doit afficher suffisamment de contexte pour qu'une personne puisse évaluer l'action : identité de l'identifiant, hôte ou service cible, méthode ou classe de commande et arguments nettoyés. N'affichez jamais le secret lui-même.

Une approbation doit autoriser une requête concrète, pas une promesse selon laquelle l'agent se comportera correctement plus tard. Si un appel d'outil indique `POST /releases`, la vue d'approbation ne doit pas le résumer en « utiliser l'API des versions ». La méthode, la destination finale et le nom de l'opération sont les faits qui distinguent une lecture inoffensive d'une écriture irréversible.

L'alternative courante consiste à utiliser une liste d'autorisation large : approuver un domaine, un binaire shell ou un agent pour toute la journée de travail. Cela semble efficace jusqu'à ce qu'une instruction injectée oriente cette même capacité vers un autre dépôt, un autre point de terminaison ou d'autres arguments. Les autorisations larges réduisent les interruptions en reportant la charge de la revue au moment où personne ne peut voir l'appel réel.

Utilisez largement les expirations. Une autorisation de session doit disparaître avec le processus client. Une décision par appel doit expirer après cette opération. Si une passerelle doit plus tard prendre en charge des autorisations plus longues, indiquez clairement leur portée et leur expiration au lieu de laisser une approbation mise en cache se faire passer pour une confiance permanente.

## SSH a besoin de sa propre frontière, pas d'une échappatoire shell

C'est avec SSH que les conceptions d'agents locaux perdent souvent toute rigueur. Les développeurs disposent déjà d'un agent SSH, d'alias d'hôtes, de clés transférées et de l'habitude de saisir des commandes arbitraires dans un terminal. La tentation est donc de laisser le serveur MCP appeler `ssh` avec l'environnement existant de l'utilisateur. L'agent devient alors appelant de chaque identité et de chaque règle d'hôte accessible depuis le shell.

OpenSSH documente une limite sérieuse du transfert de l'agent : un utilisateur distant qui peut accéder au socket de l'agent transféré peut demander des opérations à votre agent local, même s'il ne peut pas extraire les clés privées. Cela suffit pour agir en votre nom pendant la durée du transfert. Un agent autonome ne doit pas emprunter ce chemin sans précaution, car il élargit l'autorité au-delà de l'hôte d'origine.

Utilisez une identité SSH dédiée au travail de l'agent et associez-la à une fiche d'hôte nommée. Sur le serveur, limitez cette identité avec les options adaptées au compte, comme une commande forcée et la désactivation du transfert lorsque le cas d'utilisation le permet. Côté local, sélectionnez l'hôte et l'identité depuis une configuration hors du contrôle de l'agent. L'agent peut demander `host: build-staging` et une action de commande limitée, mais il ne doit pas soumettre un nom d'hôte arbitraire, un chemin de clé privée ou `-o ProxyCommand=...`.

Voici la forme minimale d'une requête qu'une passerelle d'actions peut valider :

```json
{
  "action": "ssh_exec",
  "host_id": "build-staging",
  "command_id": "read_service_status",
  "args": {"service": "worker"}
}
```

La passerelle associe `build-staging` à son hôte connu, à sa politique de clé d'hôte, à son compte et à son identifiant dédié. Elle associe `read_service_status` à un vecteur d'arguments fixe. Elle ne concatène pas cette charge utile dans une chaîne shell. Une requête refusée doit indiquer pourquoi dans un enregistrement d'audit, sans afficher de secrets ni de données potentiellement hostiles dans un terminal.

Si vous avez besoin d'un diagnostic distant arbitraire, faites-en une opération séparée, très encadrée, avec une confirmation par appel et des limites de sortie évidentes. Ne dissimulez pas un accès shell arbitraire sous un nom d'outil rassurant comme `check_server`.

## Les enregistrements d'audit doivent survivre à l'acteur qui les a provoqués

Un journal texte écrit par le même processus que celui qui réalise l'action ne constitue une preuve que jusqu'au moment où ce processus souhaite le modifier. L'activité d'un agent nécessite un enregistrement qui permet de reconstituer l'exécution et chaque appel externe, puis de détecter toute suppression ou modification ultérieure.

Consignez l'identité du processus, l'identifiant de session, l'heure, le type d'action, l'identifiant de l'accès, la cible approuvée, la forme nettoyée de la requête, le résultat de l'approbation, le statut de la réponse et la catégorie d'erreur. Séparez le journal de session du journal des actions. La vue de session répond à « Quelle exécution d'agent disposait de l'autorité ? » La vue des actions répond à « Qu'a-t-elle fait avec cette autorité ? » Ne forcez pas l'enquêteur à déduire l'un à partir d'un flux de lignes unique.

Une chaîne de hachage fournit un contrôle d'intégrité pratique. Pour chaque enregistrement, calculez un condensat à partir du condensat de l'enregistrement précédent et des octets canoniques du nouvel enregistrement chiffré. Stockez le nouveau condensat avec l'enregistrement. Un vérificateur peut ainsi détecter une modification, une suppression au milieu ou une réorganisation de la séquence sans avoir besoin du texte en clair.

Le vérificateur d'audit doit fonctionner indépendamment de l'agent et ne doit pas avoir accès aux identifiants. L'interface de commande peut être aussi simple que ceci :

```text
$ sp audit verify
records: 184
first sequence: 1
last sequence: 184
chain: valid
```

Cette forme de sortie donne à l'opérateur des informations précises à joindre à un ticket ou à un rapport d'incident. Si la vérification échoue, la commande doit signaler la première séquence où la continuité a été rompue et retourner un code de sortie différent de zéro. « Journal illisible » est trop vague pour mener une enquête.

La preuve d'altération n'est pas la prévention de l'altération. Un utilisateur local disposant de suffisamment de droits peut encore supprimer l'intégralité du journal ou restaurer une version antérieure de son stockage. Gardez cette limite visible. Si les enjeux exigent une preuve contre un retour en arrière local, exportez des points de contrôle signés vers un système contrôlé distinct. Ne prétendez pas qu'une chaîne de hachage locale résout une menace à laquelle elle ne répond pas.

## Gardez les secrets hors des variables d'environnement et des résultats d'outils

Les variables d'environnement sont pratiques pour une session shell humaine, mais offrent un confinement médiocre aux agents autonomes. Un processus enfant les hérite par défaut. Les journaux de débogage peuvent les afficher. Une commande qui liste l'environnement peut les renvoyer au modèle. Les rapports d'incident, l'inspection des processus, les paquets de support et les transcriptions de terminal copiées ont tous exposé des secrets de cette manière.

Un fichier d'identifiants dans l'espace de travail est encore pire. Le modèle peut le lire, un outil peut le téléverser, une commande Git peut l'ajouter à l'index et un service d'indexation peut en conserver une copie. Déplacer le fichier dans un répertoire caché réduit la fréquence des accidents, mais ne change pas la frontière de sécurité.

Conservez les identifiants dans un coffre contrôlé par la passerelle d'actions. La passerelle sélectionne l'identifiant à partir d'une association d'actions fixe et ne l'injecte que dans sa propre opération HTTP ou SSH. Elle ne renvoie le corps de la réponse qu'après filtrage, et seulement lorsqu'il est sûr de l'exposer à l'agent. Un jeton bearer ne doit jamais passer par le résultat MCP, même sous forme masquée, car les erreurs de masquage deviennent une partie permanente de la transcription.

Pour HTTP, préférez un contrat de réponse plutôt qu'un simple relais. Une action d'état de déploiement pourrait renvoyer ceci :

```json
{
  "environment": "staging",
  "service": "worker",
  "state": "healthy",
  "revision": "a1b2c3d4"
}
```

Elle ne doit pas renvoyer d'en-têtes susceptibles de contenir des identifiants de session, des détails de routage interne ou un nouveau jeton. Déterminez les champs dont l'agent a besoin avant l'implémentation. Un relais de réponse brut est un autre raccourci dont il devient coûteux de se défaire.

## Une petite frontière est plus facile à gérer sous pression

Vous pouvez examiner l'intégration d'un agent local sans langage de politique ni vaste programme de sécurité. Commencez par l'inventaire des actions, puis forcez chaque action à entrer dans l'une de deux catégories : elle lit ou calcule du contexte local sans autorité réutilisable, ou elle atteint un service externe et nécessite une passerelle.

Pour chaque action externe, notez l'identité fixe de la cible, l'identifiant sélectionné par la passerelle, les arguments exacts que l'agent peut influencer, la portée de l'approbation et l'enregistrement d'audit produit. Si une ligne indique « commande arbitraire », « toute URL », « jeton provenant de l'environnement » ou « l'agent choisit l'identifiant », la frontière n'est pas terminée.

Exécutez cet exercice de défaillance avant de donner à l'agent un secret utile :

1. Envoyez une requête brute conforme au schéma qui désigne une cible inattendue ou un argument trop volumineux.
2. Rejouez une requête approuvée précédemment après la fermeture du processus client.
3. Tentez une requête HTTP redirigée et une requête SSH avec des options de transfert.
4. Verrouillez le coffre, puis vérifiez que chaque action échoue avant l'ouverture de toute connexion réseau.
5. Modifiez un enregistrement d'audit stocké et vérifiez que la commande d'audit détecte la rupture de la chaîne.

Ces tests repèrent les erreurs de conception qu'une démonstration agréable dissimule. Un modèle qui suit parfaitement les instructions n'est pas un test de sécurité.

L'architecture locale la plus claire garde le serveur MCP ordinaire et remplaçable. Laissez-le exposer du contexte utile et demander des opérations conçues avec précision. Placez les secrets, l'approbation tenant compte du processus, l'exécution et un enregistrement auditable derrière la frontière d'actions. Lorsque quelqu'un demande pourquoi un outil ne peut pas simplement recevoir le jeton de production, la réponse doit être visible dans la conception : l'outil n'a jamais eu besoin du jeton pour faire son travail.
