← Retour au blog

Un runtime où l'agent ne peut pas appeler un outil en contournant le contrôle

« Les agents proposent. Cradle décide de ce qui peut être exécuté » figure sur la page d'accueil du produit. Tant qu'un seul chemin dans le code pouvait appeler un outil directement, cette phrase était une recommandation, pas une règle. Voici comment le runtime est construit et comment j'ai fermé les portes dérobées mécaniquement, et non par convention.

Trois séparations

Cradle sépare le raisonnement probabiliste du contrôle déterministe de l'exécution. Toute la construction repose sur trois affirmations :

Une proposal n'est pas une décision. La sortie d'un outil n'est pas une vérité vérifiée. Une exécution réussie n'est pas une vérification du résultat.

L'évaluation des risques dont j'ai parlé plus tôt gouverne les réponses aux humains. Le runtime la généralise au gouvernement des actions sur les systèmes.

Quatre entités au lieu d'une seule « action d'agent »

Dans une boucle d'agent classique, un « appel d'outil » est une seule entrée dans la transcription. Dans le runtime, ce sont quatre tables séparées : proposals, decisions, observations, verifications, plus une table audit_events en append-only.

EntitéCe qu'elle enregistreQui l'écrit
Proposalce que l'agent veut faire : type d'action, cible, arguments, classe d'effet de bord, pré- et postconditionsl'agent
Decisionle verdict allow / deny / repair / require_approval et l'identité autoriséela policy
Observationce que l'outil a réellement renvoyéle runtime, après l'appel
Verificationsi les postconditions tiennent, lues depuis une source indépendanteun vérificateur

La séparation n'est pas là pour embellir le schéma. Elle permet d'imposer les invariants par des vérifications bon marché sur les enregistrements, sans graphe et sans raisonneur :

  • une proposal refusée ne peut pas produire d'observation (EXECUTION_NOT_AUTHORIZED) ;
  • l'exécutant doit correspondre à l'identité autorisée (IDENTITY_MISMATCH) ;
  • une clé d'idempotence, une proposal ; une proposal, une décision ;
  • le statut de vérification se déduit des contrôles, il n'est pas fixé par l'appelant : une liste de contrôles vide donne inconclusive, parce que « nous n'avons pas regardé » n'est pas « tout va bien ».

Le dernier point est toute la thèse en une ligne de code :

const verification = recordVerification(db, {
  observationId: observation.id,
  checks: [{ name: 'refund.recorded', status: 'fail', source: 'ledger-api' }]
})

verification.status // 'failed' — even though the tool returned 200

Une vérification échouée revient comme un échec même quand l'outil a annoncé un succès. Le champ source de chaque contrôle atteste qu'une source indépendante a été lue, et non le même appel qui a fait l'écriture.

L'audit comme sous-produit

Le journal n'est pas posé par-dessus le runtime. Chaque enregistrement de proposal, decision, observation et verification produit un événement d'audit portant payload_hash, prev_hash et hash. verifyAuditChain() attrape un payload modifié, des métadonnées modifiées et une ligne supprimée (un trou dans seq).

C'est ce que j'appelle assurance by construction : on ne peut pas exécuter une action sans laisser de trace, parce que la trace n'est pas un appel séparé à un logger mais une partie du même chemin d'écriture. Un piège en route : canonicalJson() trie les clés à tous les niveaux. Sans cela, la chaîne cassait à l'aller-retour par SQLite : JSON.stringify conserve l'ordre d'insertion, et un objet fraîchement construit se sérialisait autrement que le même objet relu depuis la base.

ToolGateway : la porte unique

Au-dessus du client MCP existant se trouve ToolGateway.execute() : propose → decide → invoke → observe. Un refus de la policy est un résultat, pas une exception : l'appelant reçoit la décision avec la liste complète des raisons, et le transport n'est jamais touché.

import { ToolGateway, registerTool, mcpInvoker } from './core/runtime'

registerTool(db, {
  serverId: 'mcp_billing',
  toolName: 'refund.issue',
  sideEffectClass: 'compensatable',
  requiredScopes: ['billing:write'],
  allowedEnvironments: ['staging', 'production'],
  dryRunToolName: 'refund.validate'
})

const result = await new ToolGateway(mcpInvoker).execute(db, {
  taskId, agentId,
  identity: { id: 'user:operator_7', scopes: ['billing:write'] },
  serverId: 'mcp_billing',
  action: {
    actionType: 'refund.issue',
    target: { kind: 'order', id: 'A-119', env: 'production' },
    arguments: { amount: 4200 },
    sideEffectClass: 'compensatable',
    preconditions: ['order.exists', 'order.paid'],
    postconditions: ['refund.recorded']
  },
  idempotencyKey: 'refund:A-119'
})

result.decision.verdict  // allow | deny | repair | require_approval
result.observation       // null unless allow — the tool was never invoked
result.replayed          // true when the idempotency key was already used

Deux décisions à l'intérieur du gateway méritent d'être nommées.

Discovery n'est pas authorization. Un serveur MCP annonce le nom d'un outil et, au mieux, un destructiveHint. Il ne dit pas ce que l'outil peut toucher ni qui a le droit de l'appeler. Donc un outil non enregistré n'est jamais invoqué, et quand l'enregistrement est automatique, un nouvel outil est classé irreversible sauf si le serveur dit explicitement le contraire. L'optimisme d'un serveur distant sur sa propre sûreté n'est pas une raison de baisser la barre en local.

Une approbation humaine est un objet, pas une chaîne. Le gateway acceptait autrefois approvalId comme une chaîne et lui faisait confiance ; n'importe quel appelant satisfaisait la règle en inventant une valeur. C'est désormais un enregistrement avec son propre cycle de vie : il pointe vers une proposal ou un scope précis, il a une expiration, il se consomme une fois, et un agent ne peut pas s'approuver lui-même.

Le gardien en CI

Le gateway seul ne garantit rien tant qu'un getMcpManager().callTool() direct existe à côté. La docstring de gateway.ts le dit sans détour : sans point de passage unique, les invariants sont advisory.

La revue du code a fait apparaître trois contournements. L'un n'était pas du MCP du tout mais onze outils in-process sur la base de données du produit, dont quatre destructifs. Un autre, que j'avais classé read-only d'après la ligne 80, écrivait par le même helper : il mettait à jour des notes et les marquait terminées. La classification superficielle a été une leçon en soi.

Fermer les portes a pris trois étapes. Les types : getMcpManager() renvoie un pool de connexions qui n'a pas de callTool. Le code : chaque contournement est soit enregistré dans le registre avec une classe d'effet de bord et des scopes, soit explicitement sorti du gateway avec la raison écrite dans le code. Et un gardien mécanique : le script scripts/check-gateway.mjs, alias pnpm check:gateway.

Pourquoi un script et pas un grep. La règle porte sur l'endroit où un symbole est utilisé, pas sur la présence d'une chaîne : callTool vit légitimement dans le runtime et dans le client MCP. Le gardien vérifie trois choses : un appel hors de src/core/runtime/, l'import de tout symbole capable d'atteindre un outil, et l'ensemble des exports de src/core/mcp/, qui doit rester exactement deux exemptions nommées. L'unique exemption (récupérer la capture d'écran d'une note pour l'UI opérateur) doit garder le nom de son outil en littéral de chaîne ; élargissez sa signature à un nom arbitraire et le gardien échoue.

Le gardien a été testé sur une violation délibérée ; sinon c'est un vœu, pas un gardien :

✗ src/core/__probe/violation.ts:2 — calls .callTool() outside src/core/runtime/
→ ПРОВАЛ: 1 нарушений в 234 файлах вне src/core/runtime/

Sur du code propre : zéro violation sur 233 fichiers, et les tests du runtime sont verts.

État honnête

Ce qui n'existe pas, et qu'il ne faut pas décrire comme fonctionnel :

  • Des vérificateurs prêts. Le moteur existe ; le registre est vide. Tant que rien n'est enregistré, toute action avec des postconditions finit en inconclusive → aborted. C'est le bon défaut, et il ne sert encore à rien.
  • Un rollback au sens de restauration d'un snapshot. Il n'y a que la compensation via un outil que le serveur a lui-même déclaré.
  • Des domain packs livrables. Le manifeste existe ; un format de distribution vers des installations on-premise, non.
  • Le tool use de l'agent de canal. Le triage répond en texte ; les outils vivent dans le bac à sable et dans les domain packs. Savoir si l'agent branché sur un canal a besoin d'appels d'outils via le gateway est une question produit, et elle est ouverte.

Le chemin de repli, si l'on ne veut pas du tout de boucle d'agent à soi : un agent externe comme Claude Code, lancé dans le périmètre avec cradle launch claude contre le /v1/messages compatible Anthropic de cradle-server. Cradle ne construit alors pas l'agent ; il se tient seulement entre l'agent et les outils. C'était le cahier des charges initial.

Cet article a été créé dans un format hybride humain + IA. J'ai fixé la direction et les thèses, l'IA a aidé pour le texte, j'ai édité et vérifié. La responsabilité du contenu m'incombe.

← Retour au blog