# La gestion des requêtes d'un coffre verrouillé peut-elle rejouer un appel ?

Un coffre verrouillé doit créer une frontière temporelle infranchissable. Une requête qui atteint une passerelle d'action avant que cette frontière ne soit levée doit échouer immédiatement. Elle ne doit pas rester dans une file, survivre à une reconnexion ni devenir éligible parce que quelqu'un s'authentifie ensuite auprès du coffre.

Cela semble évident jusqu'à ce que vous testiez une véritable pile d'agent. Les agents réessaient. Les clients MCP se reconnectent. Les bibliothèques HTTP rejouent parfois une requête après une réponse perdue. Les workers conservent les tâches. Une interface peut afficher un refus alors qu'un autre composant a déjà conservé suffisamment d'état pour effectuer l'appel plus tard. Si vous surveillez uniquement la carte d'approbation ou la transcription de l'agent, vous pouvez manquer l'aspect dangereux.

Le test décrit ici répond à une question précise : la passerelle a-t-elle supprimé les appels reçus lorsque le coffre était verrouillé, ou le déverrouillage a-t-il provoqué l'exécution de certains anciens appels ? Il utilise un récepteur HTTP contrôlé, deux identifiants de requête uniques et des preuves recueillies des deux côtés de la passerelle. Effectuez-le avant de confier à un agent autonome un point de terminaison capable de modifier des fonds, une infrastructure, du code source ou des données client.

## Le verrou du coffre doit interrompre une action, pas la remettre à plus tard

La règle attendue est simple : lorsque le coffre est verrouillé, la passerelle refuse toute action qui en a besoin. Le déverrouillage modifie la réponse pour un appel ultérieur. Il ne modifie pas la réponse pour un appel déjà arrivé.

Cette distinction compte, car une requête suit un cycle de vie. Un processus écrit des octets sur stdin. Un shim MCP analyse des messages JSON-RPC. La passerelle identifie une action configurée, vérifie si elle peut utiliser un secret, injecte les identifiants si cela est autorisé, ouvre une connexion sortante et renvoie un résultat. Un bug peut conserver la requête à plusieurs étapes de ce parcours.

Une conception sûre traite la décision de refus lorsque le coffre est verrouillé comme définitive pour cette invocation. La passerelle peut enregistrer le refus, mais elle ne doit pas conserver une closure exécutable, un corps de requête sérialisé, une tâche sortante ou un jeton de nouvelle tentative capable de s'exécuter plus tard avec un coffre nouvellement ouvert.

On confond souvent deux comportements différents :

- **Une nouvelle tentative** est un nouvel appel envoyé par l'agent après avoir observé une erreur ou un changement d'état.
- **Une relecture** est l'exécution de l'appel original, conservé par la passerelle ou l'un de ses auxiliaires alors que l'accès était refusé.

Une nouvelle tentative peut être légitime, même si elle doit toujours respecter l'autorisation normale. Une relecture franchit une frontière de sécurité sans nouvelle décision. Si vous les confondez, vous pouvez réussir artificiellement le test en déverrouillant le coffre, en voyant une requête atteindre la destination et en supposant qu'il s'agissait d'une nouvelle tentative délibérée.

Le Model Context Protocol ne résout pas ce problème de conception. Son transport stdio utilise des messages JSON-RPC délimités par des sauts de ligne entre un processus serveur lancé par le client et le client. Les requêtes JSON-RPC qui possèdent un `id` reçoivent une réponse corrélée, tandis que les notifications n'en reçoivent pas. Ces règles de protocole vous donnent une corrélation utile, mais elles ne définissent pas si une passerelle peut conserver une action refusée pour l'exécuter plus tard. Votre passerelle doit prendre cette décision explicitement.

## L'heure d'arrivée précède l'appel HTTP sortant

Une requête arrive lorsque la passerelle possède suffisamment d'informations pour décider de l'exécuter, pas lorsque le serveur de destination voit passer le trafic. Si le coffre est verrouillé à ce moment-là, le refus doit intervenir avant l'injection des identifiants et avant que la passerelle ne transmette le travail à un composant capable de survivre à cette décision.

C'est ici que les tests deviennent négligents. Quelqu'un verrouille le coffre, demande à l'agent d'appeler une API, attend une erreur, déverrouille le coffre et vérifie qu'aucune requête n'apparaît immédiatement. Ce test ne détecte pas les nouvelles tentatives retardées, les workers bloqués, les pools de connexions ni les temporisateurs de nouvelle tentative du client. Il ne détecte pas non plus la possibilité que l'appel ait atteint la destination avant que l'interface n'affiche une erreur.

Utilisez trois horodatages, enregistrés depuis des sources indépendantes :

1. `T_lock` : l'instant où le verrouillage du coffre a été confirmé.
2. `T_attempt` : l'instant où l'agent a envoyé l'ancien identifiant de requête.
3. `T_unlock` : l'instant où le coffre a été rouvert.

Continuez ensuite à surveiller le récepteur après `T_unlock`. L'attente doit dépasser tous les délais de nouvelle tentative et d'expiration configurés dans le client, la passerelle et les intermédiaires éventuels. Si vous ne connaissez pas ces valeurs, ne choisissez pas un délai court qui vous rassure. Déterminez-les d'abord, ou utilisez un récepteur qui reste disponible assez longtemps pour révéler une livraison tardive.

Une formulation d'acceptation utile est plus précise que « la requête envoyée lorsque le coffre était verrouillé a échoué » :

> Pour l'identifiant de requête `locked-...`, le récepteur contrôlé enregistre zéro exécution avant et après `T_unlock` ; pour l'identifiant `fresh-...`, envoyé uniquement après `T_unlock`, le récepteur enregistre exactement une exécution.

Cette formulation détecte les deux aspects de la défaillance. Elle repère un ancien appel exécuté plus tard et prouve que le test n'a pas simplement échoué parce que le récepteur ou la configuration de l'action était défectueux.

N'utilisez pas la même charge utile pour les deux appels. Si les deux indiquent `deploy=true`, vous ne pourrez pas déterminer lequel est arrivé. Placez l'identifiant de requête dans le chemin de l'URL, dans un champ JSON sans danger et dans un en-tête si votre action configurée le permet. Cette redondance est utile, car elle révèle les réécritures ou la mise en cache accidentelles.

## C'est dans les files et les nouvelles tentatives que se cachent les défauts de relecture

Les défauts de relecture les plus dangereux ne sont pas spectaculaires. Ils viennent souvent d'un code de fiabilité ordinaire écrit par quelqu'un qui supposait qu'un refus d'autorisation se comportait comme une défaillance réseau transitoire.

Prenons une séquence typique défectueuse. L'agent envoie un appel d'outil MCP alors que le coffre est verrouillé. Le shim accepte le message et crée une tâche interne. La vérification du coffre renvoie une erreur de verrouillage, mais le worker la classe comme réessayable parce que la destination n'a jamais été atteinte. L'appelant se déconnecte ou la session se termine. Plus tard, l'utilisateur déverrouille le coffre. Le worker se réveille, trouve un identifiant utilisable et envoie la requête HTTP originale.

L'interface peut sembler correcte pendant toute cette séquence. L'agent d'origine a reçu une erreur. L'utilisateur a vu que le coffre était verrouillé. La destination n'a reçu un identifiant valide qu'après le déverrouillage. Pourtant, la passerelle a fait franchir à une action une frontière où elle aurait dû disparaître.

Voici les mécanismes à rechercher :

- Un wrapper générique de nouvelle tentative intercepte toutes les erreurs sauf les entrées mal formées.
- Une file de tâches persistante enregistre l'intention avant la décision du coffre.
- Un future ou une promise attend le déverrouillage au lieu de renvoyer une erreur définitive.
- Un chemin de reconnexion renvoie une requête conservée en mémoire après la fermeture du processus client.
- Un auxiliaire en arrière-plan gère l'état des nouvelles tentatives indépendamment de la barrière du coffre.

La recommandation courante de « réessayer toute opération réseau qui échoue » est incorrecte à cette frontière. Elle est répandue parce que les défaillances du transport réseau sont fréquentes et que les nouvelles tentatives améliorent souvent la livraison. Un coffre verrouillé n'est pas une défaillance de transport. C'est un refus explicite d'utiliser une autorité. Classez-le comme définitif pour cette invocation.

Cela s'applique aussi à l'annulation. La déconnexion d'un client ne signifie pas forcément qu'une requête HTTP ou SSE a été annulée. La spécification du transport MCP indique qu'une déconnexion peut survenir à tout moment et ne doit pas être interprétée en soi comme une annulation ; elle prévoit une notification d'annulation explicite lorsque le client souhaite annuler. Ce comportement est logique pour les tâches longues, mais il rend l'état local de la passerelle encore plus important : un appel refusé ne doit pas rester exécutable simplement parce que l'état du transport est devenu ambigu.

## Construisez un récepteur qui rend chaque exécution visible

Un récepteur contrôlé fournit de meilleures preuves qu'une transcription de conversation avec l'agent. Il vous indique si un appel sortant est réellement arrivé, quel identifiant il portait et à quel moment. Isolez-le de la production et limitez son unique effet secondaire à un journal local en ajout uniquement.

Exécutez ce petit récepteur Python sur une machine et un port accessibles à votre passerelle. Il accepte les requêtes POST, écrit une ligne JSON pour chaque arrivée et renvoie une réponse de succès sans danger. Il enregistre volontairement uniquement un court marqueur de l'en-tête d'autorisation, et non l'identifiant lui-même.

```python
# receiver.py
from http.server import BaseHTTPRequestHandler, HTTPServer
from datetime import datetime, timezone
import hashlib
import json

LOG = "receiver-events.jsonl"

class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", "0"))
        body = self.rfile.read(length).decode("utf-8", errors="replace")
        auth = self.headers.get("Authorization", "")
        auth_marker = hashlib.sha256(auth.encode()).hexdigest()[:12] if auth else None
        event = {
            "received_at": datetime.now(timezone.utc).isoformat(),
            "method": self.command,
            "path": self.path,
            "request_id": self.headers.get("X-Replay-Test-Id"),
            "auth_marker": auth_marker,
            "body": body,
        }
        with open(LOG, "a", encoding="utf-8") as log:
            log.write(json.dumps(event) + "\n")
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b'{"received":true}')

    def log_message(self, format, *args):
        return

HTTPServer(("127.0.0.1", 8787), Receiver).serve_forever()
```

Lancez-le avec :

```bash
python3 receiver.py
```

Le fichier de sortie aura une forme similaire à celle-ci :

```json
{"received_at":"2026-07-22T16:42:12.103841+00:00","method":"POST","path":"/replay-test/fresh-8f1c","request_id":"fresh-8f1c","auth_marker":"a4d7e02c1b9f","body":"{\"kind\":\"fresh\"}"}
```

Ne placez pas de secret actif dans le corps de la requête. Configurez l'action de test avec un identifiant de test à privilèges limités dans le coffre et laissez la passerelle l'injecter par le canal HTTP normal. L'empreinte de l'en-tête du récepteur prouve seulement qu'une valeur d'autorisation est arrivée ; elle ne l'écrit pas dans un fichier susceptible de rester après le test.

Avant le test avec le coffre verrouillé, envoyez une requête ordinaire lorsque le coffre est ouvert. Vérifiez que le récepteur l'enregistre et que le chemin du point de terminaison configuré est correct. Supprimez ensuite `receiver-events.jsonl` ou déplacez-le. Un journal vide au début empêche une requête de configuration antérieure de fausser le résultat.

## Exécutez le test avec deux appels volontairement différents

Le test nécessite un ancien identifiant tenté lorsque le coffre est verrouillé et un nouvel identifiant créé uniquement après le déverrouillage. Utilisez des libellés qui semblent aléatoires, mais notez-les avant l'exécution. Des libellés faciles à lire simplifient la comparaison lors de l'audit.

Par exemple :

```text
old request ID:   locked-3d4a
fresh request ID: fresh-91ce
```

Préparez une instruction pour l'agent ou une requête client MCP qui appelle votre action HTTP configurée avec ces informations :

```json
{
  "path": "/replay-test/locked-3d4a",
  "headers": {
    "X-Replay-Test-Id": "locked-3d4a"
  },
  "body": {
    "kind": "locked-period-attempt",
    "request_id": "locked-3d4a"
  }
}
```

Le nom exact de l'outil et la forme des arguments dépendent de l'interface d'action que vous avez configurée. Ne simulez pas un test réussi en appelant directement le récepteur avec une commande shell. La requête doit passer par le même agent, le même shim MCP, la même passerelle, le même coffre et le même chemin d'action HTTP que ceux auxquels vous comptez faire confiance.

Effectuez maintenant la séquence sans improviser :

1. Confirmez que le journal du récepteur est vide et que le coffre est verrouillé.
2. Démarrez un nouveau processus d'agent, puis envoyez l'appel `locked-3d4a`.
3. Enregistrez l'erreur côté agent et l'heure. Ne renvoyez pas la requête.
4. Laissez le processus de l'agent actif pendant une courte période d'observation, puis arrêtez-le. Cela détecte les comportements de nouvelle tentative immédiats et ceux liés au processus.
5. Ouvrez le coffre et attendez toute la période d'observation. Consultez régulièrement le journal du récepteur. `locked-3d4a` doit rester absent.
6. Après seulement cette attente, démarrez un nouveau processus d'agent et envoyez `fresh-91ce`. Le récepteur doit enregistrer cet identifiant une seule fois.

Gardez les anciens et les nouveaux appels dans des processus d'agent distincts. Une approbation au niveau de la session ou un cache interne du client pourrait sinon brouiller le résultat. Vous testez la capacité d'une ancienne action à franchir la frontière du verrou, pas la capacité d'un processus unique à se souvenir d'un état d'autorisation.

Si l'appel effectué alors que le coffre était verrouillé déclenche une demande d'approbation après le déverrouillage sans que l'agent n'envoie de nouvel appel, arrêtez-vous. C'est la preuve qu'une intention a été conservée. Si le récepteur reçoit `locked-3d4a` à un moment quelconque après le déverrouillage, considérez le test de sécurité comme échoué, même si le serveur a renvoyé 200 sans modifier de données.

## Examinez les deux journaux, mais ne remplacez pas le récepteur par des logs

Le journal de la passerelle vous aide à reconstituer ce qu'elle pense avoir fait. Le récepteur prouve ce qui s'est passé en dehors de la passerelle. Vous avez besoin des deux, car chaque source prise isolément peut donner une fausse impression de sécurité.

Sallyport conserve un journal Sessions pour les exécutions d'agents et un journal Activity pour les appels individuels, tous deux construits à partir d'un journal d'audit chiffré et chaîné par hachage. Son vérificateur hors ligne est disponible avec `sp audit verify` et n'a pas besoin de la clé du coffre pour valider la chaîne. Exécutez cette commande après le test et conservez le résultat avec le journal du récepteur et la transcription de l'agent.

Utilisez les enregistrements pour répondre à des questions concrètes :

- L'ancien appel a-t-il créé une entrée Activity, et indique-t-elle un résultat refusé ?
- Quel processus d'agent et quelle autorité de signature du code la session a-t-elle enregistrés ?
- Existe-t-il une activité ultérieure avec l'ancien identifiant de requête, le chemin du point de terminaison ou une fenêtre temporelle correspondante ?
- Une nouvelle session a-t-elle commencé pour le nouvel appel effectué après le déverrouillage ?
- La vérification d'audit réussit-elle pour les enregistrements recueillis ?

Ne supposez pas qu'une entrée de journal indiquant « refusé » prouve que la requête n'a jamais quitté la machine. Un journal enregistre la propre décision de la passerelle. Le récepteur contrôlé fournit la vérification indépendante. À l'inverse, une entrée manquante peut signifier que vos termes de recherche étaient incorrects, que les horodatages n'étaient pas synchronisés ou que le test n'a pas utilisé l'action prévue. C'est pourquoi la requête réussie envoyée après le déverrouillage est importante.

Pour un test d'équipe reproductible, sauvegardez quatre éléments sous un même identifiant d'exécution : la transcription de l'agent, le fichier JSONL du récepteur, l'export du journal ou les captures qui identifient les appels, ainsi que le résultat de `sp audit verify`. Évitez d'insérer des valeurs secrètes dans ces éléments.

## Les notifications, les lots et les reconnexions nécessitent des cas séparés

Un seul test de requête réussi ne couvre pas toutes les formes de messages qu'un client agent peut envoyer. Les requêtes avec identifiant sont les plus faciles à tester, car JSON-RPC exige que la réponse porte le même identifiant. Les notifications n'ont pas d'identifiant et ne reçoivent pas de réponse, ce qui supprime la preuve habituelle que la passerelle les a rejetées. JSON-RPC indique explicitement qu'un serveur ne doit pas répondre aux notifications.

Pour un chemin prenant en charge les notifications, donnez à la charge utile sortante un marqueur côté récepteur, comme `notification-77b2`. Verrouillez le coffre, provoquez l'émission unique de la notification, déverrouillez-le et vérifiez que le marqueur n'arrive jamais. Ne déduisez pas la sécurité du silence de l'interface client, car ce silence est le comportement attendu du protocole.

Les entrées par lots méritent leur propre test si votre client ou votre shim les accepte. Placez deux appels sans danger dans le lot : un appel effectué lorsque le coffre est verrouillé et un autre envoyé uniquement après le déverrouillage dans un lot séparé. Ne placez pas les anciens et les nouveaux appels dans le même lot, car une passerelle qui traite une partie d'un lot avant un changement d'état peut produire un résultat impossible à interpréter.

Les tests de reconnexion doivent faire varier une seule chose à la fois. Essayez ces cas lors d'exécutions distinctes :

- Garder le processus de l'agent actif pendant le déverrouillage.
- Tuer le processus de l'agent avant le déverrouillage.
- Redémarrer uniquement le client MCP ou le shim avant le déverrouillage.
- Déconnecter le chemin réseau après l'erreur de verrouillage, puis le reconnecter après le déverrouillage.

L'attente reste la même. L'ancien identifiant du récepteur ne doit jamais apparaître. Si un ancien identifiant apparaît uniquement après une reconnexion, vous avez découvert un chemin de relecture lié à la récupération du transport plutôt qu'à l'interface du coffre.

## Une approbation par session ne peut pas réparer une action conservée

L'autorisation par session et l'approbation par appel déterminent si une action actuelle peut continuer. Elles ne peuvent pas rendre sûre la conservation d'une action rejetée lorsque le coffre était verrouillé.

L'ordre des contrôles compte. La barrière du coffre est absolue : tant qu'il est verrouillé, toute action est refusée. Ce n'est qu'après son ouverture que la passerelle peut examiner le processus derrière une autorisation de session ou demander une approbation par appel pour un identifiant configuré. Inverser ce modèle mental amène les équipes à se demander si une ancienne carte d'approbation devrait autoriser une action retardée. Ce n'est pas le cas, car l'appel refusé lorsque le coffre était verrouillé ne devrait plus exister comme tâche exécutable.

Testez les frontières séparément. Prouvez d'abord qu'un appel effectué pendant le verrouillage ne s'exécute jamais après le déverrouillage. Puis, avec le coffre ouvert, vérifiez qu'un nouveau processus d'agent produit le comportement attendu en matière d'autorisation de session. Enfin, si un identifiant possède l'option d'approbation par appel, vérifiez que chaque nouvelle utilisation demande une nouvelle approbation. Combiner les trois dans une longue exécution rend les défaillances difficiles à attribuer.

Il existe aussi un problème subtil lié à l'identité du processus. Une approbation de session appartient à une exécution particulière du processus de l'agent, et non à l'idée d'un « même assistant ». Lorsque vous redémarrez un processus, considérez-le comme nouveau jusqu'à ce que la passerelle l'identifie et l'autorise selon ses propres règles. Ne laissez pas un script de test masquer ce point en réutilisant un processus avec une ancienne connexion.

## Faites du résultat une condition de mise en production

Exécutez ce test chaque fois que vous modifiez la distribution de la passerelle, le cycle de vie du coffre, le shim MCP, le comportement des nouvelles tentatives, la configuration du client HTTP ou l'auxiliaire qui exécute les actions SSH. Un défaut de relecture apparaît souvent lors d'une modification de fiabilité, car le code semble inoffensif à la revue : une file, un gestionnaire de reconnexion ou une clause `catch` trop générale.

Une version doit être refusée si l'une de ces affirmations est fausse :

- Le récepteur contrôlé ne contient aucune entrée pour les identifiants envoyés lorsque le coffre était verrouillé, y compris après son ouverture.
- Un nouvel identifiant envoyé après le déverrouillage atteint une fois le récepteur par la même action configurée.
- Un client ou un agent redémarré ne peut pas faire apparaître l'ancien identifiant.
- Les journaux identifient les événements refusés et autorisés attendus, sans doublons inexpliqués.
- La chaîne d'audit est validée pour les preuves conservées.

Intégrez autant que possible le cas négatif dans un test d'intégration automatisé. Le banc de test doit verrouiller le coffre, envoyer l'ancien appel, ouvrir le coffre, attendre pendant une fenêtre de nouvelle tentative limitée et vérifier que le récepteur ne contient pas l'ancien identifiant. Il doit ensuite envoyer le nouvel appel et vérifier une arrivée. Gardez le récepteur local et jetable afin que le test ne possède aucune autorité au-delà de son propre fichier journal.

Le mauvais résultat est facile à formuler : une personne ouvre le coffre pour autoriser l'action suivante et une action antérieure s'exécute à sa place. Ne vous contentez pas d'une passerelle qui affiche le bon message de verrouillage. Faites-lui prouver, avec un observateur externe, qu'elle a oublié l'ancienne requête.
