Commit 09ffc4b2 authored by Kourser's avatar Kourser
Browse files

Une machine peut lire la crise, et seulement lire

Lot A0 du cahier des charges IA : un serveur MCP en lecture seule, sur
« POST /mcp », hors du préfixe applicatif.

La garantie qui porte le reste n'est pas dans ce que les huit outils font,
mais dans ce que la liste ne contient pas. Le §7 ne suppose jamais qu'un
modèle résiste à une injection — la veille externe ramène dans la crise du
texte publié par n'importe qui, et un agent lira tôt ou tard une phrase
écrite pour lui. Entièrement détourné, il ne peut ni décider, ni mobiliser,
ni écrire. La suite l'éprouve en exerçant tout le catalogue puis en comptant
les événements du journal : identique, pas « à peu près ».

Le défaut est fermé. Une clé valide ne lit rien tant qu'une personne n'a pas
ouvert une crise nommée, depuis la crise, et l'ouverture s'inscrit en main
courante. Trois interrupteurs au-dessus : la fermeture par crise, la coupure
d'instance avec motif exigé dans les deux sens, la restriction au réseau
local. La déclaration du service et l'échéance sont tenues par la base, pas
seulement par le formulaire.

Ce qui sort est déclaré, pas hérité. Rendre la projection interne telle
quelle marchait, et faisait partir l'empreinte de la chaîne de hachage en
tableau de 32 octets par entrée, avec trois identifiants de compte et de
cellule : 1069 octets par entrée contre 243 après projection. Une surface
héritée s'élargit toute seule ; une surface déclarée demande une décision.

Les coordonnées de l'annuaire ne sortent pas. Les personnes qui y figurent
n'ont consenti à rien, et la plupart ignorent que l'organisation a branché
un agent — le dossier gelé sert les tiers et les fiches réflexes, et dit
l'absence plutôt que de la taire.

Enfin le bandeau, au même rang que le marquage « exercice » et pour la même
raison : dans une salle de crise, on dit ce qui écoute. Il nomme le service,
parce que ce qui compte pour qui consigne est de savoir chez qui sa phrase
atterrit.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 5ab093c0
Loading
Loading
Loading
Loading
Loading
+91 −0
Changes for apps/api/src/db/migrations/0039_agents.sql: 91 added lines, 0 removed lines.
Original line number Diff line number Diff line
-- ═══════════════════════════════════════════════════════════════════════════
-- Interface pour agents — lot A0 : lire, et rien d'autre (EI-01 à EI-49).
--
-- Un serveur MCP par lequel une intelligence artificielle lit une crise en
-- cours. Ce lot n'ouvre AUCUNE écriture : pas de table de propositions, pas
-- d'origine « agent » au journal. Les deux viendront avec A1, si A0 démontre
-- qu'il sert — et le cahier des charges est écrit pour qu'on puisse décider
-- de s'arrêter là.
--
-- Trois interrupteurs, tous fermés par défaut (EI-03) :
--   l'instance      — colonnes sur `instance`, tenues par la console root
--   l'organisation  — l'existence d'une clé de portée « agent »
--   la crise        — une ligne dans `agent_acces_crise`, et elle seule
--
-- Aucun de ces trois ne s'allume par une mise à jour.
-- ═══════════════════════════════════════════════════════════════════════════

-- ── L'interrupteur d'instance (EI-15, EI-46) ───────────────────────────────
--
-- Deux réglages distincts, et non un seul. « Couper » est un geste d'urgence
-- qu'on veut pouvoir faire sans rien casser d'autre et retrouver dans la
-- trace ; « réseau local seulement » est une politique, posée une fois, qui
-- interdit qu'un service distant vienne lire quoi que ce soit.
alter table instance
  add column agents_coupes_at    timestamptz,
  add column agents_coupes_par   uuid references compte(id) on delete set null,
  add column agents_coupes_motif text,
  add column agents_reseau_local boolean not null default false;

-- ── La déclaration, portée par la clé elle-même (EI-12) ────────────────────
--
-- Plutôt qu'une table de branchement à côté : une clé EST un branchement, et
-- les faire vivre séparément aurait permis une clé sans déclaration — c'est-à-
-- dire un agent dont personne ne sait à quel service il appartient.
--
-- « cree_par » tient déjà le mandant : la personne au nom de qui la clé a été
-- délivrée, et qui en répond (EI-18).
alter table cle_integration
  add column agent_service    text,
  add column agent_exploitant text,
  add column agent_pays       text;

-- Une clé de portée « agent » sans déclaration ni échéance n'existe pas.
-- EI-43 : un accès accordé dans l'urgence et jamais retiré est le défaut que
-- le produit refuse déjà pour les intervenants externes ; il n'y a pas de
-- raison de l'accepter pour une machine. La base le tient, pas seulement le
-- code — une contrainte oubliée dans une route se contourne par la suivante.
alter table cle_integration add constraint agent_declare_et_borne check (
  not (portees && array['agent_journal','agent_dossier','agent_transcription'])
  or (agent_service is not null and agent_exploitant is not null
      and agent_pays is not null and expire_at is not null)
);

-- ── L'ouverture, crise par crise (EI-13, EI-44) ────────────────────────────
--
-- Le défaut est fermé, y compris pour une organisation qui a branché un
-- agent : ouvrir une nouvelle crise ne l'étend pas. C'est une ligne qu'un
-- humain pose, et qu'il retire.
create table agent_acces_crise (
  crise_id   uuid        not null references crise(id) on delete cascade,
  cle_id     uuid        not null references cle_integration(id) on delete cascade,
  ouvert_par uuid        references compte(id) on delete set null,
  ouvert_at  timestamptz not null default now(),
  ferme_par  uuid        references compte(id) on delete set null,
  ferme_at   timestamptz,
  primary key (crise_id, cle_id)
);
create index agent_acces_ouvert on agent_acces_crise (crise_id) where ferme_at is null;

-- ── La trace (EI-41, EI-45) ────────────────────────────────────────────────
--
-- Hors journal, délibérément. Le journal est la chaîne des faits de la crise,
-- scellée ; une lecture n'en est pas un, et y inscrire chaque appel d'outil le
-- noierait sous des lignes que personne ne relit. Mais la trace doit exister :
-- le jour où l'on cherche par où une information est sortie, c'est ici qu'on
-- regarde.
--
-- Le volume rendu y figure. Une lecture massive et soudaine est le signal
-- qu'on veut voir, et un compteur d'appels seul ne le montrerait pas.
create table agent_appel (
  id          uuid        primary key default gen_random_uuid(),
  cle_id      uuid        not null references cle_integration(id) on delete cascade,
  crise_id    uuid        references crise(id) on delete set null,
  outil       text        not null,
  etendue     jsonb       not null default '{}'::jsonb,
  octets      integer     not null default 0,
  refuse      text,
  at          timestamptz not null default now()
);
create index agent_appel_cle on agent_appel (cle_id, at desc);
create index agent_appel_crise on agent_appel (crise_id, at desc);
+349 −0
Changes for apps/api/src/domaine/agents.ts: 349 added lines, 0 removed lines.
Original line number Diff line number Diff line
import type { Sql, Tx } from "../db/client.js";
import { empreinte } from "../auth/jetons.js";
import { enregistrer, type Contexte } from "../noyau/enregistrer.js";
import { journaliser } from "../noyau/administration.js";

/**
 * ╔══════════════════════════════════════════════════════════════════════════╗
 * ║  Interface pour agents — lot A0 : lire, et rien d'autre.                 ║
 * ╚══════════════════════════════════════════════════════════════════════════╝
 *
 * Ce module tient les trois interrupteurs du cahier des charges (EI-03) et la
 * trace. Il ne sert aucun outil : c'est `mcp/` qui le fait, et il ne peut le
 * faire que par ici.
 *
 * L'ordre des refus n'est pas indifférent. On coupe d'abord au plus large —
 * l'instance —, puis l'organisation, puis la crise. Un agent refusé doit
 * apprendre qu'il est refusé, jamais pourquoi une crise existe : le message
 * dit « pas d'accès », il ne dit pas « cette crise ne vous est pas ouverte »,
 * ce qui apprendrait qu'elle existe.
 */

/** Les portées d'agent, séparées comme l'exige EI-48. */
export const PORTEES_AGENT = [
  "agent_journal", "agent_dossier", "agent_transcription",
] as const;
export type PorteeAgent = (typeof PORTEES_AGENT)[number];

export interface CleAgent {
  id: string;
  organisationId: string;
  portees: string[];
  service: string;
  exploitant: string;
  pays: string;
  /** La personne au nom de qui la clé a été délivrée, et qui en répond. */
  mandantId: string | null;
  expireAt: Date;
}

export class AgentRefuse extends Error {
  constructor(public readonly code: string, message: string) {
    super(message);
    this.name = "AgentRefuse";
  }
}

/** Ce que la console d'instance a décidé pour tout le monde. */
export interface EtatInstance {
  coupes: boolean;
  motif: string | null;
  reseauLocal: boolean;
}

export async function etatInstance(sql: Sql | Tx): Promise<EtatInstance> {
  const [i] = await sql<{
    agents_coupes_at: Date | null; agents_coupes_motif: string | null;
    agents_reseau_local: boolean;
  }[]>`
    select agents_coupes_at, agents_coupes_motif, agents_reseau_local from instance`;
  return {
    coupes: i?.agents_coupes_at !== null && i?.agents_coupes_at !== undefined,
    motif: i?.agents_coupes_motif ?? null,
    reseauLocal: i?.agents_reseau_local ?? false,
  };
}

/**
 * Une adresse de la machine ou de son réseau immédiat.
 *
 * Volontairement étroit, et volontairement pas configurable : « réseau local
 * seulement » doit vouloir dire quelque chose de simple qu'un exploitant peut
 * vérifier lui-même. Les plages privées classiques, la boucle locale, et le
 * lien-local. Un client derrière un proxy inverse arrivera avec l'adresse du
 * proxy — c'est à l'exploitant de ne pas exposer ce chemin s'il a coché ce
 * réglage.
 */
export function adresseLocale(ip: string): boolean {
  const v = ip.replace(/^::ffff:/, "");
  if (v === "::1" || v === "127.0.0.1") return true;
  if (/^127\./.test(v)) return true;
  if (/^10\./.test(v)) return true;
  if (/^192\.168\./.test(v)) return true;
  if (/^172\.(1[6-9]|2\d|3[01])\./.test(v)) return true;
  if (/^169\.254\./.test(v)) return true;
  if (/^f[cd]/i.test(v)) return true; // adresses uniques locales, en IPv6
  return false;
}

/**
 * Résoudre la clé présentée, et refuser tout de suite si l'instance a coupé.
 *
 * EI-42 : la révocation est effective à la requête suivante. Rien n'est mis en
 * cache ici — une clé retirée pendant qu'un agent travaille cesse de servir au
 * prochain appel, sans redémarrage ni délai.
 */
export async function resoudreCleAgent(
  sql: Sql, jetonPresente: string | null, ipAppelant: string,
): Promise<CleAgent> {
  const etat = await etatInstance(sql);
  if (etat.coupes) {
    throw new AgentRefuse("agents_coupes",
      "Les agents sont coupés sur cette instance.");
  }
  if (etat.reseauLocal && !adresseLocale(ipAppelant)) {
    throw new AgentRefuse("reseau_refuse",
      "Cette instance n'accepte que des clients de son propre réseau.");
  }
  if (!jetonPresente) {
    throw new AgentRefuse("cle_absente",
      "Présentez la clé en en-tête Authorization: Bearer.");
  }

  const [ligne] = await sql<{
    id: string; organisation_id: string; portees: string[];
    agent_service: string | null; agent_exploitant: string | null;
    agent_pays: string | null; cree_par: string | null; expire_at: Date | null;
  }[]>`
    select id, organisation_id, portees, agent_service, agent_exploitant,
           agent_pays, cree_par, expire_at
      from cle_integration
     where empreinte = ${empreinte(jetonPresente)} and revoque_at is null
       and (expire_at is null or expire_at > now())`;
  if (!ligne) {
    throw new AgentRefuse("cle_invalide", "Clé inconnue, expirée ou révoquée.");
  }
  const agent = ligne.portees.some((p) => (PORTEES_AGENT as readonly string[]).includes(p));
  if (!agent) {
    throw new AgentRefuse("portee_insuffisante",
      "Cette clé ne porte aucune portée d'agent.");
  }
  // La contrainte de base l'assure déjà ; on ne sert pas une clé douteuse au
  // motif qu'elle a été écrite avant elle.
  if (!ligne.agent_service || !ligne.agent_exploitant || !ligne.agent_pays
      || !ligne.expire_at) {
    throw new AgentRefuse("cle_incomplete",
      "Cette clé d'agent est sans déclaration ou sans échéance.");
  }

  await sql`update cle_integration
               set dernier_usage_at = now(), usages = usages + 1
             where id = ${ligne.id}`;

  return {
    id: ligne.id, organisationId: ligne.organisation_id, portees: ligne.portees,
    service: ligne.agent_service, exploitant: ligne.agent_exploitant,
    pays: ligne.agent_pays, mandantId: ligne.cree_par, expireAt: ligne.expire_at,
  };
}

export function exigerPortee(cle: CleAgent, portee: PorteeAgent): void {
  if (!cle.portees.includes(portee)) {
    throw new AgentRefuse("portee_insuffisante",
      `Cette clé ne porte pas « ${portee} ».`);
  }
}

/**
 * Les crises que cette clé peut lire.
 *
 * Jamais « toutes celles de l'organisation » : EI-44 exige une ouverture
 * explicite, et l'ouverture d'une crise nouvelle ne l'étend pas. C'est la
 * différence entre un accès accordé et un accès qui s'étend tout seul.
 */
export async function crisesOuvertes(
  sql: Sql, cle: CleAgent,
): Promise<{ id: string; intitule: string; gravite: string; phase: string;
  exercice: boolean; ouvert_at: Date }[]> {
  return sql`
    select c.id, c.intitule, c.gravite, c.phase, c.exercice, a.ouvert_at
      from agent_acces_crise a
      join crise c on c.id = a.crise_id
     where a.cle_id = ${cle.id} and a.ferme_at is null
       and c.organisation_id = ${cle.organisationId}
     order by a.ouvert_at desc`;
}

export async function criseOuverteA(
  sql: Sql, cle: CleAgent, criseId: string,
): Promise<boolean> {
  const [r] = await sql`
    select 1 from agent_acces_crise a join crise c on c.id = a.crise_id
     where a.cle_id = ${cle.id} and a.crise_id = ${criseId}
       and a.ferme_at is null and c.organisation_id = ${cle.organisationId}`;
  return r !== undefined;
}

/** Refuse sans apprendre si la crise existe. */
export async function exigerCrise(
  sql: Sql, cle: CleAgent, criseId: string,
): Promise<void> {
  if (!await criseOuverteA(sql, cle, criseId)) {
    throw new AgentRefuse("crise_fermee",
      "Aucune crise de cet identifiant ne vous est ouverte.");
  }
}

// ── Ouvrir et fermer, par un humain ────────────────────────────────────────

export async function ouvrirCriseAAgent(
  sql: Sql, criseId: string, cleId: string, ctx: Contexte,
): Promise<void> {
  const [cle] = await sql<{
    agent_service: string | null; agent_exploitant: string | null;
    agent_pays: string | null; organisation_id: string;
  }[]>`
    select agent_service, agent_exploitant, agent_pays, organisation_id
      from cle_integration
     where id = ${cleId} and revoque_at is null
       and (expire_at is null or expire_at > now())`;
  if (!cle?.agent_service || !cle.agent_exploitant || !cle.agent_pays) {
    throw new AgentRefuse("cle_invalide", "Clé d'agent inconnue ou incomplète.");
  }
  const [meme] = await sql`
    select 1 from crise where id = ${criseId}
       and organisation_id = ${cle.organisation_id}`;
  if (!meme) {
    throw new AgentRefuse("cle_invalide",
      "Cette clé appartient à une autre organisation.");
  }

  await sql.begin(async (tx) => {
    await tx`
      insert into agent_acces_crise (crise_id, cle_id, ouvert_par)
      values (${criseId}, ${cleId}, ${ctx.acteurId})
      on conflict (crise_id, cle_id) do update
        set ferme_at = null, ferme_par = null, ouvert_at = now(),
            ouvert_par = ${ctx.acteurId}`;
    await enregistrer(tx, {
      criseId, type: "agent.acces_ouvert",
      charge: {
        cle_id: cleId, service: cle.agent_service!,
        exploitant: cle.agent_exploitant!, pays: cle.agent_pays!,
      },
      ctx,
    });
  });
}

export async function fermerCriseAAgent(
  sql: Sql, criseId: string, cleId: string, ctx: Contexte,
): Promise<boolean> {
  const [cle] = await sql<{ agent_service: string | null }[]>`
    select agent_service from cle_integration where id = ${cleId}`;
  const [ferme] = await sql`
    update agent_acces_crise set ferme_at = now(), ferme_par = ${ctx.acteurId}
     where crise_id = ${criseId} and cle_id = ${cleId} and ferme_at is null
    returning cle_id`;
  if (!ferme) return false;
  await sql.begin((tx) => enregistrer(tx, {
    criseId, type: "agent.acces_ferme",
    charge: { cle_id: cleId, service: cle?.agent_service ?? "inconnu" },
    ctx,
  }));
  return true;
}

/** Ce que la coque affiche en permanence tant qu'un agent peut lire (EI-14). */
export async function agentsSurLaCrise(
  sql: Sql, criseId: string,
): Promise<{ cle_id: string; service: string; exploitant: string; pays: string;
  ouvert_at: Date; dernier_appel_at: Date | null }[]> {
  return sql`
    select a.cle_id, k.agent_service as service, k.agent_exploitant as exploitant,
           k.agent_pays as pays, a.ouvert_at,
           (select max(at) from agent_appel p
             where p.cle_id = a.cle_id and p.crise_id = a.crise_id) as dernier_appel_at
      from agent_acces_crise a join cle_integration k on k.id = a.cle_id
     where a.crise_id = ${criseId} and a.ferme_at is null
       and k.revoque_at is null and (k.expire_at is null or k.expire_at > now())
     order by a.ouvert_at`;
}

// ── La trace (EI-41) ───────────────────────────────────────────────────────

export async function tracer(
  sql: Sql, cleId: string, outil: string,
  d: { criseId?: string | null; etendue?: unknown; octets?: number; refuse?: string },
): Promise<void> {
  await sql`
    insert into agent_appel (cle_id, crise_id, outil, etendue, octets, refuse)
    values (${cleId}, ${d.criseId ?? null}, ${outil},
            ${sql.json((d.etendue ?? {}) as never)}, ${d.octets ?? 0},
            ${d.refuse ?? null})`;
}

/**
 * Le débit, par clé (EI-45).
 *
 * Le dépassement est un fait consigné et non un simple refus : une lecture
 * massive et soudaine est précisément le signal qu'on veut retrouver après
 * coup. Compter en octets rendus autant qu'en appels — cent appels menus et
 * un seul qui rapatrie tout ne sont pas le même événement.
 */
export const PLAFOND_APPELS = 240;
export const PLAFOND_OCTETS = 8 * 1024 * 1024;
export const FENETRE_MINUTES = 5;

export async function debitDepasse(
  sql: Sql, cleId: string,
): Promise<{ depasse: boolean; appels: number; octets: number }> {
  const [r] = await sql<{ appels: string; octets: string }[]>`
    select count(*)::text as appels, coalesce(sum(octets), 0)::text as octets
      from agent_appel
     where cle_id = ${cleId}
       and at > now() - (${FENETRE_MINUTES} || ' minutes')::interval`;
  const appels = Number(r?.appels ?? 0);
  const octets = Number(r?.octets ?? 0);
  return {
    depasse: appels >= PLAFOND_APPELS || octets >= PLAFOND_OCTETS,
    appels, octets,
  };
}

// ── La console d'instance (EI-46) ──────────────────────────────────────────

export async function couperAgents(
  sql: Sql, acteurId: string, sessionId: string | null, motif: string,
): Promise<void> {
  await sql.begin(async (tx) => {
    await tx`update instance set agents_coupes_at = now(),
                agents_coupes_par = ${acteurId}, agents_coupes_motif = ${motif}`;
    await journaliser(tx, {
      type: "instance.agents_coupes", acteurId, sessionId, charge: { motif },
    });
  });
}

export async function retablirAgents(
  sql: Sql, acteurId: string, sessionId: string | null, motif: string,
): Promise<void> {
  await sql.begin(async (tx) => {
    await tx`update instance set agents_coupes_at = null,
                agents_coupes_par = null, agents_coupes_motif = null`;
    await journaliser(tx, {
      type: "instance.agents_retablis", acteurId, sessionId, charge: { motif },
    });
  });
}

export async function definirReseauLocal(
  sql: Sql, acteurId: string, sessionId: string | null, actif: boolean,
): Promise<void> {
  await sql.begin(async (tx) => {
    await tx`update instance set agents_reseau_local = ${actif}`;
    await journaliser(tx, {
      type: "instance.agents_reseau_local", acteurId, sessionId, charge: { actif },
    });
  });
}
+13 −0
Changes for apps/api/src/langues/en.ts: 13 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -306,4 +306,17 @@ export const EN: Record<string, string> = {
    "Only the crisis director restarts a transcription.",
  "Transcription demandée. Elle s'annoncera en main courante.":
    "Transcription requested. It will announce itself in the log.",
  "Accès déjà fermé.": "Access already closed.",
  "Accès fermé. Effectif à la requête suivante.":
    "Access closed. Effective from the next request.",
  "Clé révoquée. Effective à la requête suivante.":
    "Key revoked. Effective from the next request.",
  "Crise ouverte à cet agent. Tout ce qu'il lira quittera l'instance.":
    "Crisis opened to this agent. Everything it reads will leave the instance.",
  "La trace des lectures est réservée à la direction de crise.":
    "The record of readings is reserved for the crisis director.",
  "Seul le directeur de crise ouvre une crise à un agent.":
    "Only the crisis director opens a crisis to an agent.",
  "Seul le directeur de crise referme l'accès d'un agent.":
    "Only the crisis director closes an agent's access.",
};
+327 −0

File added.

Preview size limit exceeded, changes collapsed.

+263 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading