merge feature/mcp-tool-permissions-sync dans develop (#82: sync permissions MCP <-> tools exposés)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 15:57:53 +02:00
46 changed files with 1083 additions and 338 deletions

View File

@ -37,7 +37,7 @@ Tu t'occupes **du repo git local** :
Le dépôt s'articule autour de trois niveaux :
```
```text
main ← branche de RELEASE. Stable, livrable. On n'y commite jamais en direct.
develop ← branche d'INTÉGRATION. On y merge chaque feature une fois TERMINÉE et VERTE.
@ -62,7 +62,7 @@ feature/* ← une branche PAR nouvelle feature. C'est là que le dev se fait.
Tu interviens à **deux moments** du cycle de dev (cf. CLAUDE.md §3), encadré par Main :
```
```text
1. Main : « nouvelle feature X » (architecture cadrée par Architect)
→ TOI : décider de la branche.
- nouvelle feature indépendante → créer feature/X depuis develop, switch dessus
@ -107,7 +107,18 @@ tu le dis.
---
## 5. Délégation & collaboration
## 5. Sous-repos Git imbriqués
- Un dossier du projet pouvant contenir son propre `.git` fait **pleinement partie de ton périmètre** de gestion du dépôt local.
- Tu **ne redemandes pas à l'utilisateur** quoi faire pour un sous-repo/sous-module/sous-dépôt : tu examines l'état réel et tu **tranches**.
- Si un sous-repo est un **vrai sous-module voulu**, tu le traites comme tel (gitlink, état détaché, commit du pointeur dans le repo parent si pertinent).
- Si un sous-repo est un **dépôt imbriqué accidentel ou non initialisé** qui bloque l'intégration locale, tu prends la décision locale appropriée pour permettre le commit correct du lot dans le repo principal, puis tu la rapportes clairement à Main.
- Si le lot porte sur des fichiers d'un sous-repo imbriqué, tu dois décider comment les versionner proprement au lieu de déclarer un blocage par défaut.
- Tu ne considères pas la simple présence d'un `.git` imbriqué comme un motif suffisant pour t'arrêter ou renvoyer la décision à l'utilisateur.
---
## 6. Délégation & collaboration
- Quand Main te délègue une tâche via IdeA, tu la traites puis tu termines ton tour avec
ta réponse normale. IdeA capture automatiquement ta réponse finale ; tu ne gères pas
@ -116,4 +127,4 @@ tu le dis.
court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette
branche, pourquoi ce merge ou ce non-merge).
- En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces
pas une résolution hasardeuse.
pas une résolution hasardeuse.

View File

@ -10,7 +10,7 @@
## 1. Ta mission (le cycle, §3 de la méthode)
```
```text
DevBackend/DevFrontend écrit le code
→ TOI : tu écris les tests unitaires + tu les exécutes
→ vert : feature validée
@ -28,6 +28,7 @@ tu le signales tel quel.
- `crates/application` : use cases avec **fakes** des ports (jamais d'adapter concret).
- `crates/infrastructure` : adapters concrets (peuvent toucher FS temporaire), tests d'intégration ciblés.
- `crates/app-tauri` : DTO (round-trip serde), wiring.
- `crates/web-server` : handlers / Web API / mapping requête-réponse / erreurs.
- Commandes : `cargo test -p <crate>` ciblé, `cargo test --workspace` global.
**Frontend (TS/React)** :
@ -42,6 +43,7 @@ tu le signales tel quel.
- **Round-trip de sérialisation** (DTO ↔ domaine, fichiers `.ideai/*.json`).
- **Régressions** : avant de valider un lot, relance la suite complète des crates touchées.
- **Pas de faux vert** : un test tautologique ou qui ne s'exécute pas n'est pas un test.
- **Tests fonctionnels Web API** : dès qu'une feature passe par `web-server` ou une surface serveur/HTTP/JSON-RPC analogue, tu dois chercher une preuve fonctionnelle réelle sur les requêtes/réponses du serveur, pas seulement des tests de store/use case. Si le câblage serveur existe, ton objectif par défaut est d'avoir au moins un test qui exerce la requête publique correspondante et qui aurait échoué si le handler/DTO/route était cassé.
## 4. Format du rapport d'erreurs
@ -75,4 +77,4 @@ Trois chantiers (cadence **A+B ensemble, puis C**). Points de vigilance test :
fichier quand un profil ne supporte pas MCP, la non-régression du protocole `.ideai/requests`.
Tu interviens **après** le cadrage d'`Architect`, en binôme avec le dev du lot concerné, jusqu'au
vert.
vert.

View File

@ -1,8 +1,8 @@
---
issueRef: "#103"
version: 4
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1785013507979
version: 5
updatedBy: {"kind":"user"}
updatedAt: 1785271085635
---
# Carnet #103 — permission réseau exposée dans IdeA

View File

@ -2,16 +2,16 @@
id: "3d9021da-26c3-439d-9463-8d206bd06f1b"
number: 103
title: "Exposer et piloter la permission réseau des agents/commandes dans IdeA"
status: "qa"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1785011668081
updatedAt: 1785013507979
version: 4
updatedAt: 1785271085635
version: 5
---
## Problème

View File

@ -1,6 +1,6 @@
---
issueRef: "#107"
version: 3
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1785136945441
updatedAt: 1785271098557
---

View File

@ -2,7 +2,7 @@
id: "6a79006b-0201-4176-ae54-39a05cc3baa6"
number: 107
title: "[Bug] croisement entre les projet des retours des agents"
status: "open"
status: "closed"
priority: "critical"
sprint: null
links: []
@ -10,8 +10,8 @@ agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1785136727824
updatedAt: 1785136945441
version: 3
updatedAt: 1785271098557
version: 4
---
Il y a un soucis très important que j'ai constatés. J'ai actuellement 2 projets ouverts: IdeA et GameTime. Les deux projets travaillaient en même temps et j'ai vu GameTime qui semblait récupérer une requete du projet IdeA. Pour plus de précision, sur mes deux projets, j'ai un agent Git qui utilise OpenCode et un modelle local llamacpp, et j'ia eu l'impression qu'ils ont tous les deux appelé leur agent Git mais GameTime à recus la réponse de IdeA car la réponse parlait d'une branche du projet IdeA.
Je ne suis pas totalement sur de ce que j'avance, la seule chose dont je suis sur, c'est que GameTime s'est vu adressé une réponse qui était déstinée au projet IdeA.

View File

@ -0,0 +1,6 @@
---
issueRef: "#108"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1785328001206
---

View File

@ -0,0 +1,17 @@
---
id: "d5953745-406f-406f-9008-916de0527cbf"
number: 108
title: "Ajouter la possibilité de joindre des fichiers aux tickets"
status: "closed"
priority: "medium"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1785311525586
updatedAt: 1785328001206
version: 3
---
J'aimerais pouvoir ajouter des fichiers (photo, texte, xml etc...) lisible par les agents AI a mes tickets. Dans le cas ou un fichier a déjà été traité par un agent, il faudrait que le fichier soit résumé dans le carnet et flag par les agents de façon a ce que si plusieurs agents lisent le même tickets, ils ne grillent pas tous leurs tokens a lire le fichier

View File

@ -0,0 +1,6 @@
---
issueRef: "#109"
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1785328001167
---

View File

@ -0,0 +1,17 @@
---
id: "1c6440f1-806f-41e4-92c9-6cef30d3023e"
number: 109
title: "Ajouter le nom du créateur de ticket"
status: "closed"
priority: "medium"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1785311822279
updatedAt: 1785328001167
version: 4
---
J'aiemrais que le nom de celui qui a créé le ticket soit ajouté au ticket (nom de l'agent agent ou utilisateur). et qu'un filtre soit ajouté dans la liste des tickets

View File

@ -0,0 +1,6 @@
---
issueRef: "#111"
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1785331658707
---

View File

@ -0,0 +1,17 @@
---
id: "19adb66b-34db-4c44-98b7-d6383759e90c"
number: 111
title: "Gestion tmp des outils idea"
status: "open"
priority: "medium"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1785331481527
updatedAt: 1785331658707
version: 2
---
dans le dossier /tmp, je vois enormement de dossier du genre : idea-structured-session-factory-openai-e5bbdcac-0405-4ce3-be82-00d9639ee648 ou encore idea-web-root-4fbc4144-065c-4915-9e05-9f6fec20aa3b ou idea-server-test-863efbe5-5098-4f34-aab8-1ba5047f4209 ou idea-openai-compat-unreachable-f713f5fa-5469-4875-96f9-242f7913441b ou idea-openai-compat-tools-rejected-d000d319-8c11-4585-8a16-6ae8a3b45c49 ou idea-openai-compat-status-422-9fd4b27f-e0c1-4043-9fb9-72dcf8d2c2e1 ou idea-openai-compat-single-final-940b1d63-6d4a-40b0-953a-0f3279ec2bdc etc qui sont visiblement créé par IdeA. J'aiemrais que pour un maximum d'entre eux, on puisse clean ça au bon momment. Il faut donc identifier ce qui créé ce cache, puis le supprimer quand ce cache n'est plus utile.

View File

@ -0,0 +1,6 @@
---
issueRef: "#112"
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1785332227989
---

View File

@ -0,0 +1,17 @@
---
id: "ff8e11d1-98f8-4c6c-b5d0-a8a087c1dbbc"
number: 112
title: "Pouvoir ajouter plusieurs tickets a la fois a un sprint"
status: "open"
priority: "medium"
sprint: null
links: []
agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}]
attachments: []
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1785332192429
updatedAt: 1785332227989
version: 3
---
Je veux qu'on ajoute la possibilité de set le sprint des tickets selectionnés grace a la selection multiple de ticket dans la liste des tickets.

View File

@ -1,8 +1,8 @@
---
issueRef: "#15"
version: 5
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784093861343
version: 6
updatedBy: {"kind":"user"}
updatedAt: 1785271075239
---
## Cadrage Architect — DIFFÉRÉ (2026-07-13)

View File

@ -2,16 +2,16 @@
id: "5de121f3-1cd1-4a73-bcab-b0a43a7c257f"
number: 15
title: "[Bloqué par #7 — design durable] Limites de session — re-livraison auto parquée au reset (stretch B5)"
status: "open"
status: "closed"
priority: "low"
sprint: "e28a4d53-8bd2-446a-b0ac-2a017373b8b2"
links: [{"target":"#7","kind":"dependsOn"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1783208599385
updatedAt: 1784093861343
version: 5
updatedAt: 1785271075239
version: 6
---
Extrait du cadrage Architect du ticket #7 (baseline livrée : propagation inter-agent B1→B3 + F1/F2). Stretch non retenu dans #7.

View File

@ -1,6 +1,6 @@
---
issueRef: "#58"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784064521992
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1785271075234
---

View File

@ -2,16 +2,16 @@
id: "495699aa-d5d1-409f-8830-cdfaba7a4be3"
number: 58
title: "[B/F] Rendu live des tâches de fond — subscriber UI + canal IPC attachable au task output"
status: "open"
status: "closed"
priority: "low"
sprint: null
links: [{"target":"#2","kind":"dependsOn"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784064521992
updatedAt: 1784064521992
version: 1
updatedAt: 1785271075234
version: 2
---
Sorti du ticket #2 lors de sa requalification (2026-07-14).

View File

@ -1,6 +1,6 @@
---
issueRef: "#67"
version: 2
version: 3
updatedBy: {"kind":"user"}
updatedAt: 1784193467784
updatedAt: 1785271075247
---

View File

@ -2,7 +2,7 @@
id: "e3d16c70-01c5-448e-9b5e-37ba2ccbbb58"
number: 67
title: "Lock inter-process de l'app-data-dir (desktop ↔ idea --serve)"
status: "open"
status: "closed"
priority: "low"
sprint: "028179b1-eaf4-41e9-9c1f-7c37125117e6"
links: [{"target":"#13","kind":"dependsOn"}]
@ -10,8 +10,8 @@ agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784187618710
updatedAt: 1784193467784
version: 2
updatedAt: 1785271075247
version: 3
---
Issu de la validation live #13. `idea --serve` et l'app desktop peuvent écrire le même app-data-dir (`~/.local/share/app.idea.ide`) simultanément — pas de lock inter-process → risque de corruption d'état (projects.json/profiles.json/tasks). Aujourd'hui contourné par une simple consigne de doc (« ne pas lancer les deux en même temps »).

View File

@ -1,6 +1,6 @@
---
issueRef: "#71"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784210443885
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1785271075230
---

View File

@ -2,16 +2,16 @@
id: "4d78a0e9-b54c-4db6-92e5-1461c17d7c84"
number: 71
title: "Diagnostic d'accessibilité du serveur : dire pourquoi ça ne marche pas, pas seulement que ça tourne"
status: "open"
status: "closed"
priority: "medium"
sprint: null
links: [{"target":"#68","kind":"relatesTo"},{"target":"#65","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784210443885
updatedAt: 1784210443885
version: 1
updatedAt: 1785271075230
version: 2
---
Issu d'une session de debug live réelle (2026-07-16) où un serveur `idea-serve` CORRECTEMENT configuré est resté injoignable, sans que rien dans le produit n'indique pourquoi. Le serveur tournait, se croyait bon, affichait « listening on … ». Deux boucles de debug successives ont été nécessaires, dont aucune n'était diagnosticable depuis le produit.

View File

@ -1,6 +1,6 @@
---
issueRef: "#73"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784223044517
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1785271075224
---

View File

@ -2,16 +2,16 @@
id: "a43a07d0-573f-41e9-9b7b-2f88beb4e660"
number: 73
title: "TLS intégré à idea-serve : supprimer la cause racine de la cérémonie reverse proxy"
status: "open"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#72","kind":"relatesTo"},{"target":"#66","kind":"relatesTo"},{"target":"#68","kind":"relatesTo"},{"target":"#71","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784223044517
updatedAt: 1784223044517
version: 1
updatedAt: 1785271075224
version: 2
---
**Décision utilisateur (2026-07-16) : IdeA gagne le TLS intégré.** Contrainte posée : prendre la technologie la plus légère.

View File

@ -1,6 +1,6 @@
---
issueRef: "#80"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784287697560
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1785271075219
---

View File

@ -2,16 +2,16 @@
id: "f36ada98-0ca1-41e6-94c1-24eb81731fee"
number: 80
title: "L'environnement de QA ne peut pas ouvrir de socket — il ne peut pas valider les features réseau"
status: "open"
status: "closed"
priority: "medium"
sprint: null
links: [{"target":"#77","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784287697560
updatedAt: 1784287697560
version: 1
updatedAt: 1785271075219
version: 2
---
## Constat

View File

@ -1,6 +1,6 @@
---
issueRef: "#84"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784377833773
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1785271075213
---

View File

@ -2,16 +2,16 @@
id: "beab1811-2fd7-4563-ac39-6a354fbd1feb"
number: 84
title: "[Bug] Reprise auto après limite de session ne se déclenche pas pour l'orchestrator (Main, profil Claude)"
status: "open"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#7","kind":"relatesTo"},{"target":"#15","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784377833773
updatedAt: 1784377833773
version: 1
updatedAt: 1785271075213
version: 2
---
Rapporté par l'utilisateur (2026-07-18) sur l'AppImage courante (buildée depuis develop, contient #7 baseline + #30 fix chemin direct Main) : quand l'orchestrator (Main) atteint une limite de session sur un profil Claude, la reprise automatique au reset n'a PAS lieu en pratique, alors que la feature #7 (mergée d7041c5, 2026-06-17) et le fix #30 dédié précisément au cas "Main qui limite session" (commit 9430c65, 2026-07-13, "brancher le handle de limite sur le chemin direct") sont censés couvrir ce cas.

View File

@ -1,6 +1,6 @@
---
issueRef: "#85"
version: 1
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1784379436323
version: 2
updatedBy: {"kind":"user"}
updatedAt: 1785271075206
---

View File

@ -2,16 +2,16 @@
id: "34b23044-4b82-4e7e-b999-cca7cbef7861"
number: 85
title: "Test flaky : setCellAgent « changing the agent kills the previous PTY (Bug #3) » échoue en exécution shuffled"
status: "open"
status: "closed"
priority: "low"
sprint: null
links: [{"target":"#79","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784379436323
updatedAt: 1784379436323
version: 1
updatedAt: 1785271075206
version: 2
---
Découvert en marge de #79 (DevFrontend puis confirmé par QA sur `npx vitest run --sequence.shuffle`, ~2 échecs sur 3 passages) : `src/features/layout/setCellAgent.test.tsx` → « changing the agent kills the previous PTY (Bug #3) ».

View File

@ -1,6 +1,6 @@
---
issueRef: "#91"
version: 4
version: 5
updatedBy: {"kind":"user"}
updatedAt: 1784994105390
updatedAt: 1785271075172
---

View File

@ -2,7 +2,7 @@
id: "2d54c254-d2e8-44b3-ac12-3bf20f818d23"
number: 91
title: "Sur la notification de fin de tache backend, mettre plutot les deux agents en conversation"
status: "open"
status: "closed"
priority: "medium"
sprint: "5afd6780-0f76-40d7-a10f-32ee52469d74"
links: []
@ -10,7 +10,7 @@ agentRefs: [{"agentId":"a6ced819-b893-4213-b003-9e9dc79b9641","role":"assigned"}
createdBy: {"kind":"user"}
updatedBy: {"kind":"user"}
createdAt: 1784652125081
updatedAt: 1784994105390
version: 4
updatedAt: 1785271075172
version: 5
---
Sur la notification de fin de tache backend, mettre plutot les deux agents en conveersation et qui a lancé l'appel (par exemple Main->DevBackend) pour que ça soit un peu plus explicite

View File

@ -1,8 +1,8 @@
---
issueRef: "#96"
version: 3
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1784994119004
updatedAt: 1785271075243
---
## Décision (2026-07-24) : différée — documentée, non codée

View File

@ -2,7 +2,7 @@
id: "d5993d21-307b-4fc8-93f9-2bfbf44223d8"
number: 96
title: "Les assistants de ticket (tous adaptateurs) n'ont aucune résolution EffectivePermissions — permission toujours native/absente"
status: "open"
status: "closed"
priority: "low"
sprint: "883534aa-7fc1-4d83-a9c0-17cac4a4eea5"
links: []
@ -10,8 +10,8 @@ agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784823281794
updatedAt: 1784994119004
version: 3
updatedAt: 1785271075243
version: 4
---
Trouvé en implémentant #94 (projection des permissions dans opencode.json). Le call site des assistants de ticket dans `crates/infrastructure/src/assistant/mod.rs` n'a **aucun** `PermissionStore`/`EffectivePermissions` câblé, pour aucun adaptateur (Claude, Codex, OpenCode) — vérifié par grep sur la composition root `crates/backend/src/lib.rs`. Le fix #94 y passe donc `eff: None` (préserve le comportement natif existant, pas de régression), mais ce n'est pas un vrai fix de fond : les permissions IdeA configurées pour un agent n'ont jamais été appliquées aux assistants de ticket, quel que soit l'adaptateur.

View File

@ -1,8 +1,8 @@
---
issueRef: "#99"
version: 3
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedAt: 1785059758742
version: 4
updatedBy: {"kind":"user"}
updatedAt: 1785271085627
---
# Carnet #99 — Cadrage (prêt pour le cycle)

View File

@ -2,16 +2,16 @@
id: "45733f3f-5ef5-4f33-96e7-25afddcd1ce6"
number: 99
title: "Modèle par agent contrôlable pour Codex & Claude (headless + TUI) — égaler le pattern OpenCode"
status: "qa"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#98","kind":"relatesTo"}]
agentRefs: []
createdBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"agent","agent_id":"a6ced819-b893-4213-b003-9e9dc79b9641"}
updatedBy: {"kind":"user"}
createdAt: 1784987973464
updatedAt: 1785059758742
version: 3
updatedAt: 1785271085627
version: 4
---
## Problème

View File

@ -1,3 +1,3 @@
{
"nextNumber": 107
"nextNumber": 113
}

File diff suppressed because it is too large Load Diff

View File

@ -13,7 +13,7 @@ use domain::ports::{
AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker,
ToolSpec,
};
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
use infrastructure::{TemplateToolProvider, TicketToolProvider};
use serde_json::Value;
@ -62,6 +62,22 @@ impl ToolInvoker for LateBoundOpenAiToolInvoker {
.map_or_else(Vec::new, |inner| inner.tools())
}
async fn tools_for_context(
&self,
project_root: &str,
requester: &str,
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
let inner = self
.inner
.lock()
.expect("mutex sain")
.clone()
.ok_or_else(|| {
ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned())
})?;
inner.tools_for_context(project_root, requester).await
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let inner = self
.inner
@ -102,14 +118,38 @@ impl ToolInvoker for AppOpenAiToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
infrastructure::orchestrator::mcp::catalogue()
.into_iter()
.map(|tool| ToolSpec {
name: tool.name.to_owned(),
description: tool.description.to_owned(),
input_schema: tool.input_schema,
})
.map(tool_def_to_spec)
.collect()
}
async fn tools_for_context(
&self,
project_root: &str,
requester: &str,
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
let ephemeral_policy = self.policies.get_policy(requester);
let project = self.find_project(project_root).await?;
let surface = infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface(
Some(self.mcp_tool_permissions.as_ref()),
&project,
requester,
ephemeral_policy.as_ref(),
)
.await
.map_err(|e| {
ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}"))
})?;
Ok(
infrastructure::orchestrator::mcp::policy::effective_tool_catalogue(
ephemeral_policy.as_ref(),
&surface,
)
.into_iter()
.map(tool_def_to_spec)
.collect(),
)
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let value: Value = serde_json::from_str(args_json)
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
@ -140,18 +180,7 @@ impl ToolInvoker for AppOpenAiToolInvoker {
if let Some(policy) = &ephemeral_policy {
enforce_policy(policy, &requester, name, &value)?;
}
let project = self
.projects
.list_projects()
.await
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
.into_iter()
.find(|project| project.root.as_str() == project_root)
.ok_or_else(|| {
ToolInvocationError::Execution(format!(
"projet introuvable pour root `{project_root}`"
))
})?;
let project = self.find_project(&project_root).await?;
enforce_durable_tool_policy(
self.mcp_tool_permissions.as_ref(),
&project,
@ -209,6 +238,30 @@ impl ToolInvoker for AppOpenAiToolInvoker {
}
}
impl AppOpenAiToolInvoker {
async fn find_project(&self, project_root: &str) -> Result<Project, ToolInvocationError> {
self.projects
.list_projects()
.await
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
.into_iter()
.find(|project| project.root.as_str() == project_root)
.ok_or_else(|| {
ToolInvocationError::Execution(format!(
"projet introuvable pour root `{project_root}`"
))
})
}
}
fn tool_def_to_spec(tool: infrastructure::orchestrator::mcp::ToolDef) -> ToolSpec {
ToolSpec {
name: tool.name.to_owned(),
description: tool.description.to_owned(),
input_schema: tool.input_schema,
}
}
async fn enforce_durable_tool_policy(
store: &dyn McpToolPermissionStore,
project: &Project,
@ -239,38 +292,17 @@ async fn durable_tool_policy(
ephemeral_policy: Option<&AgentToolPolicy>,
requester: &str,
) -> Result<Option<McpToolPolicy>, ToolInvocationError> {
let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names();
let policy = if let Some(agent_id) = requester_agent_id(requester) {
let doc = store
.load_mcp_tool_permissions(project)
.await
.map_err(|e| {
ToolInvocationError::Execution(format!("failed to load MCP tool permissions: {e}"))
})?;
doc.effective_policy(
agent_id,
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
&known_tools,
)
.map_err(|e| ToolInvocationError::Execution(format!("invalid MCP tool permissions: {e}")))?
} else if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() {
McpToolPolicy::read_only(
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
&known_tools,
)
.map_err(|e| {
ToolInvocationError::Execution(format!("invalid read-only MCP tool fallback: {e}"))
})?
} else {
return Ok(None);
};
Ok(Some(policy))
}
fn requester_agent_id(requester: &str) -> Option<AgentId> {
uuid::Uuid::parse_str(requester)
.ok()
.map(AgentId::from_uuid)
infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface(
Some(store),
project,
requester,
ephemeral_policy,
)
.await
.map(|surface| surface.durable_policy)
.map_err(|e| {
ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}"))
})
}
fn enforce_policy(
@ -643,6 +675,46 @@ mod tests {
let _ = std::fs::remove_dir_all(temp);
}
#[tokio::test]
async fn openai_tools_list_matches_durable_agent_policy() {
let temp = std::env::temp_dir().join(format!(
"idea-app-tauri-openai-mcp-tools-list-{}",
Uuid::new_v4()
));
let core = backend::BackendCore::build(temp.clone());
let agent = AgentId::from_uuid(Uuid::from_u128(84));
let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
Arc::new(FakePolicies::default()),
mcp_permissions(allow_doc(
agent,
&["idea_memory_read", "idea_skill_read", "idea_create_skill"],
)),
Arc::new(FakeTicketTools::default()),
Arc::new(FakeTemplateTools::default()),
);
let names = invoker
.tools_for_context("/tmp/project", &agent.to_string())
.await
.expect("tools list resolves")
.into_iter()
.map(|tool| tool.name)
.collect::<Vec<_>>();
assert_eq!(
names,
vec![
"idea_memory_read".to_owned(),
"idea_skill_read".to_owned(),
"idea_create_skill".to_owned(),
]
);
let _ = std::fs::remove_dir_all(temp);
}
#[tokio::test]
async fn openai_durable_agent_override_allows_explicit_write_tool() {
let temp = std::env::temp_dir().join(format!(

View File

@ -4967,59 +4967,14 @@ mod mcp_serve_peer_tests {
assert_eq!(resp["id"], json!(1));
let tools = resp["result"]["tools"].as_array().expect("tools array");
let names: Vec<&str> = tools.iter().map(|t| t["name"].as_str().unwrap()).collect();
for expected in [
"idea_list_agents",
"idea_ask_agent",
"idea_ask_agents",
"idea_run_in_background",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_create_skill",
// FileGuard-mediated context/memory tools (cadrage C7).
"idea_context_read",
"idea_context_propose",
"idea_memory_read",
"idea_memory_write",
// Skill-awareness : lecture à la demande du corps d'un skill.
"idea_skill_read",
// Conversation inter-agent headless : réponse inline capturée depuis le Final.
"idea_ask_agent",
// Live-state (programme live-state, lot LS4).
"idea_workstate_read",
"idea_workstate_set",
// Public ticket tools (Issue domain).
"idea_ticket_create",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_bulk_update_status",
"idea_ticket_bulk_update_priority",
"idea_ticket_bulk_delete",
"idea_ticket_read_carnet",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_sprint_list",
// Public template tools.
"idea_template_list",
"idea_template_read",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
] {
assert!(
names.contains(&expected),
"missing tool {expected}; got {names:?}"
);
}
let expected_names: Vec<&str> = infrastructure::orchestrator::mcp::catalogue()
.into_iter()
.map(|tool| tool.name)
.collect();
assert!(!names.contains(&"idea_reply"));
assert_eq!(
tools.len(),
34,
"exactly the thirty-four exposed idea_* tools; got {names:?}"
names, expected_names,
"tools/list must expose exactly the canonical MCP catalogue"
);
drop(client); // EOF ⇒ serve loop ends

View File

@ -13,7 +13,7 @@ use domain::ports::{
AgentToolPolicyStore, McpToolPermissionStore, ProjectStore, ToolInvocationError, ToolInvoker,
ToolSpec,
};
use domain::{AgentId, AgentToolPolicy, IssueRef, McpToolPolicy, Project};
use domain::{AgentToolPolicy, IssueRef, McpToolPolicy, Project};
use infrastructure::{TemplateToolProvider, TicketToolProvider};
use serde_json::Value;
@ -62,6 +62,22 @@ impl ToolInvoker for LateBoundOpenAiToolInvoker {
.map_or_else(Vec::new, |inner| inner.tools())
}
async fn tools_for_context(
&self,
project_root: &str,
requester: &str,
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
let inner = self
.inner
.lock()
.expect("mutex sain")
.clone()
.ok_or_else(|| {
ToolInvocationError::Execution("ToolInvoker OpenAI non initialisé".to_owned())
})?;
inner.tools_for_context(project_root, requester).await
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let inner = self
.inner
@ -102,14 +118,38 @@ impl ToolInvoker for AppOpenAiToolInvoker {
fn tools(&self) -> Vec<ToolSpec> {
infrastructure::orchestrator::mcp::catalogue()
.into_iter()
.map(|tool| ToolSpec {
name: tool.name.to_owned(),
description: tool.description.to_owned(),
input_schema: tool.input_schema,
})
.map(tool_def_to_spec)
.collect()
}
async fn tools_for_context(
&self,
project_root: &str,
requester: &str,
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
let ephemeral_policy = self.policies.get_policy(requester);
let project = self.find_project(project_root).await?;
let surface = infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface(
Some(self.mcp_tool_permissions.as_ref()),
&project,
requester,
ephemeral_policy.as_ref(),
)
.await
.map_err(|e| {
ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}"))
})?;
Ok(
infrastructure::orchestrator::mcp::policy::effective_tool_catalogue(
ephemeral_policy.as_ref(),
&surface,
)
.into_iter()
.map(tool_def_to_spec)
.collect(),
)
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let value: Value = serde_json::from_str(args_json)
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
@ -140,18 +180,7 @@ impl ToolInvoker for AppOpenAiToolInvoker {
if let Some(policy) = &ephemeral_policy {
enforce_policy(policy, &requester, name, &value)?;
}
let project = self
.projects
.list_projects()
.await
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
.into_iter()
.find(|project| project.root.as_str() == project_root)
.ok_or_else(|| {
ToolInvocationError::Execution(format!(
"projet introuvable pour root `{project_root}`"
))
})?;
let project = self.find_project(&project_root).await?;
enforce_durable_tool_policy(
self.mcp_tool_permissions.as_ref(),
&project,
@ -209,6 +238,30 @@ impl ToolInvoker for AppOpenAiToolInvoker {
}
}
impl AppOpenAiToolInvoker {
async fn find_project(&self, project_root: &str) -> Result<Project, ToolInvocationError> {
self.projects
.list_projects()
.await
.map_err(|e| ToolInvocationError::Execution(e.to_string()))?
.into_iter()
.find(|project| project.root.as_str() == project_root)
.ok_or_else(|| {
ToolInvocationError::Execution(format!(
"projet introuvable pour root `{project_root}`"
))
})
}
}
fn tool_def_to_spec(tool: infrastructure::orchestrator::mcp::ToolDef) -> ToolSpec {
ToolSpec {
name: tool.name.to_owned(),
description: tool.description.to_owned(),
input_schema: tool.input_schema,
}
}
async fn enforce_durable_tool_policy(
store: &dyn McpToolPermissionStore,
project: &Project,
@ -239,38 +292,17 @@ async fn durable_tool_policy(
ephemeral_policy: Option<&AgentToolPolicy>,
requester: &str,
) -> Result<Option<McpToolPolicy>, ToolInvocationError> {
let known_tools = infrastructure::orchestrator::mcp::tools::classified_tool_names();
let policy = if let Some(agent_id) = requester_agent_id(requester) {
let doc = store
.load_mcp_tool_permissions(project)
.await
.map_err(|e| {
ToolInvocationError::Execution(format!("failed to load MCP tool permissions: {e}"))
})?;
doc.effective_policy(
agent_id,
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
&known_tools,
)
.map_err(|e| ToolInvocationError::Execution(format!("invalid MCP tool permissions: {e}")))?
} else if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() {
McpToolPolicy::read_only(
infrastructure::orchestrator::mcp::tools::READ_ONLY_TOOLS,
&known_tools,
)
.map_err(|e| {
ToolInvocationError::Execution(format!("invalid read-only MCP tool fallback: {e}"))
})?
} else {
return Ok(None);
};
Ok(Some(policy))
}
fn requester_agent_id(requester: &str) -> Option<AgentId> {
uuid::Uuid::parse_str(requester)
.ok()
.map(AgentId::from_uuid)
infrastructure::orchestrator::mcp::policy::resolve_effective_tool_surface(
Some(store),
project,
requester,
ephemeral_policy,
)
.await
.map(|surface| surface.durable_policy)
.map_err(|e| {
ToolInvocationError::Execution(format!("failed to resolve MCP tool permissions: {e}"))
})
}
#[cfg(test)]
@ -796,6 +828,42 @@ mod tests {
let _ = std::fs::remove_dir_all(temp);
}
#[tokio::test]
async fn openai_tools_list_matches_durable_agent_policy() {
let temp =
std::env::temp_dir().join(format!("idea-openai-mcp-tools-list-{}", Uuid::new_v4()));
let core = crate::BackendCore::build(temp.clone());
let agent = AgentId::from_uuid(Uuid::from_u128(182));
let invoker = AppOpenAiToolInvoker::new(
Arc::clone(&core.orchestrator_service),
Arc::new(FakeProjects::with(project())) as Arc<dyn ProjectStore>,
Arc::new(FakePolicies::default()),
mcp_permissions(allow_doc(
agent,
&["idea_ticket_list", "idea_ticket_update_carnet"],
)),
Arc::new(FakeTicketTools::default()),
Arc::new(FakeTemplateTools::default()),
);
let tools = invoker
.tools_for_context("/tmp/project", &agent.to_string())
.await
.expect("tools list should resolve durable policy");
let names = tools
.iter()
.map(|tool| tool.name.as_str())
.collect::<Vec<_>>();
assert!(names.contains(&"idea_ticket_list"));
assert!(names.contains(&"idea_ticket_update_carnet"));
assert!(!names.contains(&"idea_ask_agent"));
assert!(!names.contains(&"idea_ticket_update"));
assert!(!names.contains(&"idea_run_in_background"));
let _ = std::fs::remove_dir_all(temp);
}
#[tokio::test]
async fn openai_durable_agent_override_allows_explicit_write_tool() {
let temp =

View File

@ -660,6 +660,29 @@ pub trait ToolInvoker: Send + Sync {
/// Liste des outils exposés au modèle.
fn tools(&self) -> Vec<ToolSpec>;
/// Liste des outils exposés dans le contexte déjà lié par l'adapter appelant.
///
/// Les invokers simples peuvent conserver le comportement historique via
/// [`Self::tools`]. Les wrappers qui portent déjà l'identité requester et le
/// projet peuvent surcharger cette méthode pour refléter les permissions
/// effectives au moment où les outils sont exposés au modèle.
async fn tools_for_bound_context(&self) -> Result<Vec<ToolSpec>, ToolInvocationError> {
Ok(self.tools())
}
/// Liste des outils exposés pour un projet et un requester explicites.
///
/// Sert aux adapters qui savent résoudre la policy durable à partir du root
/// projet et de l'identité agent avant d'injecter les tools dans une requête
/// OpenAI-compatible.
async fn tools_for_context(
&self,
_project_root: &str,
_requester: &str,
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
Ok(self.tools())
}
/// Appelle un outil avec ses arguments JSON bruts.
///
/// # Errors

View File

@ -1,10 +1,12 @@
//! In-memory MCP tool policy registry.
//! MCP tool policy helpers and in-memory live policy registry.
use std::collections::HashMap;
use std::sync::RwLock;
use domain::AgentToolPolicy;
use domain::AgentToolPolicyStore;
use domain::ports::McpToolPermissionStore;
use domain::{AgentId, AgentToolPolicy, AgentToolPolicyStore, McpToolPolicy, Project, StoreError};
use super::tools::{self, ToolDef};
/// Stores per-requester MCP tool policies for live assistant sessions.
#[derive(Default)]
@ -53,6 +55,106 @@ impl AgentToolPolicyStore for ToolPolicyRegistry {
}
}
/// Effective MCP tool surface resolved for one requester.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EffectiveToolSurface {
/// Durable MCP policy when one applies.
pub durable_policy: Option<McpToolPolicy>,
/// Whether the read-only fallback was used because the requester could not be
/// resolved to an agent policy.
pub used_read_only_fallback: bool,
}
impl EffectiveToolSurface {
/// Returns whether `tool` is allowed by the durable policy, when present.
#[must_use]
pub fn permits_durable(&self, tool: &str) -> bool {
self.durable_policy
.as_ref()
.map_or(true, |policy| policy.permits(tool))
}
}
/// Resolves the durable MCP tool policy for the given requester.
///
/// This is the shared source of truth for the surfaces that expose IdeA tools to
/// agents. Agent UUID requesters use `.ideai/mcp-tool-permissions.json`;
/// anonymous/legacy requesters and non-agent requesters without an ephemeral
/// policy fall back to the canonical read-only policy. Non-agent requesters with
/// an ephemeral policy (ticket assistants) are intentionally governed only by
/// that narrower session policy.
///
/// # Errors
/// [`StoreError`] on store load failure or invalid persisted policy.
pub async fn resolve_effective_tool_surface(
store: Option<&dyn McpToolPermissionStore>,
project: &Project,
requester: &str,
ephemeral_policy: Option<&AgentToolPolicy>,
) -> Result<EffectiveToolSurface, StoreError> {
let Some(store) = store else {
return Ok(EffectiveToolSurface {
durable_policy: None,
used_read_only_fallback: false,
});
};
let known_tools = tools::classified_tool_names();
if let Some(agent_id) = requester_agent_id(requester) {
let doc = store.load_mcp_tool_permissions(project).await?;
let policy = doc
.effective_policy(agent_id, tools::READ_ONLY_TOOLS, &known_tools)
.map_err(|err| StoreError::Invalid(err.to_string()))?;
return Ok(EffectiveToolSurface {
durable_policy: Some(policy),
used_read_only_fallback: false,
});
}
if requester.is_empty() || requester == "mcp" || ephemeral_policy.is_none() {
application::diag!(
"[mcp] unresolved requester `{}` uses read-only tool fallback",
if requester.is_empty() {
"mcp"
} else {
requester
},
);
let policy = McpToolPolicy::read_only(tools::READ_ONLY_TOOLS, &known_tools)
.map_err(|err| StoreError::Invalid(err.to_string()))?;
return Ok(EffectiveToolSurface {
durable_policy: Some(policy),
used_read_only_fallback: true,
});
}
Ok(EffectiveToolSurface {
durable_policy: None,
used_read_only_fallback: false,
})
}
/// Filters the canonical catalogue by the effective ephemeral + durable policies.
#[must_use]
pub fn effective_tool_catalogue(
ephemeral_policy: Option<&AgentToolPolicy>,
surface: &EffectiveToolSurface,
) -> Vec<ToolDef> {
tools::catalogue()
.into_iter()
.filter(|tool| {
ephemeral_policy.map_or(true, |policy| policy.permits(tool.name))
&& surface.permits_durable(tool.name)
})
.collect()
}
fn requester_agent_id(requester: &str) -> Option<AgentId> {
uuid::Uuid::parse_str(requester)
.ok()
.map(AgentId::from_uuid)
}
#[cfg(test)]
mod tests {
use domain::IssueRef;

View File

@ -347,25 +347,20 @@ impl McpServer {
/// The `tools/list` result: the catalogue as MCP tool descriptors.
async fn tools_list_result(&self) -> Result<Value, JsonRpcError> {
let ephemeral_policy = self.ephemeral_tool_policy();
let durable_policy = self.durable_tool_policy().await?;
let tools: Vec<Value> = tools::catalogue()
.into_iter()
.filter(|t| {
ephemeral_policy
.as_ref()
.map_or(true, |policy| policy.permits(t.name))
&& durable_policy
.as_ref()
.map_or(true, |policy| policy.permits(t.name))
})
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
let surface = self
.effective_tool_surface(ephemeral_policy.as_ref())
.await?;
let tools: Vec<Value> =
super::policy::effective_tool_catalogue(ephemeral_policy.as_ref(), &surface)
.into_iter()
.map(|t| {
json!({
"name": t.name,
"description": t.description,
"inputSchema": t.input_schema,
})
})
})
.collect();
.collect();
Ok(json!({ "tools": tools }))
}
@ -376,53 +371,30 @@ impl McpServer {
}
async fn durable_tool_policy(&self) -> Result<Option<McpToolPolicy>, JsonRpcError> {
let Some(store) = &self.mcp_tool_permissions else {
return Ok(None);
};
let known_tools = tools::classified_tool_names();
let policy = if let Some(agent_id) = self.requester_agent_id() {
let doc = store
.load_mcp_tool_permissions(&self.project)
.await
.map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("failed to load MCP tool permissions: {e}"),
)
})?;
doc.effective_policy(agent_id, tools::READ_ONLY_TOOLS, &known_tools)
.map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("invalid MCP tool permissions: {e}"),
)
})?
} else if self.requester.is_empty()
|| self.requester == "mcp"
|| self.ephemeral_tool_policy().is_none()
{
// Anonymous/legacy peers cannot be mapped to an agent override. Fail
// closed to the canonical read-only policy. Non-agent requesters with an
// ephemeral policy (ticket assistants) are governed by that narrower,
// session-scoped policy instead of the durable per-agent store.
McpToolPolicy::read_only(tools::READ_ONLY_TOOLS, &known_tools).map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("invalid read-only MCP tool fallback: {e}"),
)
})?
} else {
return Ok(None);
};
Ok(Some(policy))
let ephemeral_policy = self.ephemeral_tool_policy();
Ok(self
.effective_tool_surface(ephemeral_policy.as_ref())
.await?
.durable_policy)
}
fn requester_agent_id(&self) -> Option<AgentId> {
uuid::Uuid::parse_str(&self.requester)
.ok()
.map(AgentId::from_uuid)
async fn effective_tool_surface(
&self,
ephemeral_policy: Option<&AgentToolPolicy>,
) -> Result<super::policy::EffectiveToolSurface, JsonRpcError> {
super::policy::resolve_effective_tool_surface(
self.mcp_tool_permissions.as_deref(),
&self.project,
&self.requester,
ephemeral_policy,
)
.await
.map_err(|e| {
JsonRpcError::new(
error_codes::INTERNAL_ERROR,
format!("failed to resolve MCP tool permissions: {e}"),
)
})
}
fn enforce_durable_tool_policy(

View File

@ -41,6 +41,12 @@ impl ToolInvoker for ProjectScopedToolInvoker {
self.inner.tools()
}
async fn tools_for_bound_context(&self) -> Result<Vec<ToolSpec>, ToolInvocationError> {
self.inner
.tools_for_context(&self.project_root, &self.requester)
.await
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
let mut value: Value = serde_json::from_str(args_json)
.map_err(|e| ToolInvocationError::InvalidArguments(format!("JSON invalide: {e}")))?;
@ -257,6 +263,7 @@ mod tests {
#[derive(Default)]
struct RecordingToolInvoker {
call: Mutex<Option<(String, String)>>,
tools_context: Mutex<Option<(String, String)>>,
}
#[async_trait]
@ -265,6 +272,20 @@ mod tests {
Vec::new()
}
async fn tools_for_context(
&self,
project_root: &str,
requester: &str,
) -> Result<Vec<ToolSpec>, ToolInvocationError> {
*self.tools_context.lock().unwrap() =
Some((project_root.to_owned(), requester.to_owned()));
Ok(vec![ToolSpec {
name: "idea_memory_read".to_owned(),
description: "Read memory".to_owned(),
input_schema: json!({"type":"object"}),
}])
}
async fn call(&self, name: &str, args_json: &str) -> Result<String, ToolInvocationError> {
*self.call.lock().unwrap() = Some((name.to_owned(), args_json.to_owned()));
Ok("ok".to_owned())
@ -299,6 +320,28 @@ mod tests {
);
}
#[tokio::test]
async fn project_scoped_tool_invoker_filters_tools_with_bound_context() {
let recorder = Arc::new(RecordingToolInvoker::default());
let invoker = ProjectScopedToolInvoker {
inner: recorder.clone(),
project_root: "/project/root".to_owned(),
requester: "agent-1".to_owned(),
};
let tools = invoker
.tools_for_bound_context()
.await
.expect("tools resolve");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].name, "idea_memory_read");
assert_eq!(
recorder.tools_context.lock().unwrap().clone(),
Some(("/project/root".to_owned(), "agent-1".to_owned()))
);
}
#[test]
fn fallback_requester_uses_cwd_file_name_only_when_explicit_identity_absent() {
assert_eq!(fallback_requester_from_cwd("/tmp/run/7"), "7");

View File

@ -281,7 +281,7 @@ impl OpenAiCompatibleSession {
send_tap(&tap, &ReplyEvent::Heartbeat);
for iteration in 0..=self.config.effective_max_tool_iterations() {
let transcript = self.transcript.lock().expect("mutex sain").clone();
let tools = self.effective_tools();
let tools = self.effective_tools().await?;
let response = self.post_chat(&transcript, &tools, true).await;
let response = match response {
Ok(response) => response,
@ -330,13 +330,17 @@ impl OpenAiCompatibleSession {
unreachable!("loop returns at max_tool_iterations");
}
fn effective_tools(&self) -> Vec<ToolSpec> {
async fn effective_tools(&self) -> Result<Vec<ToolSpec>, AgentSessionError> {
if *self.tools_disabled.lock().expect("mutex sain") {
return Vec::new();
return Ok(Vec::new());
}
match &self.tool_invoker {
Some(invoker) => invoker
.tools_for_bound_context()
.await
.map_err(|e| AgentSessionError::Start(format!("résolution tools: {e}"))),
None => Ok(Vec::new()),
}
self.tool_invoker
.as_ref()
.map_or_else(Vec::new, |invoker| invoker.tools())
}
async fn post_chat(