Commit 1f413b47 authored by Kourser's avatar Kourser
Browse files

Serveur MCP : ouvrir l'outillage en lecture à un assistant

Lot 1 du cahier des charges : transport, jeton d'agent, garde, journal,
plafonnement, interrupteur, et quatorze outils de lecture — conception, revue
de cohérence, RETEX.

Trois décisions portent le reste.

Le serveur est ENTRANT : un client vient à Enkadenn, qui n'appelle aucun
service extérieur. Le confinement de la simulation tient donc entier. Un
personnage joué par une IA serait sortant ; c'est une autre fonctionnalité.

Le tenantId et le rôle viennent du jeton, jamais d'un argument d'outil. Une
partie de ce qu'un agent lit est écrite par les joueurs : accepter une autorité
par ce canal ferait du modèle l'arbitre du cloisonnement. Un serveur est monté
par appel, le contexte capturé dans la fermeture de chaque outil — il n'y a
rien à passer de main en main, donc rien à oublier.

Chaque outil énumère les champs qu'il rend. Une sérialisation par exclusion
aurait laissé passer contactEmail, l'adresse réelle du joueur, qui vit dans la
même table que l'adresse simulée.

Fermé par défaut : ouvrir place un modèle dans le périmètre d'autorité de
l'instance, ce qui est une décision d'exploitant.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 606dc792
Loading
Loading
Loading
Loading
+16 −0
Changes for README.md: 16 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -156,6 +156,22 @@ node dist/scripts/reset-mfa.js --email=root@exemple.org --confirm
La commande avertit et n'écrit rien sans `--confirm`. Le compte se reconnecte alors avec son
seul mot de passe, et l'obligation le renvoie immédiatement vers un nouvel enrôlement.

### Serveur MCP : ouvrir l'outillage à un assistant

Un assistant conversationnel peut lire les exercices d'une organisation — pour relire un scénario
monté, ou dépouiller un RETEX. La surface est **fermée par défaut** :

```bash
MCP_ENABLED=true
```

Chacun émet ensuite son propre jeton depuis **Mon compte → Jetons d'agent**, et le donne à son
client MCP (adresse `https://<instance>/api/mcp`, en-tête `Authorization: Bearer <jeton>`). Le jeton
est en lecture seule, limité à l'organisation active, et expire au bout de 90 jours au plus.

Ouvrir cette surface place un modèle de langage dans le périmètre d'autorité de l'instance : lisez
la section correspondante de [SECURITY.md](SECURITY.md) avant de le faire.

### Fichiers téléversés : balayer les orphelins

Supprimer un exercice ou une organisation efface aussi ses fichiers téléversés — images
+38 −0
Changes for SECURITY.md: 38 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -601,6 +601,44 @@ plateforme qu'en interne — peut substituer ses propres fichiers dans
`apps/web/public/avatars/accounts/` sans modifier une ligne de code. Cette décision
lui appartient ; elle n'est pas prise par défaut à sa place.

## Serveur MCP (agents)

> Fermé par défaut. `MCP_ENABLED` à faux tant qu'un exploitant ne l'ouvre pas : ouvrir MCP place un
> modèle de langage dans le périmètre d'autorité de l'instance, ce qui est une décision, pas une
> conséquence de montée de version.

Le serveur MCP est **entrant** : un client IA se connecte à Enkadenn et appelle des outils.
Enkadenn n'émet aucune requête vers un fournisseur de modèle, et **le confinement énoncé plus haut
reste entier** — aucun module sous `engine/` ou `exercises/` n'importe de client de modèle. Un
personnage joué par une IA, lui, serait sortant : c'est une autre fonctionnalité, hors périmètre.

- **Jeton d'agent** : chaîne à forte entropie préfixée `enk_agent_`, dont seule l'empreinte SHA-256
  est stockée — même primitive que les liens joueurs. Porté par une **appartenance** (un tenant, un
  rôle), avec échéance obligatoire de 90 jours au plus, révocable, et périmé par `sessionEpoch`
  comme les sessions : changer un mot de passe emporte les jetons d'agent.
- **Émission refusée à un compte qui doit un second facteur.** Un jeton porteur ne rejoue pas le
  second facteur ; sans cette règle, il permettrait de s'affranchir de l'obligation d'enrôlement
  pendant les 90 jours de sa validité — bien au-delà du délai de grâce.
- **Le `tenantId` et le rôle viennent du jeton, jamais d'un argument d'outil.** C'est la règle qui
  commande toutes les autres. Les accepter en argument ferait du modèle l'autorité sur le
  cloisonnement : il suffirait qu'un message écrit par un joueur contienne un autre identifiant pour
  que la barrière tombe. Un serveur MCP est monté **par appel**, le contexte capturé dans la
  fermeture de chaque outil — l'oubli n'est pas possible, il n'y a rien à passer de main en main.
- **Lecture seule.** Le lot 1 n'expose aucun outil d'écriture : ils ne sont pas refusés, ils ne sont
  pas enregistrés. La console d'instance, la gestion des comptes, les liens d'accès joueurs et le
  déclenchement d'injects ne sont exposés par aucun outil.
- **Injection par le contenu.** Une partie de ce qu'un agent lit est écrite par les participants
  (main courante, réponses RETEX, observations). Ces résultats portent un avertissement explicite
  disant qu'il s'agit de données et non d'instructions. Le marquage ne neutralise pas une
  injection ; ce qui la rend inoffensive, c'est qu'aucune autorité ne transite par ce canal.
- **Données personnelles.** Chaque outil énumère les champs qu'il rend (**liste blanche**). Une
  sérialisation par exclusion laisserait passer la colonne ajoutée demain — c'est exactement ainsi
  que `contactEmail`, arrivé après coup, se serait retrouvé exposé. `simEmail` (fictive) sort ;
  `contactEmail`, `User.email` et les liens d'accès ne sortent jamais. Vérifié par balayage de la
  charge rendue dans la recette, et non par relecture.
- **Traces et plafonds.** Chaque appel est journalisé (`mcp.appel`) sans jamais consigner les
  arguments, qui peuvent porter du contenu d'exercice. Plafond de 120 appels par minute.

## RGPD

- **Minimisation** : les joueurs n'ont pas de compte ; les scénarios doivent utiliser des
+2 −1
Changes for apps/api/package.json: 2 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@
  },
  "dependencies": {
    "@enkadenn/shared": "workspace:*",
    "@modelcontextprotocol/sdk": "1.30.0",
    "@nestjs/common": "10.4.7",
    "@nestjs/config": "3.3.0",
    "@nestjs/core": "10.4.7",
@@ -50,8 +51,8 @@
    "@types/express": "4.17.21",
    "@types/jest": "29.5.14",
    "@types/multer": "1.4.12",
    "@types/nodemailer": "6.4.17",
    "@types/node": "22.8.7",
    "@types/nodemailer": "6.4.17",
    "jest": "29.7.0",
    "prisma": "5.22.0",
    "ts-jest": "29.2.5",
+34 −0
Changes for apps/api/prisma/migrations/20260906100000_agent_token/migration.sql: 34 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Jeton d'acces d'un agent au serveur MCP.
--
-- Seule l'empreinte du jeton est stockee (cf. liens joueurs). Le jeton est porte
-- par une APPARTENANCE : un tenant, un role. `epoch` recopie `User.sessionEpoch`
-- a l'emission, ce qui fait tomber le jeton avec les sessions du compte.
CREATE TABLE "AgentToken" (
    "id"         TEXT NOT NULL,
    "tenantId"   TEXT NOT NULL,
    "userId"     TEXT NOT NULL,
    "name"       TEXT NOT NULL,
    "tokenHash"  TEXT NOT NULL,
    "role"       "UserRole" NOT NULL,
    "readOnly"   BOOLEAN NOT NULL DEFAULT true,
    "exerciseId" TEXT,
    "epoch"      INTEGER NOT NULL,
    "expiresAt"  TIMESTAMP(3) NOT NULL,
    "lastUsedAt" TIMESTAMP(3),
    "revokedAt"  TIMESTAMP(3),
    "createdAt"  TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

    CONSTRAINT "AgentToken_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "AgentToken_tokenHash_key" ON "AgentToken"("tokenHash");
CREATE INDEX "AgentToken_tenantId_idx"   ON "AgentToken"("tenantId");
CREATE INDEX "AgentToken_userId_idx"     ON "AgentToken"("userId");
CREATE INDEX "AgentToken_exerciseId_idx" ON "AgentToken"("exerciseId");

ALTER TABLE "AgentToken" ADD CONSTRAINT "AgentToken_tenantId_fkey"
  FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AgentToken" ADD CONSTRAINT "AgentToken_userId_fkey"
  FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "AgentToken" ADD CONSTRAINT "AgentToken_exerciseId_fkey"
  FOREIGN KEY ("exerciseId") REFERENCES "Exercise"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+57 −0
Changes for apps/api/prisma/schema.prisma: 57 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -90,6 +90,7 @@ model Tenant {
  newsArticles      NewsArticle[]
  documents         Document[]
  exerciseImages    ExerciseImage[]
  agentTokens       AgentToken[]
  observerNotes     ObserverNote[]
  events            EventLog[]
  kits              Kit[]
@@ -154,6 +155,8 @@ model User {

  /// Organisations auxquelles ce compte appartient, avec son role dans chacune.
  memberships  Membership[]
  /// Jetons d'agent emis par ce compte (serveur MCP).
  agentTokens  AgentToken[]

  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt
@@ -466,6 +469,7 @@ model Exercise {
  newsArticles  NewsArticle[]
  documents     Document[]
  images        ExerciseImage[]
  agentTokens   AgentToken[]
  observerNotes ObserverNote[]
  events        EventLog[]
  comchecks       Comcheck[]
@@ -1444,3 +1448,56 @@ model BugReport {

  @@index([status, createdAt])
}

/// Jeton d'acces d'un agent au serveur MCP.
///
/// Meme forme que les liens joueurs : chaine a forte entropie dont SEULE
/// l'empreinte SHA-256 est stockee. Le jeton en clair n'existe qu'une fois, a la
/// creation, dans la reponse qui le remet.
///
/// Il est porte par une APPARTENANCE, pas par un compte : un tenant, un role. Un
/// compte present dans trois organisations emet trois jetons distincts — sans quoi
/// un seul jeton franchirait le cloisonnement que tout le reste du produit tient.
model AgentToken {
  id        String   @id @default(cuid())
  /// Organisation que ce jeton ouvre. C'est LUI qui la porte, jamais l'appelant.
  tenantId  String
  tenant    Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  /// Compte porteur. Le retirer de l'organisation emporte ses jetons.
  userId    String
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  /// Libelle choisi a la creation, pour reconnaitre le jeton dans la liste.
  name      String
  /// Empreinte SHA-256 du jeton. Unique : c'est la cle de recherche a chaque appel.
  tokenHash String   @unique

  /// Role au moment de l'emission. Fige a dessein : une promotion de role ne doit
  /// pas elargir en silence un jeton deja remis. Une modification d'appartenance
  /// incremente `sessionEpoch` et perime donc le jeton — il faut en emettre un neuf.
  role      UserRole

  /// Lecture seule. Le lot 1 n'emet que des jetons en lecture ; la colonne existe
  /// pour que l'ouverture a l'ecriture soit un reglage, pas une migration.
  readOnly  Boolean  @default(true)
  /// Restriction facultative a UN exercice. Null = toute l'organisation.
  exerciseId String?
  exercise   Exercise? @relation(fields: [exerciseId], references: [id], onDelete: Cascade)

  /// Valeur de `User.sessionEpoch` a l'emission. Comparee a chaque appel : changer
  /// de mot de passe ou d'appartenance perime les jetons d'agent avec les sessions.
  epoch     Int

  /// Echeance obligatoire. Il n'existe pas de jeton perpetuel.
  expiresAt DateTime
  /// Dernier appel accepte. Sert a reperer un jeton oublie dans une configuration.
  lastUsedAt DateTime?
  /// Revocation explicite. La ligne est conservee : la trace survit au retrait.
  revokedAt DateTime?

  createdAt DateTime @default(now())

  @@index([tenantId])
  @@index([userId])
  @@index([exerciseId])
}
Loading