Commit c959b216 authored by Kourser's avatar Kourser
Browse files

Serveur MCP : un agent peut se corriger, et l'on voit ce qu'il a fait

Trois manques du cahier des charges, comblés.

MODIFIER. inject_modifier et personnage_modifier existent enfin : un agent ne
savait qu'ajouter, et la moindre coquille repassait par la console.

InjectsService.update prend un DTO complet et remplace TOUT : un agent qui
n'aurait fourni que le nouvel instant aurait effacé corps, objet, expéditeur et
ciblage, en silence. L'outil relit donc l'inject, reconstitue son DTO canal par
canal et n'y applique que les champs fournis. Cette logique vit seule dans
fusion-inject.ts, éprouvée seule : c'est là que se joue la perte de contenu. Le
ciblage vit hors du payload — l'oublier aurait renvoyé « à tous » un inject
adressé à trois personnages — et donner un instant à un inject manuel le
programme, sans quoi le changement serait perdu sans un mot.

VOIR. L'onglet Préparation montre ce qu'un agent a écrit : objet, action,
instant, jeton auteur. La route existait depuis le lot 2, rien ne l'affichait.

COUPER. La page Utilisateurs liste les jetons de l'organisation, porteur nommé,
et permet de les révoquer. Un administrateur ne peut toujours pas en émettre
pour autrui — ce serait prendre ses accès. Sans cet écran, un jeton survivait
quatre-vingt-dix jours au départ de son porteur sans que personne le sache.

Le nettoyage a révélé un défaut du lot 2 : AgentWrite n'avait aucune clé
étrangère vers l'exercice. Supprimer un exercice laissait des traces illisibles
— la route de lecture est par exercice — et jamais purgées. Même argument que
pour les fichiers orphelins, et je l'avais enfreint. La trace survit toujours à
la révocation du jeton et à la suppression de l'objet écrit ; plus à celle de
l'exercice. 49 orphelines effacées par la migration.

Le lot de recette, lui, archivait son exercice puis échouait en 403 à le
supprimer : un exercice s'ajoutait à la démonstration à chaque passage.

Une page de wiki dédiée : brancher un client, portée des jetons, ce qui n'est
pas exposé et pourquoi.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent d0fecfc6
Loading
Loading
Loading
Loading
+13 −2
Changes for SECURITY.md: 13 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -639,10 +639,21 @@ personnage joué par une IA, lui, serait sortant : c'est une autre fonctionnalit
- **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.
- **Modifier n'efface pas.** `InjectsService.update` remplace l'inject entier : un agent qui ne
  fournirait que le nouvel instant aurait effacé corps, objet, expéditeur et ciblage, en silence.
  L'outil de modification relit donc l'inject, reconstitue son DTO et n'y applique que les champs
  fournis (`mcp/outils/fusion-inject.ts`, logique pure, éprouvée seule). Un canal ajouté à
  `buildPayload` sans l'être ici ferait perdre son contenu à la première modification — le cas de
  recette qui modifie un inject puis relit son corps est là pour l'attraper.
- **Un administrateur d'organisation voit et coupe, il n'émet pas.** Il ne peut pas émettre de jeton
  au nom d'autrui — ce serait prendre ses accès — mais il voit tous les jetons de son organisation,
  avec leur porteur, et peut les révoquer. Sans cela un jeton survit quatre-vingt-dix jours au départ
  de la personne qui l'a émis, sans que personne d'autre sache qu'il existe.
- **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é.
  suppression de ce qui a été écrit, au même titre que le journal d'instance. Elle **s'affiche** dans
  l'onglet Préparation de l'exercice concerné : un concepteur qui reprend un exercice monté par un
  agent doit voir ce qui vient de lui.
- **Un outil est aussi strict que sa route, jamais plus.** Les listes de rôles recopient ce que
  chaque route de la console exige, une par une. Une première version les ouvrait toutes à
  `OBSERVER` : or ce rôle ne voit dans la console ni le chronogramme, ni l'annuaire, ni les
+13 −0
Changes for apps/api/prisma/migrations/20260909200000_agent_write_cascade/migration.sql: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- La trace d'ecriture d'agent disparait avec son EXERCICE.
--
-- Elle survit toujours a la revocation du jeton et a la suppression de l'objet
-- ecrit — c'est son objet. Mais sans cle etrangere vers l'exercice, les traces
-- d'un exercice supprime restaient : illisibles (la route de lecture est par
-- exercice) et jamais purgees. De la donnee conservee sans finalite.

-- Les orphelines d'abord : la contrainte les refuserait.
DELETE FROM "AgentWrite" w
  WHERE NOT EXISTS (SELECT 1 FROM "Exercise" e WHERE e.id = w."exerciseId");

ALTER TABLE "AgentWrite" ADD CONSTRAINT "AgentWrite_exerciseId_fkey"
  FOREIGN KEY ("exerciseId") REFERENCES "Exercise"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+11 −2
Changes for apps/api/prisma/schema.prisma: 11 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -471,6 +471,7 @@ model Exercise {
  documents     Document[]
  images        ExerciseImage[]
  agentTokens   AgentToken[]
  agentWrites   AgentWrite[]
  observerNotes ObserverNote[]
  events        EventLog[]
  comchecks       Comcheck[]
@@ -1469,8 +1470,14 @@ enum AgentScope {
///
/// 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.
/// elle aurait disparu avec la ligne. Ici la trace SURVIT a la revocation du
/// jeton et a la suppression de l'OBJET ecrit — c'est tout son objet.
///
/// Elle ne survit PAS a la suppression de l'exercice, elle. La premiere version
/// n'avait pas de cle etrangere vers lui : les traces d'un exercice supprime
/// restaient, illisibles — la route de lecture est par exercice — et jamais
/// purgees. De la donnee conservee sans finalite ni moyen d'y acceder, ce que la
/// purge de retention est justement la pour eviter.
model AgentWrite {
  id         String   @id @default(cuid())
  tenantId   String
@@ -1481,7 +1488,9 @@ model AgentWrite {
  /// responsabilite de la personne qui l'a emis.
  userId     String
  exerciseId String
  exercise   Exercise @relation(fields: [exerciseId], references: [id], onDelete: Cascade)
  /// Type d'objet ecrit (« inject », « personnage »...) et son identifiant.
  /// PAS de cle etrangere ici : la trace doit survivre a la suppression de l'objet.
  entity     String
  entityId   String
  action     String
+27 −0
Changes for apps/api/src/mcp/agent-tokens.controller.ts: 27 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -84,3 +84,30 @@ export class AgentTokensController {
    await this.jetons.revoke(me.tenantId, me.userId, id);
  }
}

/**
 * Jetons d'agent de TOUTE l'organisation — vue de l'administrateur.
 *
 * Separee de `account/agent-tokens` a dessein : ce ne sont pas les memes droits
 * ni le meme geste. Ici on VOIT et on COUPE ; on n'emet pas. Emettre au nom
 * d'autrui reviendrait a prendre ses acces.
 */
@Controller('organisation/agent-tokens')
@UseGuards(AuthGuard, RolesGuard)
@Roles('TENANT_ADMIN')
export class OrgAgentTokensController {
  constructor(private readonly jetons: AgentTokensService) {}

  @Get()
  async list(@CurrentUser() me: AuthUser) {
    if (!mcpActif()) throw new NotFoundException();
    return { jetons: await this.jetons.listForTenant(me.tenantId) };
  }

  @Delete(':id')
  @HttpCode(HttpStatus.NO_CONTENT)
  async revoke(@CurrentUser() me: AuthUser, @Param('id') id: string) {
    if (!mcpActif()) throw new NotFoundException();
    await this.jetons.revokeAsAdmin(me.tenantId, me.userId, id);
  }
}
+35 −0
Changes for apps/api/src/mcp/agent-tokens.service.ts: 35 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -111,6 +111,41 @@ export class AgentTokensService {
    return lignes.map((l) => this.view(l));
  }

  /**
   * TOUS les jetons de l'organisation, porteur nomme.
   *
   * Reserve a l'administrateur d'organisation. Il ne peut pas EMETTRE pour
   * autrui — ce serait prendre ses acces — mais il doit pouvoir voir et couper :
   * un jeton reste vivant quatre-vingt-dix jours apres le depart de la personne
   * qui l'a emis, et personne d'autre ne sait qu'il existe.
   */
  async listForTenant(tenantId: string): Promise<(AgentTokenView & { porteur: string })[]> {
    const lignes = await this.prisma.forTenant(tenantId).agentToken.findMany({
      orderBy: { createdAt: 'desc' },
      include: {
        exercise: { select: { name: true } },
        user: { select: { displayName: true } },
      },
    });
    return lignes.map((l) => ({ ...this.view(l), porteur: l.user.displayName }));
  }

  /**
   * Revocation par un administrateur d'organisation, quel qu'en soit le porteur.
   *
   * Le cloisonnement borne deja la portee a son organisation ; il n'y a donc pas
   * de filtre sur `userId`, contrairement a `revoke`. C'est la difference entre
   * « je retire mon jeton » et « je coupe celui d'un collegue parti ».
   */
  async revokeAsAdmin(tenantId: string, actorUserId: string, id: string): Promise<void> {
    const res = await this.prisma.forTenant(tenantId).agentToken.updateMany({
      where: { id, revokedAt: null },
      data: { revokedAt: new Date() },
    });
    if (res.count === 0) throw new NotFoundException('Jeton introuvable ou déjà révoqué');
    this.audit.record('agent_token.revoke_admin', { tenantId, actorUserId, tokenId: id });
  }

  /**
   * Emet un jeton. La valeur en clair n'est renvoyee QUE maintenant.
   *
Loading