Un outil n’est pas pour tout le monde
Un outil qui annule toutes les commandes ne doit pas être offert à tout appelant. Un serveur lancé sur une machine n’a pas de page de connexion. C’est celui qui le lance qui dit ce que l’appelant a le droit de faire.
Ici, vous écrivez ce contrôle, et le journal qui garde la trace de chaque appel.
À la fin de cette leçon
Remarque
Je peux limiter les outils d’un serveur selon celui qui appelle.
Mots clés
| Mot | Ce que ça veut dire | Exemple |
|---|---|---|
| Droit | une permission écrite comme un nom | menu:lire |
| Joker | un droit qui en couvre plusieurs | commandes:* |
| Journal | la trace écrite de chaque appel et de ce qu’il a reçu | |
| Refuser | dire non parce que le droit manque | |
| Moindre privilège | ne donner que les droits dont on a besoin |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Un droit par outil
Chaque outil exige un droit. L’appelant arrive avec une liste de droits. Si le droit de l’outil est dans la liste, l’appel passe. Sinon, le serveur refuse.
Un joker comme commandes: couvre toute une famille : commandes:ecrire et commandes:admin, et rien d’autre. Le droit tout seul couvre tout.
Exemple : trois outils, deux droits donnés
| Outil | Droit exigé | Avec menu:lire et commandes:ecrire |
|---|---|---|
| lire_menu | menu:lire | autorisé |
| passer_commande | commandes:ecrire | autorisé |
| tout_annuler | commandes:admin | refusé |
| bruler_cuisine | (aucun : il n’existe pas) | inconnu |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.
Cacher ne suffit pas : il faut aussi refuser
Le serveur de cette leçon ne montre dans tools/list que les outils permis. C’est une aide, pas une protection.
Un appel peut nommer un outil qu’on ne lui a jamais montré. Le serveur contrôle donc le droit à chaque appel. Un droit absent donne une erreur d’outil que le modèle peut lire. Un outil qui n’existe pas donne une erreur de protocole.
Une erreur courante
Attention
Beaucoup croient qu’un outil absent de la liste est un outil protégé. Un appel n’a pas besoin de la liste pour nommer un outil. Le contrôle se fait à chaque appel, et le journal garde la trace des refus comme des succès.
Écrire l’assistant du modèle
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const base = process.env.AI_URL || \"http://ai:4000\";' 'async function demander(messages, echantillon, max) {' ' try {' ' const res = await fetch(base + \"/v1/chat/completions\", { method: \"POST\", headers: { \"content-type\": \"application/json\", authorization: \"Bearer lab\" }, body: JSON.stringify({ model: \"lab\", messages: messages, max_tokens: max || 80 }), signal: AbortSignal.timeout(30000) });' ' if (res.ok === false) throw new Error(\"statut \" + res.status);' ' const donnees = await res.json();' ' return { texte: donnees.choices[0].message.content, enregistre: false };' ' } catch (erreur) {' ' console.log(\"Échantillon enregistré : aucun modèle n’a répondu (\" + erreur.message + \").\");' ' return { texte: echantillon, enregistre: true };' ' }' '}' 'module.exports = { demander };' > model.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le serveur, partie 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const Exigez = { lire_menu: \"menu:lire\", passer_commande: \"commandes:ecrire\", tout_annuler: \"commandes:admin\" };' 'const DROITS = (process.env.DROITS || \"\").split(\",\");' 'let compte = 0;' 'function envoyer(m) { process.stdout.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); }' 'function mots(texte, mauvais) { return { content: [{ type: \"text\", text: texte }], isError: mauvais }; }' 'function noter(outil, decision) {' ' compte += 1;' ' process.stderr.write(JSON.stringify({ n: compte, outil: outil, decision: decision, exige: Exigez[outil] || null }) + \"\n\");' '}' > serveur5.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le serveur, partie 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function appeler(msg, p) {' ' if (Object.keys(Exigez).indexOf(p.name) < 0) {' ' noter(p.name, \"inconnu\");' ' return envoyer({ id: msg.id, error: { code: -32602, message: \"Outil inconnu : \" + p.name } });' ' }' ' if (DROITS.indexOf(Exigez[p.name]) < 0) {' ' noter(p.name, \"refuse\");' ' return envoyer({ id: msg.id, result: mots(\"Refusé : \" + p.name + \" exige \" + Exigez[p.name], true) });' ' }' ' noter(p.name, \"autorise\");' ' envoyer({ id: msg.id, result: mots(\"fait : \" + p.name, false) });' '}' >> serveur5.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le serveur, partie 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function visibles() {' ' return Object.keys(Exigez).filter(function (n) { return DROITS.indexOf(Exigez[n]) >= 0; }).map(function (n) {' ' return { name: n, description: \"Outil de cantine \" + n, inputSchema: { type: \"object\" } };' ' });' '}' >> serveur5.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le serveur, partie 4
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'function traiter(msg) {' ' if (msg.id === undefined) return;' ' const p = msg.params || {};' ' if (msg.method === \"initialize\") return envoyer({ id: msg.id, result: { protocolVersion: p.protocolVersion, capabilities: { tools: {} }, serverInfo: { name: \"cantine\", version: \"0.5\" } } });' ' if (msg.method === \"tools/list\") return envoyer({ id: msg.id, result: { tools: visibles() } });' ' if (msg.method === \"tools/call\") return appeler(msg, p);' ' envoyer({ id: msg.id, error: { code: -32601, message: \"Méthode inconnue : \" + msg.method } });' '}' 'require(\"readline\").createInterface({ input: process.stdin }).on(\"line\", function (ligne) { traiter(JSON.parse(ligne)); });' >> serveur5.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le client scripté, partie 1
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const { demander } = require(\"./model\");' 'const env = Object.assign({}, process.env, { DROITS: \"menu:lire,commandes:ecrire\" });' 'const serveur = require(\"child_process\").spawn(\"node\", [\"serveur5.js\"], { env: env });' 'const appel = function (id, nom) { return { id: id, method: \"tools/call\", params: { name: nom, arguments: {} } }; };' 'const scenario = [' ' { id: 1, method: \"initialize\", params: { protocolVersion: \"2025-03-26\" } },' ' { id: 2, method: \"tools/list\" },' ' appel(3, \"lire_menu\"), appel(4, \"passer_commande\"), appel(5, \"tout_annuler\"), appel(6, \"bruler_cuisine\"),' '];' > client5.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le client scripté, partie 2
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'const vues = [];' 'const journal = [];' 'require(\"readline\").createInterface({ input: serveur.stderr }).on(\"line\", function (ligne) { journal.push(ligne); });' 'require(\"readline\").createInterface({ input: serveur.stdout }).on(\"line\", function (ligne) {' ' const m = JSON.parse(ligne);' ' const noms = m.result && m.result.tools ? \"outils : \" + m.result.tools.map(function (t) { return t.name; }).join(\", \") : null;' ' console.log(\"<- \" + (noms || (m.result && m.result.content ? m.result.content[0].text : JSON.stringify(m.error || m.result))));' ' vues.push(m);' ' if (vues.length === 6) serveur.stdin.end();' '});' 'scenario.forEach(function (m) { serveur.stdin.write(JSON.stringify(Object.assign({ jsonrpc: \"2.0\" }, m)) + \"\n\"); });' >> client5.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Écrire le client scripté, partie 3
docker exec -w /home/node/project agent sh -c "printf '%s\n' 'serveur.on(\"close\", async function () {' ' journal.forEach(function (l) { console.log(\"journal \" + l); });' ' const refus = vues[4].result.content[0].text;' ' const q = \"En une phrase courte, explique ce refus à un élève : \" + refus;' ' const r = await demander([{ role: \"user\", content: q }], \"Votre compte n’a pas le droit d’annuler toutes les commandes.\", 60);' ' console.log(\"le modèle dit : \" + r.texte);' '});' >> client5.js"Affichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Lancer le client contre le serveur
docker exec -w /home/node/project agent node client5.jsAffichée ici, non exécutée. Créez un compte gratuit pour appuyer sur Exécuter et la saisir dans le terminal de votre propre labo.
Ce que le script fait
Le script lance le serveur avec deux droits : menu:lire et commandes:ecrire. Il liste les outils, puis appelle les trois outils et un quatrième qui n’existe pas.
À la fin, il écrit le journal que le serveur a tenu sur stderr, puis demande à un modèle d’expliquer un refus. Sans modèle, le Run affiche un Échantillon enregistré.
Ce que chaque requête reçoit
| Requête | Réponse |
|---|---|
| 2 tools/list | Deux outils seulement : lire_menu et passer_commande |
| 3 lire_menu | fait : lire_menu |
| 4 passer_commande | fait : passer_commande |
| 5 tout_annuler | Refusé : tout_annuler exige commandes:admin |
| 6 bruler_cuisine | Erreur de protocole -32602, outil inconnu |
Les cellules que les étudiants remplissent restent vides dans cet aperçu.