Commit e7981fe9 authored by Kourser's avatar Kourser
Browse files

Serveur MCP : la conception par un agent, et sa séparation d'avec les joueurs

Lot 2 du cahier des charges. Six outils d'écriture — exercice, personnage,
phase, inject, document, fiche réflexe — et le dispositif qui les encadre.

Le booléen « lecture seule » devient une PORTÉE, et les deux ne se cumulent
pas. LECTURE lit tout, contenu écrit par les joueurs compris, et n'écrit rien.
CONCEPTION écrit le scénario et ne voit aucun contenu joueur. Un agent qui
lirait la prose des participants et pourrait écrire dans l'exercice offrirait à
n'importe lequel d'entre eux un canal pour piloter la conception depuis un
message de chat. Qui veut les deux émet deux jetons, et le geste se voit.

Un agent n'écrit que dans un BROUILLON. Un exercice se joue devant des gens ;
ce qu'ils voient apparaître ne se décide pas dans une boucle d'agent, et un
exercice clos doit rester fidèle à ce qu'ils ont vu.

L'outil qui ajoute un personnage n'expose pas contactEmail. La liste blanche
vaut en écriture comme en lecture : une adresse réelle écrite par un modèle
viendrait de son contexte, pas d'un formulaire rempli par quelqu'un.

Les écritures sont tracées dans une table dédiée, sans clé étrangère vers le
jeton ni vers l'objet — la trace survit à la révocation comme à la suppression,
au même titre que le journal d'instance. Une route la rend lisible : consignée
sans moyen de la lire, elle n'aurait rassuré personne.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 1f413b47
Loading
Loading
Loading
Loading
+11 −1
Changes for README.md: 11 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -167,7 +167,17 @@ 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.
est limité à l'organisation active et expire au bout de 90 jours au plus.

Deux portées, qui **ne se cumulent pas** :

| Portée | Lit le scénario | Lit ce que les joueurs ont écrit | Écrit |
|---|---|---|---|
| **Lecture** | oui | oui | non |
| **Conception** | oui | **non** | oui, dans un exercice en brouillon |

La séparation n'est pas un raffinement : un agent qui lirait la prose des participants et pourrait
écrire dans l'exercice offrirait à n'importe lequel d'entre eux un canal pour piloter la conception.

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.
+22 −3
Changes for SECURITY.md: 22 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -624,9 +624,28 @@ personnage joué par une IA, lui, serait sortant : c'est une autre fonctionnalit
  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.
- **Deux portées, qui ne se cumulent pas.** `LECTURE` lit tout, contenu écrit par les joueurs
  compris, et n'écrit rien. `CONCEPTION` écrit le scénario et **ne voit aucun contenu joueur** — ni
  main courante, ni réponses de RETEX, ni observations. La séparation est le cœur du dispositif :
  un agent qui lirait la prose des participants **et** pourrait écrire dans l'exercice offrirait à
  n'importe lequel d'entre eux un canal pour piloter la conception depuis un message de chat. Qui
  veut les deux émet deux jetons, et le geste se voit.
- **La conception écrit dans un BROUILLON, jamais ailleurs.** Un exercice lancé, clos ou archivé se
  modifie depuis la console. Un exercice se joue devant des gens ; ce qu'ils voient apparaître ne se
  décide pas dans une boucle d'agent, et un exercice clos doit rester fidèle à ce qu'ils ont vu.
- **Une portée de conception se demande, et le rôle est vérifié.** Seuls `TENANT_ADMIN` et
  `DESIGNER` en émettent : animer n'est pas concevoir. Le défaut, à l'émission, est la portée la
  plus étroite.
- **Aucune donnée personnelle en entrée non plus.** L'outil qui ajoute un personnage n'expose pas
  `contactEmail` : une adresse écrite par un modèle viendrait de son contexte, pas d'un formulaire
  rempli par quelqu'un. La liste blanche vaut dans les deux sens.
- **Toute écriture est tracée** (`AgentWrite`) : jeton auteur, compte porteur, objet écrit. La table
  n'a de clé étrangère ni vers le jeton ni vers l'objet — la trace survit à la révocation comme à la
  suppression de ce qui a été écrit, au même titre que le journal d'instance. Elle se lit depuis la
  console, sur l'exercice concerné.
- **Ce qu'aucun outil n'atteint** : la console d'instance, la gestion des comptes et des
  appartenances, les liens d'accès joueurs, le déclenchement manuel d'un inject, toute publication
  vers les joueurs, et toute suppression.
- **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
+34 −0
Changes for apps/api/prisma/migrations/20260906140000_agent_scope/migration.sql: 34 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- Portee d'un jeton d'agent, en remplacement du booleen « lecture seule ».
--
-- Deux portees qui ne se cumulent pas : LECTURE voit le contenu joueur et n'ecrit
-- rien ; CONCEPTION ecrit le scenario et ne voit aucun contenu joueur. Un agent
-- qui aurait les deux offrirait a un participant un canal pour piloter la
-- conception depuis un message de chat.
CREATE TYPE "AgentScope" AS ENUM ('LECTURE', 'CONCEPTION');

ALTER TABLE "AgentToken" ADD COLUMN "scope" "AgentScope" NOT NULL DEFAULT 'LECTURE';
-- Les jetons deja emis etaient tous en lecture : le defaut les couvre exactement.
ALTER TABLE "AgentToken" DROP COLUMN "readOnly";

-- Trace des ecritures d'agent. Elle survit a la revocation du jeton et a la
-- suppression de ce qui a ete ecrit.
CREATE TABLE "AgentWrite" (
    "id"         TEXT NOT NULL,
    "tenantId"   TEXT NOT NULL,
    "tokenId"    TEXT NOT NULL,
    "userId"     TEXT NOT NULL,
    "exerciseId" TEXT NOT NULL,
    "entity"     TEXT NOT NULL,
    "entityId"   TEXT NOT NULL,
    "action"     TEXT NOT NULL,
    "createdAt"  TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

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

CREATE INDEX "AgentWrite_tenantId_idx"   ON "AgentWrite"("tenantId");
CREATE INDEX "AgentWrite_exerciseId_idx" ON "AgentWrite"("exerciseId");
CREATE INDEX "AgentWrite_tokenId_idx"    ON "AgentWrite"("tokenId");

ALTER TABLE "AgentWrite" ADD CONSTRAINT "AgentWrite_tenantId_fkey"
  FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+44 −3
Changes for apps/api/prisma/schema.prisma: 44 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -91,6 +91,7 @@ model Tenant {
  documents         Document[]
  exerciseImages    ExerciseImage[]
  agentTokens       AgentToken[]
  agentWrites       AgentWrite[]
  observerNotes     ObserverNote[]
  events            EventLog[]
  kits              Kit[]
@@ -1458,6 +1459,39 @@ model BugReport {
/// 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.
/// Portee d'un jeton d'agent. Cf. `AgentToken.scope`.
enum AgentScope {
  LECTURE
  CONCEPTION
}

/// Trace des ECRITURES faites par un agent.
///
/// Table dediee plutot qu'une colonne « auteur » sur chacun des six modeles
/// concernes : une colonne par table aurait multiplie les migrations, et surtout
/// elle aurait disparu avec la ligne. Ici la trace SURVIT — a la revocation du
/// jeton comme a la suppression de ce qui a ete ecrit — et se lit d'un endroit.
model AgentWrite {
  id         String   @id @default(cuid())
  tenantId   String
  tenant     Tenant   @relation(fields: [tenantId], references: [id], onDelete: Cascade)
  /// Jeton auteur. Conserve meme apres sa revocation : la trace le nomme encore.
  tokenId    String
  /// Compte porteur du jeton, recopie : le jeton peut disparaitre, pas la
  /// responsabilite de la personne qui l'a emis.
  userId     String
  exerciseId String
  /// Type d'objet ecrit (« inject », « personnage »...) et son identifiant.
  entity     String
  entityId   String
  action     String
  createdAt  DateTime @default(now())

  @@index([tenantId])
  @@index([exerciseId])
  @@index([tokenId])
}

model AgentToken {
  id        String   @id @default(cuid())
  /// Organisation que ce jeton ouvre. C'est LUI qui la porte, jamais l'appelant.
@@ -1477,9 +1511,16 @@ model AgentToken {
  /// 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)
  /// Ce que le jeton ouvre. Deux portees, et elles ne se cumulent PAS.
  ///
  /// `LECTURE` lit tout, y compris ce que les joueurs ont ecrit, et n'ecrit rien.
  /// `CONCEPTION` lit et ecrit le scenario, mais ne voit AUCUN contenu joueur.
  ///
  /// La separation est le coeur du dispositif : un agent qui lirait la prose des
  /// joueurs ET pourrait ecrire dans l'exercice offrirait a n'importe quel
  /// participant un canal pour piloter la conception. Qui veut les deux emet deux
  /// jetons — et le geste se voit.
  scope     AgentScope @default(LECTURE)
  /// Restriction facultative a UN exercice. Null = toute l'organisation.
  exerciseId String?
  exercise   Exercise? @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
+25 −2
Changes for apps/api/src/mcp/agent-token.ts: 25 added lines, 2 removed lines.
Original line number Diff line number Diff line
import type { UserRole } from '@prisma/client';
import type { AgentScope, UserRole } from '@prisma/client';
import { generateToken, hashToken } from '../common/one-time-token';

/**
@@ -99,7 +99,30 @@ export interface AgentContext {
  tenantId: string;
  userId: string;
  role: UserRole;
  readOnly: boolean;
  scope: AgentScope;
  /** Exercice unique auquel le jeton est restreint, s'il l'est. */
  exerciseId: string | null;
}

/**
 * Le jeton peut-il ECRIRE ?
 *
 * Fonction plutot que comparaison eparpillee : le jour ou une troisieme portee
 * apparait, il n'y a qu'un endroit a reprendre — et pas huit `=== 'CONCEPTION'`
 * dispersés dans les outils, dont on oublierait un.
 */
export function peutEcrire(agent: AgentContext): boolean {
  return agent.scope === 'CONCEPTION';
}

/**
 * Le jeton peut-il lire ce que les JOUEURS ont ecrit ?
 *
 * Main courante, reponses de RETEX, observations. Refuse a un jeton de
 * conception : un agent qui lirait la prose des joueurs et pourrait ecrire dans
 * l'exercice offrirait a n'importe quel participant un canal pour piloter la
 * conception depuis un message de chat.
 */
export function peutLireContenuJoueur(agent: AgentContext): boolean {
  return agent.scope === 'LECTURE';
}
Loading