83 KiB
83 KiB
upTo, objective
| upTo | objective |
|---|---|
| b41c78ed-f731-4552-9917-a95e63e16847 | CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX. |
Objectif : CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX. ## Contexte / gap déjà localisé Le round-trip inter-agent fonctionne pour
- Prompt: CADRAGE ARCHITECTURE (pas de code) — Support de la délégation inter-agents (idea_ask_agent/idea_reply) pour les profils CODEX. ## Contexte / gap déjà localisé Le round-trip inter-agent fonctionne pour Claude (pont stdio↔loopback
idea mcp-server+ serveur MCP par projet). Pour Codex, il est VOLONTAIREMENT bloqué : -crates/application/src/orchestrator/service.rs→guard_mcp_bridge_supported(l.1313) exige/.codex) vs projet, et l'impact sur l'isolation par projet (l'endpoint loopback est par-projet : comment garantir que l'agent Codex d'un projet pointe le bon endpoint sans collision entre projets ouverts ?). 2. Stratégie de config : faut-il une nouvelle variante deStructuredAdapter::ClaudeET une capacité MCPConfigFile(".mcp.json"). Raison documentée (l.1300-1309) : IdeA matérialise le serveur MCP en.mcp.json(lu par Claude), mais Codex lit~/.codex/config.toml(table TOML[mcp_servers.<nom>]), donc le pont n'est jamais branché ⇒ la cible Codex ne peut pas appeler idea_reply ⇒ on coupe court avec une erreur typée. - Modèle de profil :crates/domain/src/profile.rs→McpConfigStrategy(enum),McpCapability,StructuredAdapter::{Claude,Codex}. - Le bridge lui-même (crates/app-tauri/src/mcp_bridge.rs) et le serveur (infrastructure/.../mcp/server.rs) sont déjà profil-agnostiques (stdio↔loopback + handshakerequester). LeMcpRuntimeProvider(app-tauri) fournit exe+endpoint+requester. - La matérialisation.mcp.jsoncôté Claude se fait danscrates/app-tauri/src/state.rs(cf. testsrun_dir_migration_tests::merge_idea_mcp_json_*et la réconciliation du run dir). Repère le code exact qui écrit.mcp.jsonet leMcpConfigStrategyconsommé. ## Ce que je veux de toi (livrable = note d'archi concise, hexagonale/SOLID) 1. Format Codex : confirme où et comment Codex lit ses serveurs MCP (typiquement~/.codex/config.toml, table[mcp_servers.idea]aveccommand/args). Précise si c'est global (McpConfigStrategy(p.ex.TomlTable { path, table }ouCodexConfig) ou une généralisation de l'existante ? Donne le contrat exact (domaine) et la frontière : qui écrit le fichier (adapter infra/app-tauri), quel port. 3. Garde F2 : critère assoupli pour accepter Codex (sans réintroduire le risque de timeout muet). Quel prédicat exact remplaceis_claude? (p.ex. « le profil porte une McpCapability dont la stratégie est effectivement matérialisée par IdeA pour cet adaptateur »). 4. Découpage en lots dev/test : liste ordonnée de tâches autonomes (domaine → application → infra/app-tauri → tests), chacune avec ses fichiers cibles et son critère de validation, prête à être confiée aux agents de dev puis QA. Inclure : extension du test fonctionnelstate::mcp_e2e_loopback_testspour qu'il s'exécute À LA FOIS avec un profil Claude ET un profil Codex (paramétrage du profil/handshake ; round-trip identique en dessous, donc la différence testée = garde + matérialisation de config). 5. Risques/pièges : notamment le nettoyage (Codex config global persistant entre projets), idempotence d'écriture, et le fait qu'on ne lance PAS de vrai CLI dans les tests (fakes). Réponds via idea_reply avec la note d'archi structurée (sections 1→5). Sois concret sur les fichiers et contrats ; ne produis pas de code d'implémentation, juste les signatures/contrats nécessaires. - Response: # Note d'archi — Pont inter-agents
idea_*pour profils Codex Cadrage hexagonal/SOLID, sans code d'implémentation. Tout est ancré sur le code réel lu. --- ## 1. Format Codex & isolation par projet Où Codex lit ses serveurs MCP. Codex CLI (Rust) lit sonconfig.tomlsous$CODEX_HOME(défaut~/.codex/). Les serveurs y sont déclarés en table TOML[mcp_servers.<nom>]aveccommand/args/env. C'est global à la machine, pas par projet — contrairement à Claude qui lit.mcp.jsondepuis son cwd (le run dir isolé{agentRunDir}, cf.cwd_templatedansdomain/src/profile.rs). Le problème d'isolation (central). L'endpoint loopback est par-projet :mcp_endpoint(&project.id)(app-taurimcp_endpoint.rs), injecté dansMcpRuntime { exe, endpoint, project_id, requester }à chaquelaunch_agent(commands.rs:1041). Si on écrivait dans le~/.codex/config.tomlglobal, deux projets ouverts simultanément écraseraient mutuellement l'unique[mcp_servers.idea](endpoints/project_id différents) → collision + course à l'écriture + résidu persistant entre projets. Inacceptable. Solution retenue : relocaliserCODEX_HOMEpar agent. À chaque lancement, IdeA : 1. écrit unconfig.tomldans le run dir isolé de l'agent (ex.{runDir}/.codex/config.toml), 2. pousse l'envCODEX_HOME={runDir}/.codexdans leSpawnSpec. Le run dir est déjà unique par agent ({agentRunDir}=.ideai/run/<agent-id>/) → isolation native, zéro collision cross-projet, nettoyage trivial (le run dir est éphémère/régénéré, déjà clobber à chaque relaunch comme.mcp.json— cf.apply_mcp_configlifecycle.rs:1730 « regenerated and clobbered on every (re)launch »). On ne touche jamais au~/.codexglobal. C'est le même invariant qui rend Claude sûr (config dans le cwd isolé), transposé viaCODEX_HOME. > Alternative écartée : injecter via flags-c mcp_servers.idea.command=…. Rejetée : la sérialisation TOML inline (tables/arrays) sur la ligne de commande est fragile, multiplie les args, et contourne le seam de matérialisation-fichier déjà éprouvé (write atomique, clobber/non-clobber). Le fichier +CODEX_HOMEréutilise le seam existant à l'identique. --- ## 2. Stratégie de config (contrat domaine) Décision : nouvelle variante deMcpConfigStrategy(domain/src/profile.rs:179), pas de généralisation des existantes. Justification : le code est explicitement Open/Closed sur ces enums (« ajouter un moteur = une variante »), et le couple Codex porte deux mécanismes inséparables (écrire un fichier TOML + relocaliser le home via env) qu'aucune variante actuelle ne capture seule (ConfigFile= fichier lu depuis cwd, sans env ;Env= passe juste un chemin, sans format ni fichier).rust // domain/src/profile.rs — ajout à l'enum McpConfigStrategy (tag = "strategy") /// Écrire la config MCP en TOML (table [mcp_servers.idea]) dans le run dir, /// ET relocaliser le "config home" de la CLI via une variable d'env pointée /// sur le dossier contenant ce fichier (ex. Codex : CODEX_HOME). /// Garantit l'isolation par-agent d'une CLI qui lit une config GLOBALE. TomlConfigHome { /// Chemin relatif sûr du config.toml (ex. ".codex/config.toml"). target: String, /// Variable d'env pointée sur le DOSSIER parent de `target` /// (ex. "CODEX_HOME"). L'adaptateur dérive le dossier de `target`. home_env: String, }Constructeur validé (parse-don't-validate, comme les autres) :rust pub fn toml_config_home(target, home_env) -> Result<Self, DomainError> // target → validation::relative_safe (pas de "..", pas d'absolu) // home_env → validation::valid_env_varFrontière (qui écrit quoi) — inchangée par rapport à Claude : - Port : aucun nouveau port. La matérialisation passe par leFileSystem(port domaine existant) +SpawnSpec.env(déjà le véhicule deMcpConfigStrategy::Env). - Application :LaunchAgent::apply_mcp_config(application/src/agent/lifecycle.rs:1708) gagne un brasTomlConfigHome: écrit le TOML viaself.fs.write(mêmes sémantiques clobber/non-clobber selonruntime.is_some()) et pousse(home_env, parent_dir(target))dansspec.env. - app-tauri : la voie de réconciliation/migration au project-open (reconcile_claude_run_dirs/migrate_claude_run_dir,state.rs:1069/1128) gagne son pendant Codex (réécrit leconfig.tomldu run dir pour rafraîchir exe$APPIMAGE/endpoint qui dérivent entre runs). Source de format unique. Aujourd'hui le JSON est produit en deux endroits :mcp_server_declaration(application, lifecycle.rs:1857) etmcp_server_entry(app-tauri, state.rs:1344). Il faut un sibling TOML symétrique dans chacun (mcp_server_declaration_toml/mcp_server_entry_toml), produisant la même donnée logique (command,args=["mcp-server","--endpoint",…,"--project",…,"--requester",…],transport) en table[mcp_servers.idea]. Recommandation SOLID : factoriser la donnée (structMcpServerWiring { command, args, transport }) et n'avoir que deux encodeurs (json/toml) — évite la dérive entre les 4 sites. --- ## 3. Garde F2 (critère assoupli, sans timeout muet) Prédicat actuel (orchestrator/service.rs:1313) :honours_mcp_json && is_claude— exclut Codex en dur. Nouveau prédicat = « le couple (adaptateur, stratégie) est effectivement matérialisé par IdeA pour cette CLI ». Le risque à éviter (timeout 300 s muet) vient justement d'un profil qui déclare une stratégie qu'IdeA n'écrit pas réellement dans le format que la CLI lit. Donc le critère doit refléter exactement l'ensemble que le code de matérialisation (§2) sait câbler :rust // Couples réellement honorés (whitelist) : (Claude, ConfigFile { target: ".mcp.json" }) // existant (Codex, TomlConfigHome { .. }) // nouveau // tout autre couple ⇒ AppError::Invalid immédiate (comme aujourd'hui)Anti-dérive (clé SOLID). Ne pas dupliquer cette whitelist dans la garde ET dansapply_mcp_config. Exposer une seule fonction domaine, source de vérité partagée :rust // domain/src/profile.rs impl AgentProfile { /// true ssi IdeA matérialise effectivement le pont idea_* pour ce profil /// (couple adaptateur structuré × stratégie MCP réellement écrit dans le /// format que la CLI lit). Consommé par la garde F2 ET par la matérialisation. pub fn materializes_idea_bridge(&self) -> bool { /* match sur la whitelist */ } }La garde devient :if profile.materializes_idea_bridge() { Ok(()) } else { Err(Invalid(...)) }. Profil introuvable ⇒Ok(())(inchangé : la garde ne fait que typer un échec connu). Le message d'erreur est généralisé (ne plus dire « cible un agent Claude »). --- ## 4. Découpage en lots dev/test (ordonné par dépendance) | Lot | Couche | Contenu | Fichiers cibles | Critère de validation | |---|---|---|---|---| | D1 | domaine | VarianteTomlConfigHome { target, home_env }+ constructeur validé +AgentProfile::materializes_idea_bridge()(whitelist Claude/.mcp.json + Codex/TomlConfigHome). |domain/src/profile.rs|cargo test -p domain: (de)sérialisation tag="strategy", rejet../absolu sur target, rejet env var invalide,materializes_idea_bridgevrai/faux par couple. | | D2 | domaine | Encodeur TOML partagé : structMcpServerWiring+ sérialisation table[mcp_servers.idea](sibling pur, testable sans I/O). |domain/src/profile.rs(ou module dédié) |cargo test -p domain: args ordonnés, échappement TOML d'un chemin avec espaces/\, transport rendu. | | A1 | application | BrasTomlConfigHomedansapply_mcp_config: write TOML (clobber si runtime, non-clobber sinon) + push(home_env, parent(target))dansspec.env. +mcp_server_declaration_toml. |application/src/agent/lifecycle.rs|cargo test -p application: fakeFileSystemreçoit le write au bon chemin avec contenu TOML ;spec.envcontientCODEX_HOME=parent ; sémantique clobber/non-clobber selon runtime (miroir des testsapply_mcp_configexistants). | | A2 | application | Garde F2 ré-exprimée viamaterializes_idea_bridge()+ message générique. |orchestrator/service.rs:1300-1346|cargo test -p application: profil Codex+TomlConfigHome ⇒Ok; profil Codex sans mcp / Codex+ConfigFile ⇒Invalid; Claude+.mcp.json ⇒Ok(non-régression) ; profil inconnu ⇒Ok. | | I1 | app-tauri | Sibling Codex de la réconciliation :is_codex_mcp_profile,migrate_codex_run_dir,mcp_server_entry_toml; brancher dansreconcile_claude_run_dirs/migrate_claude_run_dirs(les renommerreconcile_mcp_run_dirsou dispatcher par profil). |crates/app-tauri/src/state.rs(~1069-1420) |cargo test -p app-tauri: test typereconcile_*_repairs_legacy_files_on_diskmais pour Codex (réécrit.codex/config.toml, rafraîchit exe/endpoint, idempotent au 2e passage). | | T1 | test fonctionnel | Paramétrermcp_e2e_loopback_testsClaude ⊕ Codex. Le round-trip sous le pont est identique (loopback réel + fakes) ; la seule différence testée = garde + matérialisation de config. Factoriser le corps des 4 tests (*_over_real_loopback) sur un paramètreprofile_kind, instancier unAgentProfileCodex (structured=Codex, mcp=TomlConfigHome, transport stdio) à côté du Claude existant. Le handshake (requester/project) etidea_ask_agent/idea_replyrestent inchangés. |crates/app-tauri/src/state.rs:2848+| Les 4 round-trips (list_agents,ask_then_reply,orphan_reply,handshake_requester) verts pour les deux profils ; ajouter un cas qui assert que la garde accepte une cible Codex (là où elle refusait avant). | Ordre d'exécution : D1→D2→A1→A2→I1→T1. Chaque lot confiable à un binôme dev+QA, vert avant le suivant. --- ## 5. Risques / pièges 1. Config Codex globale persistante → résolu par design viaCODEX_HOME=run dir : rien n'est jamais écrit dans~/.codex. Vérifier en test qu'apply_mcp_confign'émet aucun chemin absolu ni HOME, seulement{runDir}/.... 2. Idempotence d'écriture : conserver la dissymétrie déjà en place —runtime=Some(lancement app-tauri réel) ⇒ clobber (exe$APPIMAGE/endpoint dérivent à chaque run, cf. lifecycle.rs:1730) ;runtime=None(orchestrateur/hot-swap/tests) ⇒ non-clobber (ne pas écraser une vraie déclaration par la minimale). Le TOML doit suivre la même règle. 3. On ne lance PAS de vrai CLI en test : aucun test n'exécutecodex. Le T1 prouve la garde + le contrat de matérialisation (fichier écrit, env posée) et le round-trip MCP via loopback réel + fakes (FakeSession,FakeContexts) ; jamais un binaire Codex. Le format TOML est validé par parsing en test (toml crate), pas par exécution. 4. Échappement TOML : un exe$APPIMAGEou endpoint avec espaces/\doit rester un TOML valide (équivalent dujson_stringactuel). Tester explicitement (cf.mcp_declaration_*existants). 5. Deux écrivains de format (applicationmcp_server_declaration+ app-taurimcp_server_entry) : risque de dérive JSON↔TOML. Mitiger par la structMcpServerWiringpartagée (D2) consommée des deux côtés. 6.parent(target)vide : si un profil déclaretarget="config.toml"(sans sous-dossier),home_envpointerait le run dir lui-même — acceptable, mais préférer conventionnellement.codex/config.tomlpour ne pas polluer le cwd que Codex pourrait scanner. À documenter dans le profil seed. 7. Garde anti-timeout : ne jamais élargir la whitelistmaterializes_idea_bridge()sans que le bras de matérialisation correspondant existe — sinon réintroduction exacte du timeout muet que F2 protège. Les deux évoluent ensemble, dans le même lot. --- Aucun nouveau port/adapter : on réutiliseFileSystem,SpawnSpec.env,McpRuntime, le bridge stdio↔loopback et le serveur MCP (déjà profil-agnostiques). L'ajout est déclaratif (1 variante d'enum) + 2 encodeurs + garde unifiée — strictement Open/Closed. - Prompt: Nouveau chantier : gestion des permissions des agents dans IdeA. Je te demande le cadrage architecture complet (hexagonal/SOLID), AVANT tout code, conformément à la méthode §3 et §11. ## Besoin produit (mots de l'utilisateur) Gérer les permissions des agents à deux échelles : - Échelle projet : permissions par défaut héritées par tous les agents (ex. « les agents ont tous les droits »). - Échelle agent : override ciblé qui contredit le défaut projet (ex. « cet agent n'a PAS le droit d'utiliser bash »). Donc : défaut projet, surchargé point par point par l'agent. Doit marcher sur Claude et Codex dès maintenant, extensible à d'autres moteurs plus tard (Open/Closed, déclaratif). ## Ce que je veux de toi (livrable de cadrage) 1. Modèle de domaine des permissions : quelle granularité (capacités/catégories abstraites model-agnostiques, ex. exécution shell, écriture fichiers, réseau…), comment représenter « tous les droits » vs deny ciblé, et la sémantique de résolution projet→agent (override = comment ? tri-state allow/deny/inherit ?). 2. Frontières hexagonales : où vit le modèle (domaine), où vit la résolution (application), quels ports (traits) introduire, et comment ça se branche sur l'existant
AgentProfile/lancement d'agent (crates/domain/src/profile.rs est purement déclaratif et additif Open/Closed — je veux la même philosophie). 3. Mapping runtime : comment une permission abstraite se traduit en flags CLI concrets pour Claude (ex.--allowedTools/--disallowedTools/--permission-mode) et Codex (sandbox/approval), via un mécanisme déclaratif/adapter pour rester extensible. Indique le point d'injection dans le lancement (lifecycle/catalogue). 4. Persistance : où stocker (échelle projet :.ideai/— quel fichier ? ; échelle agent :agents.jsonou fichier dédié ?), avec zéro régression de sérialisation (clés optionnelles, défauts), cohérent avec les patterns existants (providers.json, etc.). 5. Découpage en lots dev (DevBackend / DevFrontend) + critères de test pour QA, et la liste précise des fichiers/contrats à créer ou toucher. Réponds via idea_reply avec le plan de cadrage structuré (ports, contrats, fichiers, découpage en lots, critères de test). Ne code rien — c'est un cadrage. - Prompt: Test de la conversation inter-agents IdeA. Peux-tu confirmer que tu reçois bien ce message et répondre via idea_reply avec un court statut de santé (qui tu es, ton rôle, et confirmation que le pont fonctionne) ?
- Response: ✅ Message bien reçu — le pont inter-agents IdeA fonctionne. Qui je suis : codextest, un agent du projet IdeA. Rôle : agent de test/validation rattaché au project root IdeA. Santé du pont : opérationnel. J'ai reçu ta tâche (ticket 6ee3458a-a0c9-4adc-9d52-219023af626c) et je te réponds via
idea_replycomme prévu. Le cycle délégation → réception → réponse est donc validé de bout en bout. Prêt pour la suite quand tu veux. - Prompt: CADRAGE ARCHITECTURE — Système de permissions projet + agents (avant tout code). CONTEXTE PRODUIT (validé par l'utilisateur) : On veut gérer les permissions des agents IA à deux niveaux : 1. Niveau PROJET : permissions par défaut héritées par tous les agents du projet. 2. Niveau AGENT : surcharge fine, agent par agent. Les permissions couvrent (a) les opérations FICHIERS (lecture, écriture/modification, suppression…) et (b) le droit d'exécuter des COMMANDES BASH. Objectif final : quand une permission est définie dans IdeA, l'agent (qu'il soit Claude Code OU Codex) NE REDEMANDE PLUS la permission au run. DÉCISIONS PRODUIT DÉJÀ ARBITRÉES (à respecter, ne pas re-débattre) : - Modèle neutre = CAPABILITIES + SCOPES : capacités typées (read / write / delete / execute-bash), chacune scopable par chemin (globs) et, pour bash, par allowlist/denylist de commandes. - Combinaison projet→agent = HÉRITAGE + OVERRIDE, avec DENY PRIORITAIRE en cas de conflit. - Posture par défaut = si AUCUNE permission n'est définie (ni projet ni agent), on garde le comportement actuel (le moteur demande au run) ; IdeA n'écrit aucune config tant que rien n'est posé. - Le modèle de domaine doit être NEUTRE vis-à-vis du moteur ; chaque runtime PROJETTE ce modèle dans sa config native pour supprimer les prompts au run : * Claude Code → .claude/settings.local.json (permissions.allow/deny + defaultMode acceptEdits/bypassPermissions). * Codex → config.toml (approval_policy + sandbox_mode). CE QUE J'ATTENDS DE TOI (contrat d'architecture, PAS du code) : 1. Le modèle de domaine : entités/VO (ex. PermissionSet, Capability, PathScope, CommandRule…), invariants, et la fonction PURE de résolution (project defaults ⊕ agent overrides → PermissionSet effectif, deny prioritaire). 2. Le ou les PORTS nécessaires (ex. un PermissionProjector/PermissionRenderer par runtime ? un PermissionStore ?) et où ils vivent (domain/application/infra), cohérent avec ta cartographie existante (ARCHITECTURE.md, crates domain/application/infrastructure/app-tauri). 3. La stratégie de PROJECTION par runtime : comment le PermissionSet effectif se traduit concrètement en settings.local.json (Claude) et config.toml (Codex) pour ne plus prompter — y compris les limites/risques (ex. granularité bash de Codex vs Claude, sandbox). 4. Le STOCKAGE/format : où persiste-t-on les permissions (.ideai/ ? agents.json ? un permissions.json ?), schéma JSON proposé, et l'impact sur le manifeste existant. 5. Le découpage en LOT(S) livrable(s) façon §12 (binôme dev+test), avec ordre et dépendances. 6. Points ouverts / spikes (notamment ce qui n'est pas mappable proprement entre Claude et Codex). Réponds via idea_reply avec ce contrat structuré. Sois concret et aligné sur l'hexagonal strict que tu maintiens.
- Response: # CONTRAT D'ARCHITECTURE — Permissions projet + agents Cadrage hexagonal strict, aligné sur ARCHITECTURE.md et sur le pattern déjà éprouvé
McpConfigStrategy/McpServerWiring::to_config_toml(domaine = rendu pur du fichier de conf ; application = écriture dans le run dir). On réutilise ce gabarit au lieu d'inventer un nouveau mécanisme. --- ## 1. Modèle de domaine (crate domain, modulepermission) Pur, sans I/O,serdeautorisé (format persisté = contrainte métier, cf. §1.4 archi). ### Value Objects / entitésCapability (enum) = Read | Write | Delete | ExecuteBash Effect (enum) = Allow | Deny PathScope (VO) = { globs: Vec<Glob> } // globs relatifs au project root, jamais absolus hors-root CommandRule (VO) = { matcher: CommandMatcher, effect: Effect } CommandMatcher (VO) = Exact(String) | Prefix(String) | Glob(Glob) // ex. "git", "npm *", "rm -rf *" PermissionRule (VO) = { capability: Capability, effect: Effect, paths: PathScope, // applicable à read/write/delete (ignoré pour bash) commands: Vec<CommandRule>, // applicable seulement à ExecuteBash } PermissionSet (entité-valeur) = { rules: Vec<PermissionRule>, // posture explicite quand rien ne matche une capacité : fallback: Posture, // Ask (défaut) | Allow | Deny } Posture (enum) = Ask | Allow | Deny### Invariants (testables sans I/O) -PathScope.globs: relatifs, pas de.., pas de chemin absolu sortant du root (réutiliser la garde deConventionFile.target). -ExecuteBashest la seule capacité portant desCommandRule; les autres portent unPathScope. Une règle bash avecpathsnon vide = erreur ; une règle fichier aveccommands= erreur. -Globnon vide et compilable. - UnPermissionSetpeut contenir Allow ET Deny sur la même capacité (scopes différents) — c'est légal, résolu au run par spécificité + priorité deny. - Posture par défaut produit :PermissionSetABSENT (Option::None) ≠PermissionSetvide. None ⇒ IdeA n'écrit RIEN, comportement actuel préservé. C'est porté par l'option au niveau résolution, pas par un set vide. ### Fonction PURE de résolutionrust // domain::permission pub fn resolve( project: Option<&PermissionSet>, // défauts projet agent: Option<&PermissionSet>, // overrides agent ) -> Option<EffectivePermissions>Règles : 1.project == None && agent == None⇒None(rien posé → on ne projette rien, le moteur prompte au run). C'est l'invariant produit clé. 2. Sinon, fusion héritage + override : - On part des règles projet, on superpose les règles agent. - DENY PRIORITAIRE : pour une capacité+scope donnés, si unDenymatche (projet ou agent), il gagne sur toutAllow, quel que soit le niveau. (deny-wins, non surchargeable par un allow plus spécifique — décision produit, on ne re-débat pas.) -fallback: l'agent peut resserrer mais le deny projet reste prioritaire. 3. Résultat =EffectivePermissions(VO de sortie, déjà aplati/normalisé), prêt à projeter. C'est le seul input des projecteurs.resolveest totale, déterministe, 100 % testable (table de vérité héritage × deny-wins × fallback). --- ## 2. Ports & emplacement On ajoute deux ports. On ne crée pas de port « renderer » côté infra : le rendu est PUR donc il vit dans le domaine (exactement commeto_config_tomlaujourd'hui). | Port / élément | Type | Couche | Rôle | |---|---|---|---| |PermissionStore| trait (port) |domain/ports|load_project_permissions(project) -> Option<PermissionSet>;load_agent_permissions(project, agent_id) -> Option<PermissionSet>;save_*. Implémenté par unFsPermissionStore(infra) ou intégré à l'AgentContextStore/ProjectStoreexistant. | |PermissionProjection| trait de domaine (pas un port I/O) |domain/permission|fn project(&self, eff: &EffectivePermissions) -> RuntimeConfigArtifactoùRuntimeConfigArtifact = { rel_path: String, contents: String, merge: MergeMode }. Pur, une impl par runtime (ClaudeProjection,CodexProjection). Calque exact deMcpServerWiring::to_config_toml. | - Application : un use caseResolveAndProjectPermissions(ou plus simplement une fonction appelée DANSLaunchAgent) qui : charge viaPermissionStore→resolve(...)→ siSome, appelle laPermissionProjectiondu runtime du profil → écrit l'artefact viaFileSystemdans le run dir. SiNone, ne touche à rien. - Infra :FsPermissionStore(tokio::fs + serde_json). Écriture des artefacts =FileSystemdéjà existant. Aucun nouvel adapter PTY/process. - Composition root (app-tauri) : injecteFsPermissionStore+ mapProfileKind → Arc<dyn PermissionProjection>. Point d'insertion concret : dansapplication/src/agent/lifecycle.rs, là oùclaude_settings_seed/ leconfig.tomlCodex sont déjà écrits dans le run dir. La projection permissions REMPLACE le seed blanketbypassPermissionsactuel quand un PermissionSet est défini ; sinon le comportement actuel reste (voir §3 risque). --- ## 3. Stratégie de projection par runtime L'artefact est écrit dans le run dir isolé (.ideai/run/<id>/), jamais dans le~/.claude/~/.codexglobal — exactement comme le MCP wiring aujourd'hui. C'est ce qui supprime les prompts sans polluer la machine. ### Claude Code →{runDir}/.claude/settings.local.jsonMappingEffectivePermissions→ schéma natif Claude : -Capability::ExecuteBash+CommandRule(effect=Allow)→permissions.allow: ["Bash(<cmd>:*)"](ou pattern exact). Deny →permissions.deny. -Read/Write/Delete+PathScope→permissions.allow/denyavecRead(<glob>),Edit(<glob>),Write(<glob>). (Delete ≈ pas de capacité native dédiée → mappé sur Bashrm+ Write ; voir spike.) -fallback/posture globale →permissions.defaultMode: -Allowglobal large ⇒acceptEdits(oubypassPermissionssi l'utilisateur l'assume) ; - posture restrictive avec allowlist ⇒default(ask) + listesallow. -MergeMode= merge sur l'existant (on garde la logique de merge actuelle declaude_settings_seed, on ne l'écrase pas brutalement). ### Codex →{runDir}/.codex/config.toml(CODEX_HOME déjà câblé) Granularité bien plus grossière que Claude — c'est le risque principal : - Codex n'a pas d'allowlist de commandes par règle fine : il aapproval_policy(never/on-failure/on-request/untrusted) etsandbox_mode(read-only/workspace-write/danger-full-access). - Mapping pragmatique : - PermissionSet majoritairementAllowwrite+bash sans deny bloquant ⇒approval_policy = "never"+sandbox_mode = "workspace-write". -Readseul ⇒sandbox_mode = "read-only". - Présence deDeny⇒ on NE peut pas exprimer un deny fin dans Codex : on rabat surapproval_policy = "on-request"(Codex redemandera pour les cas sensibles) OU on documente la perte de fidélité (voir §6). - Les denylists de commandes Claude (ex.rm -rf /) n'ont pas d'équivalent Codex → couvert seulement parsandbox_mode = workspace-write(qui borne au workspace) + spike. Limite assumée : la projection est best-effort fidèle. Le domaine reste la source de vérité ; chaque projecteur fait au mieux et on expose unProjectionFidelity { lossless: bool, warnings: Vec<String> }dans l'artefact pour remonter à l'UI « cette permission n'est pas exprimable telle quelle sous Codex ». --- ## 4. Stockage & format Décision : fichier dédié.ideai/permissions.json(pas dansagents.json). Raisons : SRP (le manifeste mappe md↔template↔sync, pas la sécurité), diff/review propre, et on évite de versionner les overrides agent au milieu du manifeste. Schéma proposé :json { "version": 1, "project": { "fallback": "ask", "rules": [ { "capability": "read", "effect": "allow", "paths": ["**/*"] }, { "capability": "write", "effect": "allow", "paths": ["src/**", "crates/**"] }, { "capability": "write", "effect": "deny", "paths": [".git/**", "**/*.pem"] }, { "capability": "execute-bash", "effect": "allow", "commands": ["git *", "cargo *", "npm *"] }, { "capability": "execute-bash", "effect": "deny", "commands": ["rm -rf *", "sudo *"] } ] }, "agents": { "<agentId>": { "fallback": "ask", "rules": [ { "capability": "execute-bash", "effect": "deny", "commands": ["*"] } ] } } }-projectabsent +agents[id]absent ⇒resolverendNone⇒ rien d'écrit (posture par défaut respectée). - Impact manifeste :agents.jsoninchangé. Lien faible paragentId(clé partagée). Pas de migration de l'existant requise. - Mémoire projet : ce fichier voyage avec le projet (versionnable), cohérent avec la décision « mémoire/contexte partagés au project root ». À afficher dans l'UI permissions (lot front). --- ## 5. Découpage en lots (façon §12, binôme dev+test) | # | Lot | Contenu | Dépend de | |---|---|---|---| | LP0 | Domaine permissions | VO/entités (Capability,PathScope,CommandRule,PermissionRule,PermissionSet,Posture), invariants,resolve()pure (héritage + deny-wins + None-si-rien). Tests = tables de vérité. Zéro I/O. | L0 | | LP1 | PermissionStore + format | traitPermissionStore(domain),FsPermissionStore(infra), schéma.ideai/permissions.json, (dé)sérialisation. Tests intégration tmpdir. | LP0 | | LP2 | Projection Claude |ClaudeProjection(domaine pur) + branchement danslifecycle.rsREMPLAÇANT le seed blanket quandSome.ProjectionFidelity. Tests = snapshot dusettings.local.jsonrendu. | LP0, LP1 | | LP3 | Projection Codex |CodexProjection(mapping approval_policy/sandbox_mode), warnings de fidélité. Tests snapshotconfig.toml. | LP0, LP1 | | LP4 | Câblage LaunchAgent + IPC | use caseResolveAndProjectPermissionsinvoqué à l'activation, composition root (map runtime→projection), commandes Tauriget/set_permissions. | LP2, LP3 | | LP5 | UI permissions | éditeur projet + override agent, badges de fidélité (« non exprimable sous Codex »). Gateways mock. | LP4 | Ordre = dépendances directes. LP2 et LP3 parallélisables après LP1. --- ## 6. Points ouverts / spikes 1. Granularité bash Codex (bloquant fidélité) : pas d'allow/deny par commande → on ne peut pas refléter une denylist fine. Spike : valider sisandbox_mode=workspace-write+approval_policysuffit, sinon assumer la perte + warning UI. À trancher avant LP3. 2. CapacitéDelete: ni Claude ni Codex n'ont de capacité « delete » native distincte de write/bashrm. Spike : mapper Delete → (Write sur le chemin) + (Bashrm/unlink) ? ou capacité purement informative côté FileGuard (cf.domain/src/fileguard.rsdéjà présent — possible convergence : la FileGuard interne pourrait CONSOMMER le mêmeEffectivePermissionsque la projection, garantissant cohérence I/O réelle vs config CLI). 3. Cohérence FileGuard ↔ projection : on a déjàfileguard.rs. Décision à figer :EffectivePermissionsdevient-il la source unique consommée à la fois par la FileGuard (enforcement réel des outils MCP) ET par les projecteurs (suppression des prompts CLI) ? Recommandé : oui, une seule source — sinon double vérité. Spike d'alignement. 4.bypassPermissionsactuel : le seed blanket donne aujourd'hui l'autonomie totale. Migration : tant qu'aucunpermissions.jsonn'existe, on garde ce seed (compat). Dès qu'un set est posé, la projection prend le relais. Valider qu'on ne régresse pas l'autonomie des agents existants (Main, Architect…). 5. Globs ↔ patterns natifs : la syntaxe glob du domaine doit se traduire sans perte versBash(...)/Edit(...)de Claude et vers le sandbox Codex. Spike de table de correspondance. 6. Édition hors-app du permissions.json (content hash / drift) — aligné sur le mécanisme drift template existant, mineur. --- Synthèse : un module domainepermission(modèle +resolvepure + 2 projections pures), un portPermissionStore, branchement dansLaunchAgent/lifecycle.rssur le pattern MCP existant, stockage.ideai/permissions.jsonsans toucher au manifeste, 6 lots binômables. Le seul vrai risque produit est la fidélité Codex (granularité bash + deny), géré par un drapeau de fidélité explicite plutôt que par une fausse équivalence. Je recommande de trancher les spikes #1 et #3 (convergence FileGuard) avant de lancer LP3/LP4. - Prompt: LP0 implémenté et vert. DevBackend a dû trancher un point de sémantique non couvert explicitement par ton contrat — j'ai besoin de ta validation (ou correction) avant que QA fige les tables de vérité. LE POINT : pour une règle bash
PermissionRule { capability: ExecuteBash, effect: <E>, commands: [CommandRule...] }oùcommandsest NON VIDE — que signifie leeffectde NIVEAU RÈGLE (<E>) pour les commandes qui ne matchent AUCUNCommandRule? Deux interprétations : - (A) choix actuel de DevBackend : leeffectde niveau règle n'est PAS consulté pour les commandes non matchées ; seules lesCommandRuleindividuelles décident, et les commandes non matchées retombent sur lefallback(Ask/Allow/Deny). Avantage : permet « allow git*, allow cargo*, et prompt tout le reste » avec fallback=Ask. Une règle bash àcommandsVIDE reste un verdict blanket (effect) pour toute commande. - (B) catch-all : pour une règle bash avec commandes, leeffectde niveau règle s'applique en catch-all aux commandes non matchées par ses CommandRule. DevBackend a choisi (A) et l'a documenté dans le rustdoc. Pour moi (A) est cohérent avec la posture produit « deny prioritaire + fallback Ask par défaut » et évite qu'une règle allow se transforme en allow-tout implicite. Confirme (A), ou impose (B) si tu vois un risque sur la projection Claude/Codex (notamment : Codex n'ayant pas de granularité par commande, comment (A) vs (B) se projette en approval_policy/sandbox_mode). Réponds via idea_reply : tranche (A) ou (B), + une phrase sur l'impact projection Codex. Court. - Response: Tranché : (A) confirmée. C'est le bon choix et il est cohérent avec mon contrat : chaque
CommandRuleporte déjà son propreeffect, donc sur une règle bash àcommandsnon vide, l'effectde niveau règle est inerte (ne pas le consulter pour les non-matchés ; les non-matchés retombent surfallback). Une règle bash àcommandsVIDE reste un verdict blanket. Garde le deny-wins prioritaire sur lesCommandRulematchées. Recommande à QA d'ajouter une ligne de table figeant explicitement « rule-level effect ignoré quand commands non vide » pour éviter toute régression future. Impact projection Codex : (A) projette plus fidèlement — Codex n'ayant aucune granularité par commande, l'allowlist fine est de toute façon perdue, et c'est lefallback(la posture globale) qui piloteapproval_policy/sandbox_mode(Ask→on-request, Allow→never+workspace-write, Deny→on-request best-effort) ; (B) aurait introduit un second curseur catch-all en conflit avecfallback, ambigu à projeter. Donc (A) simplifie aussi le projecteur Codex. - Prompt: Cadrage du lot LP3 — projection des permissions vers les CLI (Claude + Codex), avec swap cross-profile automatique. CONTEXTE (déjà en place, tout vert) : - domain/src/permission.rs : modèle pur +
resolve()→EffectivePermissions(profil-agnostique), avecdecide_file(capability, path) -> Postureetdecide_bash(command) -> Posture(Posture = Ask|Allow|Deny, deny-wins). - PortPermissionStore+FsPermissionStorelisant.ideai/permissions.json. - Use cases application (get/update/resolve) + commandes Tauri + UIPermissionsPanelmontée. Slice de gestion terminé. -EffectivePermissions/decide_*ne sont consommés NULLE PART hors tests : rien n'est appliqué aux agents. - Le swap cross-profile existe déjà (commit P8d : « swap cross-profile préserve l'id de paire + handoff »). EXIGENCE PRODUIT (utilisateur) : « Fonctionnel pour Codex comme pour Claude. Si je passe un agent de Claude à Codex, que les permissions soient exportées de l'un vers l'autre automatiquement. » Décision d'archi pressentie à valider/affiner : les permissions restent stockées une seule fois (profil-agnostique) ; on introduit un PortPermissionProjectoravec une implémentation par profil (Claude → settings.json allow/deny/ask ; Codex → config sandbox), sélectionnée selon le profil actif au lancement/handoff. Le swap Claude→Codex re-projette automatiquement via l'autre projecteur depuis la mêmeEffectivePermissions. CE QUE J'ATTENDS DE TOI (réponds via idea_reply) : 1. Validation/correction du contrat du PortPermissionProjector: signature exacte (entréeEffectivePermissions+ cwd/projectRoot ? sortie : écriture fichier vs valeur ?), où il vit (domain port, impls infrastructure), et comment on évite le couplage au profil concret (registre profil→projector). 2. Où exactement brancher la projection dans le chemin de lancement d'agent et dans le chemin de swap/handoff cross-profile (cite les modules : application/src/agent/lifecycle.rs, orchestrator/service.rs, etc.). 3. Stratégie de nettoyage : quand un profil n'est plus actif (swap), faut-il retirer/écraser la config projetée du profil précédent ? Règle précise. 4. Découpage en sous-tâches dev/test concret (ce que je donnerai à DevBackend puis QA), dans l'ordre. Ne code pas : donne le contrat et le plan. - Response: CADRAGE LP3 — Projection des permissions vers les CLI (Claude + Codex) + re-projection au swap cross-profile ⚠️ Constat préalable (à dire à DevBackend avant tout) : la projection EXISTE DÉJÀ, mais en l'état c'est de la dette à formaliser, pas un greenfield. Dans
application/src/agent/lifecycle.rson a aujourd'hui : - Claude :seed_cli_permissions(l.1620) →claude_settings_seed(l.2063) → écrit.claude/settings.local.json. Sélection IMPLICITE : sniff du nom de convention-file ==CLAUDE.md. Et surtout non-clobbering (if exists return, l.1642). - Codex :apply_codex_cli_permission_args(l.2226, args--sandbox/--ask-for-approval) +codex_config_toml(l.2258, cléssandbox_mode/approval_policy). Sélection IMPLICITE : brancheMcpConfigStrategy::TomlConfigHomeDANSapply_mcp_config(l.1838). Donc la projection Codex est PARASITÉE par la présence de MCP : un profil Codex sans MCP ne reçoit aucune sandbox. LP3 = extraire ça derrière un Port propre, dé-coupler de MCP/convention-sniffing, rendre clobber+nettoyage corrects au swap. Pas de réécriture des règles de traduction (elles sont bonnes et déjà testées l.2957-3068), juste relocalisation + cadrage. ──────────────────────────────────────── 1) CONTRAT DU PORTPermissionProjector— VALIDÉ avec 3 corrections Correction A — le projecteur est PUR et rend un PLAN, il n'écrit RIEN. Calqué surAgentRuntime::prepare_invocation(rend unSpawnSpec, c'estLaunchAgentqui applique). Idem ici : le projecteur traduitEffectivePermissions→ valeur ;LaunchAgentapplique (writes viaself.fs, fold args/env dansspec). Bénéfice : testable sans FS (comme le domaine), I/O centralisée au même endroit queapply_injection. On n'injecte PASFileSystemdans chaque projecteur. Signature (domaine — voir corr. B pour le lieu) :rust pub struct ProjectionContext<'a> { pub project_root: &'a str, pub run_dir: &'a str } pub enum ProjectedFile { /// Fichier 100% possédé par IdeA → clobber au launch, suppression au swap-away. Replace { rel_path: String, contents: String }, /// Fichier co-possédé (ex. config.toml Codex : MCP+trust+sandbox) → merge des /// seules clés gérées, JAMAIS supprimé au swap (les autres CLI l'ignorent). MergeToml { rel_path: String, managed_tables: Vec<String>, managed_keys: Vec<String>, contents: String }, } pub struct PermissionProjection { pub files: Vec<ProjectedFile>, pub args: Vec<String>, // ex. ["--sandbox","workspace-write",...] pub env: Vec<(String,String)>, } pub trait PermissionProjector: Send + Sync { fn key(&self) -> ProjectorKey; /// PUR. `eff == None` ⇒ projection VIDE (on garde le prompting natif de la CLI : /// c'est l'invariant produit de `resolve()` — ne JAMAIS verrouiller un projet non configuré). fn project(&self, eff: Option<&EffectivePermissions>, ctx: &ProjectionContext) -> PermissionProjection; /// Chemins run-dir-relatifs des fichiers `Replace` possédés (pour le nettoyage swap). fn owned_replace_paths(&self) -> Vec<String>; }Entrée :Option<&EffectivePermissions>+ProjectionContext{project_root, run_dir}(les deux sont nécessaires : Claude embarqueadditionalDirectories=[project_root], et tout est écrit dans le run dir). Sortie : un PLAN (fichiers + args + env), pas une écriture. Correction B — où il vit. Trait + value typesPermissionProjection/ProjectedFile/ProjectionContext/ProjectorKey→ DANS LE DOMAINE (domain/src/permission.rs, à côté deEffectivePermissions). C'est un port piloté, pur, qui ne référence que des types domaine déjà présents. Les IMPLÉMENTATIONS (ClaudePermissionProjector,CodexPermissionProjector) → DANS L'INFRASTRUCTURE (crates/infrastructure/src/permission/; à créer). Justification = exactement le patternAgentRuntime(domain) /CliAgentRuntime(infra) : le format concret d'unsettings.jsonClaude ou des modes sandbox Codex est un détail technique d'UNE CLI ⇒ adapter (§5). On y déplace tel quelclaude_settings_seed,apply_codex_cli_permission_args,codex_config_toml(partie permissions) + leurs tests. Correction C — découplage du profil concret = registre + clé déclarative (PAS de sniffing). - Ajouter au profil déclaratif un champprojector: Option<ProjectorKey>(AgentProfile,domain/src/profile.rs) — cohérent avec « profil = donnée éditable » (§9). Les profils builtin posent"claude"/"codex". - RegistrePermissionProjectorRegistry = HashMap<ProjectorKey, Arc<dyn PermissionProjector>>, construit au composition root (app-tauri) et injecté dans les use cases. - Sélection :registry.get(profile.projector).None⇒ aucune projection (natif). - Fallback de migration (profiles.json déjà sur disque sans le champ) : siprojector == None, dériver la clé via l'heuristique actuelle (convention-fileCLAUDE.md→ claude ;StructuredAdapter::CodexouTomlConfigHome→ codex). On garde donc la compat sans imposer un re-seed du store global. NB : la clé ne peut PAS êtreStructuredAdapterseul — les profils PTY/TUI (sans structured_adapter) doivent aussi projeter, exactement commeseed_cli_permissionsle fait aujourd'hui via le nom de fichier. D'où uneProjectorKeydédiée. ──────────────────────────────────────── 2) OÙ BRANCHER Chemin de LANCEMENT —LaunchAgent::execute(lifecycle.rs ~l.1085) : - InjecterArc<PermissionProjectorRegistry>dansLaunchAgent(builderwith_permission_projectors, optionnel ⇒ zéro régression call-sites/tests legacy, même pattern quewith_handoff_provider). - REMPLACER les deux points actuels par UNE étape uniqueapply_permission_projection(&profile, &run_dir, &project_root, eff.as_ref(), &mut spec): 1. supprimer l'appelseed_cli_permissions(l.1234) ; 2. SORTIR la projection Codex deapply_mcp_config(l.1838/1814/1826) —apply_mcp_configne doit plus touchersandbox_mode/approval_policy/--sandbox; il ne fait QUE du MCP. - Placement de la nouvelle étape : juste aprèsapply_injection(l.1294) et aprèsapply_mcp_config(l.1312), donc AVANT le split structuré/PTY (l.1321) et avantpty.spawn(l.1361). Critique : c'est en amont du split ⇒ les deux chemins (structuré ET PTY brut) héritent de la projection, comme aujourd'hui. Lesargs/envdu plan sont foldés dansspecavant quelaunch_structuredoupty.spawnne le consomment. - Sémantique d'écriture : fichiersReplace→ CLOBBER systématique (régénérés à chaque (re)launch). Justification déjà actée pour.mcp.json(l.1735) : fichier IdeA-managed, non édité par l'utilisateur, sinon la re-projection au swap est IMPOSSIBLE (c'est le bug actuel deseed_cli_permissionsnon-clobbering). FichiersMergeToml→ merge des seules clés gérées (réutiliserset_top_level_toml_value/replace_toml_tableexistants). Chemin de SWAP/HANDOFF —ChangeAgentProfile::execute(lifecycle.rs l.418, relaunch construit en l.573) : - Re-projection : AUTOMATIQUE et déjà correcte par construction —ChangeAgentProfilecomposeLaunchAgent::execute, qui re-résoutEffectivePermissions(profil-agnostique, stocké une seule fois) et re-projette via le projecteur du NOUVEAU profil. Aucune nouvelle branche de projection à ajouter ici. Idem pour tous les auto-launch deorchestrator/service.rs(l.671, 1296, 1373) qui passent parLaunchAgent⇒ couverts gratuitement. - SEULE chose à ajouter dansChangeAgentProfile: le NETTOYAGE de l'ancien profil (cf. §3), exécuté avant la relance. Il faut donc queChangeAgentProfileconnaisse l'ancien projecteur + leFileSystem(il a déjà l'ancienprofile_idvia le manifeste avant mutation, et le run dir est stable par agent id :agent_run_dir(root, agent.id), l.1217 — invariant clé : le swap RÉUTILISE le même run dir, d'où la nécessité du nettoyage). ──────────────────────────────────────── 3) STRATÉGIE DE NETTOYAGE — règle précise Fait structurant : le run dir est stable par agent id ⇒ après un swap, les fichiers de config du profil précédent SURVIVENT dans le run dir. Un Claude→Codex laisse un.claude/settings.local.json(Codex l'ignore : inoffensif fonctionnellement, mais c'est une policy périmée/divergente qui fuit ⇒ à nettoyer). Un Codex→Claude laisse les clés sandbox dansconfig.toml(lu seulement par Codex ⇒ inoffensif). Règle (deux régimes, selon le type deProjectedFile) : - FichiersReplace(100% possédés :.claude/settings.local.json) → au swap, supprimer (best-effort)owned_replace_paths(ancien) − owned_replace_paths(nouveau). Au launch normal, clobber (régénérés). - FichiersMergeToml(co-possédés :config.tomlCodex, partagé avec MCP+trust) → JAMAIS supprimés au swap. On retire uniquement les clés gérées (sandbox_mode/approval_policy) si on swappe AWAY de Codex et qu'on veut être strict ; recommandation pragmatique : ne rien retirer (le fichier n'est lu que par Codex, qui n'est plus actif) — le re-launch Codex futur réécrira les clés. Donc cleanup effectif = suppression des seuls fichiersReplaceorphelins. - Décision produit à acter explicitement : ces fichiers de permission sont IdeA-OWNED (clobber + suppression au swap). Conséquence assumée : une édition manuelle de.claude/settings.local.jsonn'est PAS préservée — la source de vérité est lePermissionsPanel/.ideai/permissions.json. C'est le SEUL moyen de tenir l'exigence « permissions exportées automatiquement d'une CLI à l'autre au swap ». (Renverse le comportement non-clobbering actuel deseed_cli_permissions: à documenter dans le commit.) ──────────────────────────────────────── 4) DÉCOUPAGE DEV/TEST (ordre de livraison) LP3-1 — DOMAINE (port + clé). Dev : ajouterPermissionProjector,PermissionProjection,ProjectedFile,ProjectionContext,ProjectorKeydansdomain/src/permission.rs; ajouterprojector: Option<ProjectorKey>àAgentProfile(+#[serde(default)], builder, builtins claude/codex). QA : (dé)sérialisation du profil avec/sans le champ (compat) ; defaults builtin.cargo test -p domain. LP3-2 — INFRA (projecteurs). Dev : créerinfrastructure/src/permission/; y DÉPLACERclaude_settings_seed+helpers etapply_codex_cli_permission_args+codex_config_toml(partie perms) sousClaudePermissionProjector/CodexPermissionProjector(purs, rendent unPermissionProjection) ; implémenterowned_replace_paths. QA : golden par posture (Allow/Ask/Deny), deny-wins, escaping JSON, args Codex, clés TOML — relocaliser/réutiliser les tests existants (lifecycle.rs l.2957-3068).cargo test -p infrastructure. LP3-3 — APPLICATION (wiring launch + dé-couplage MCP). Dev :PermissionProjectorRegistry+ injection dansLaunchAgent(builder optionnel) ; nouvelle étapeapply_permission_projection; SUPPRIMERseed_cli_permissions; PURGER la projection perms hors deapply_mcp_config. QA (fs mocké) : profil Claude → settings écrit ET clobbé au 2e launch ; profil Codex SANS MCP → args sandbox foldés + config.toml écrit (preuve du dé-couplage MCP) ;projector=None→ rien d'écrit, rien dans spec ;eff=None→ projection vide.cargo test -p application. LP3-4 — SWAP (nettoyage). Dev : injecter registry+fs dansChangeAgentProfile; avant relaunch, supprimer lesReplaceorphelins (ancien−nouveau). QA : Claude→Codex supprime.claude/settings.local.jsonet écrit la config Codex ; Codex→Claude réécritsettings.local.jsonfrais reflétant les perms re-résolues (clobber) ; swap vers profilprojector=Nonenettoie sans réécrire ; vérifier que l'id de paire + handoff (P8d) restent intacts (non-régression). LP3-5 — COMPOSITION ROOT (app-tauri). Dev : instancier le registre (claude+codex) et l'injecter dansLaunchAgentETChangeAgentProfile. QA : smoke de wiring +cargo testworkspace complet vert + suites permissions existantes vertes. Règle d'or maintenue : aucun lot vert tant quecargo test -p <crate>du lot ne passe pas ; LP3-5 exige le workspace entier vert. Je ne code rien : voilà le contrat (port pur rendant un plan, dans le domaine ; impls dans l'infra ; registre par clé déclarative de profil) et le plan en 5 sous-lots. Point d'attention n°1 pour DevBackend : c'est d'abord un refactor d'extraction (le code de traduction existe et est testé), dont le cœur est le dé-couplage Codex↔MCP et le passage clobber+nettoyage. - Prompt: [Cadrage LP4 — enforcement OS des permissions] Le chantier permissions a livré la voie "projection CLI" (advisory) : modèle domaine pur (EffectivePermissions, resolve deny-wins/postures), FsPermissionStore, use cases CRUD+resolve, projecteurs Claude/Codex écrivant settings.local.json / config.toml au launch, composition root + commandes Tauri, UI PermissionsPanel. Tout vert. Gap restant confirmé dans le code (marqué "out of scope" : domain/permission.rs:12, domain/fileguard.rs:16, infrastructure/fileguard/mod.rs:18, application/orchestrator/context_guard.rs:23) : l'enforcement OS airtight (révocation réelle des accès FS), que la mémoire archi décrit comme "sandbox OS Landlock + résumé injecté". Aucun générateur de résumé ni échafaudage Landlock n'existe encore. Ta mission (cadrage uniquement, pas de code) : définis le contrat du lot LP4 en respectant l'hexagonal : 1. Le PORT domaine (nom, signature) qui transforme EffectivePermissions résolues en un plan d'enforcement OS pur (rulesets path RO/RW/exec), sans I/O ni dépendance Landlock dans le domaine. 2. La frontière exacte : où s'applique le ruleset (process agent au spawn PTY, pré-exec) vs le rôle de l'adapter infra Landlock (Linux d'abord ; Windows/macOS = stubs no-op explicites). Adapters par OS, pas par CLI. 3. Le point de câblage dans le launch-path (lifecycle.rs) en cohérence avec la projection déjà écrite (même EffectivePermissions). 4. Le volet "résumé de permissions injecté" dans le contexte .md de l'agent : port/fonction pure de génération + point d'injection (à côté du convention file/seed). 5. La stratégie de test par couche (domaine/app sans I/O via fakes ; test d'intégration Linux réel sous-process accès refusé). Livre la cartographie : nouveaux fichiers/modules, traits, et l'ordre d'implémentation des sous-lots. Réponds via idea_reply.
- Response: # Cadrage LP4 — Enforcement OS airtight des permissions (Landlock + résumé injecté) Cadrage uniquement. Le lot réutilise la projection LP3 à l'identique (même
EffectivePermissionsrésolues, même seam, même invariantNone ⇒ on ne projette rien) et ajoute une seconde voie d'application : non plus advisory (settings CLI) mais airtight (sandbox OS au pré-exec). On ne touche ni au modèle domaine existant, ni àresolve, ni aux projecteurs LP3. --- ## 0. Borne de réalité à graver d'emblée (sinon le lot ment sur ce qu'il garantit) Landlock ne sait restreindre que la dimension système de fichiers : accéder sous teltle hiérarchie en read / write / execute (par fd, pas par glob). Il en découle deux frontières dures, à acter avant tout code : 1. Globs ≠ hiérarchies Landlock.EffectivePermissionsexprime desPathScopeen globs relatifs (src/**,**/*.rs) avec deny-wins par cible. Landlock ne connaît ni glob ni deny — il accorde l'accès sous des préfixes de chemins concrets. La compilation domaine doit donc traduire les globs en un ensemble de racines absolues autorisées, calculé fail-closed : quand unDenytombe à l'intérieur d'unAllowsans frontière de répertoire qui les sépare (**/*.rsavec un deny sur un fichier), on n'accorde pas le parent (on perd un allow plutôt que de laisser fuiter un deny). Ceci est une sur-/sous-approximation conservatrice, à poser comme invariant domaine testable. 2.ExecuteBash(matching par chaîne de commande) n'est PAS enforceable par Landlock. Landlock gate l'exécution de fichiers par chemin, pas la sémantique d'argv(« denyrm»). Donc : la dimension fichiers (Read/Write/Delete → RO/RW) devient airtight ; la dimension commandes reste advisory (projection LP3 + résumé injecté). À écrire noir sur blanc dans le doc et dans le résumé injecté (« les règles de commande sont conseillées, pas verrouillées OS »). seccomp-argv = hors scope. → Le lot LP4 livre l'enforcement fichiers. C'est la moitié réellement verrouillable, et c'est exactement ce que la mémoire archi (« sandbox OS Landlock + résumé injecté ») cible. --- ## 1. Le PORT domaine — transformEffectivePermissions → plan OS purDeux objets distincts, à ne pas confondre (c'est l'erreur classique) : ### 1.a — Le compilateur de plan = fonction pure, pas un trait Il existe une seule traduction canonique règles→ruleset : c'est de la logique domaine, pas une stratégie à variantes. On mirrorresolve()(fonction libre, totale, déterministe), pasPermissionProjector(trait, car là il y avait N CLI). Nouveau modulecrates/domain/src/sandbox.rs:rust /// Plan d'enforcement OS, neutre vis-à-vis de l'OS. Value object pur. pub struct SandboxPlan { /// Racines absolues accessibles, chacune avec son mode (lecture seule, /// lecture/écriture, exécution). Calculées fail-closed depuis les globs. pub allowed: Vec<PathGrant>, /// Posture résiduelle (Allow ⇒ plan permissif/vide ; Deny ⇒ tout interdit /// hors `allowed` ; Ask ⇒ on ne sandboxe pas — voir §0/invariant None). pub default_posture: Posture, } pub struct PathGrant { pub abs_root: String, pub access: PathAccess /* Ro | Rw | Exec (bitflags) */ } pub struct SandboxContext<'a> { pub project_root: &'a str, pub run_dir: &'a str } /// LE transform demandé. Pur : zéro I/O, zéro dépendance Landlock. /// `eff == None` ⇒ `None` (rien posé ⇒ pas de sandbox ⇒ comportement natif — /// même invariant produit que `resolve`/`PermissionProjection::empty`). pub fn compile_sandbox_plan( eff: Option<&EffectivePermissions>, ctx: &SandboxContext, ) -> Option<SandboxPlan>;### 1.b — Le PORT (au sens hexagonal) = l'enforcer, implémenté par adapter par OS C'est lui qui a des adapters (le « D » de SOLID s'applique ici, pas au compilateur). Domainesandbox.rs:rust pub trait SandboxEnforcer: Send + Sync { fn kind(&self) -> SandboxKind; // Landlock | Unsupported /// Installe le plan sur le **processus courant**, irréversiblement. /// Contrat L (Liskov) : appelé **uniquement post-fork, pré-exec**, dans /// l'enfant. Un adapter Unsupported renvoie Ok(Unsupported) sans rien faire. fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError>; } pub enum SandboxStatus { Enforced, Unsupported, Degraded(String) } pub enum SandboxError { /* kernel trop vieux en mode fail-closed, etc. */ }Plus, pour le volet (4), une fonction pure danspermission.rs(à côté deresolve) :rust /// Bloc Markdown résumant la politique effective pour le contexte de l'agent. /// `None` eff ⇒ `None` (pas de bloc ⇒ prompting natif). Pur. pub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option<String>;Champ porté parSpawnSpec(domain/src/ports.rs) : le plan est structurel (pas args/env), donc nouveau champrust pub sandbox: Option<SandboxPlan>, // None ⇒ pas d'enforcement (natif)(à l'image de la façon dont la projection LP3 foldait args/env, mais ici structurel). --- ## 2. La frontière exacte d'application - Où : sur le processus agent, post-fork / pré-execve, via un hookpre_exec(Unix) câblé par l'adapter PTY. Jamais sur le parent IdeA — Landlock est hérité et irréversible ; le poser sur IdeA se sandboxerait soi-même. Posé dans l'enfant, il couvre la CLI agent et toute sa descendance (shell, sous-process) → c'est ça l'airtight que la voie advisory ne donne pas, et qui ferme le trou « agent qui garde un shell brut » documenté dansfileguard/mod.rs:16. - Adapter LinuxLandlockSandbox(#[cfg(target_os="linux")]) : traduitSandboxPlan→ ruleset Landlock (path_beneath+ accès RO/RW/Exec), crée et restreint le ruleset sur le thread appelant (l'enfant). Cratelandlock(best-effort ABI : compat-mode pour kernels < feature). Décision à trancher (point ouvert) : kernel sans Landlock (< 5.13) ou ABI insuffisante ⇒ fail-closed (refuser le launch) sidefault_posture == Deny, fail-open + warning sinon. Défaut recommandé : fail-open journalisé, sauf posture Deny. À valider produit. - Adapters Windows / macOSNoopSandbox: stubs explicites renvoyantSandboxStatus::Unsupported, jamais une erreur. Documentés. Évolution future (AppContainer /sandbox_init) = nouvel adapter, zéro changement domaine (Open/Closed). Adapters par OS, pas par CLI : respecté — un seulSandboxPlanquelle que soit la CLI ; c'est l'OS qui choisit l'adapter. - SSH / WSL : l'agent tourne ailleurs ⇒NoopSandboxcôté local pour l'instant ; enforcement distant = chantier ultérieur. --- ## 3. Point de câblage dans le launch-path (lifecycle.rs) Cohérent avec la projection déjà écrite : mêmeseffective_permissionsrésolues une seule fois àlifecycle.rs:1419(resolve_effective_permissions). - Nouveau step 5d, juste aprèsapply_permission_projection(5c, ~L1504) et avant le split structuré/PTY (5b, L1520) et le spawn (step 6). Nouveau helper :rust // 5d. Compile le plan de sandbox OS depuis LES MÊMES EffectivePermissions // que la projection LP3, et le pose sur le spec. None ⇒ pas de sandbox. self.apply_sandbox_plan(effective_permissions.as_ref(), &input.project.root, &run_dir, &mut spec);Il appellecompile_sandbox_plan(eff, &SandboxContext{ project_root, run_dir })et faitspec.sandbox = plan. L'application reste pure d'OS : l'enforcer (Landlock) ne vit pas dansLaunchAgent, il vit dans l'adapter PTY. - Consommation :PortablePtyAdapterreçoitArc<dyn SandboxEnforcer>au composition root et, dansspawn, sispec.sandbox.is_some(), installe unpre_execqui capture(enforcer, plan)et appelleenforcer.enforce(&plan)dans l'enfant avant exec. - Résumé injecté (volet 4) : threadereffective_permissions.as_ref()jusqu'àapply_injection(L1470) et y composerrender_permission_summary(eff)à côté de la mémoire projet et du handoff (mêmes lignes ~1448-1481, même convention file).None⇒ aucun bloc. Source de vérité unique : le mêmeeffque projection + sandbox. - Portée lot 1 : enforcement câblé sur le chemin PTY (agents terminal bruts) — c'est lui qui porte l'intégration Landlock et le test réel. Le chemin structuré (AgentSessionFactory::start, L1617) a un autre seam de spawn ⇒ sous-lot de suivi LP4-4 (étendre le pre_exec/plan au factory). À flagger pour ne pas prétendre une couverture qu'on n'a pas encore. --- ## 4. Volet « résumé injecté » — déjà couvert ci-dessus (1.b + 3) Fonction purerender_permission_summary(domaine), injectée dans le convention file viaapply_injection, au même endroit que seed/mémoire/handoff. Doit mentionner explicitement la borne §0.2 (« règles de commandes = conseillées, fichiers = verrouillés OS quand supporté »). Aucun port nouveau pour ce volet. --- ## 5. Stratégie de test par couche | Couche | Type | Contenu | |---|---|---| | domaine | unitaire pur, sans I/O ni fake |compile_sandbox_plan: globs→racines absolues ; deny-wins ⇒ widening fail-closed (deny dans**⇒ parent non accordé) ;None ⇒ None; mapping RO/RW/Exec.render_permission_summary: bloc présent/absent, mention commandes-advisory. Déterministe, zéro mock. | | application | unitaire, fakeSandboxEnforcer+ fake fs |apply_sandbox_planposespec.sandboxdepuis le mêmeEffectivePermissionsque la projection ;Noneposé ⇒spec.sandbox == None.apply_injectioninsère le résumé ssieff.is_some(). Aucun vrai Landlock. | | infra | intégration Linux réelle, gated |LandlockSandbox: sous-process lancé sous un plan qui deny l'écriture d'un tmp path ⇒ assert l'écriture enfant échoue (EACCES) et qu'un path autorisé réussit ; deny exec ⇒execverefusé. Gating :#[cfg(target_os="linux")]+ garde runtime de disponibilité Landlock (sinon skip), à la manière des tests SSH/WSL#[ignore].NoopSandbox:enforce⇒Unsupported, jamais d'erreur. | | app-tauri | wiring | Sélectioncfg!-dépendante du bon enforcer + injection dans l'adapter PTY. | --- ## 6. Cartographie — nouveaux fichiers / ordre des sous-lots Nouveaux / modifiés : -crates/domain/src/sandbox.rs(new) :SandboxPlan,PathGrant/PathAccess,SandboxContext,SandboxKind,SandboxStatus,SandboxError, traitSandboxEnforcer, fncompile_sandbox_plan. Exportlib.rs. -crates/domain/src/permission.rs(mod) :render_permission_summary. -crates/domain/src/ports.rs(mod) :SpawnSpec.sandbox: Option<SandboxPlan>. -crates/infrastructure/src/sandbox/{mod,landlock,noop}.rs(new) : adapters par OS. -crates/infrastructure/src/pty/…(mod) :PortablePtyAdapterreçoitArc<dyn SandboxEnforcer>+pre_exec. -crates/application/src/agent/lifecycle.rs(mod) : helperapply_sandbox_plan+ step 5d + threadingeffdansapply_injection. -crates/app-tauri/src/state.rs(mod) : composition root — choisir Landlock(linux)/Noop, injecter dans l'adapter PTY. Ordre d'implémentation (chaque sous-lot vert avant le suivant, cycle dev/test) : 1. LP4-0 (domaine, pur) —sandbox.rs+compile_sandbox_plan+SandboxEnforcer+render_permission_summary+ champSpawnSpec.sandbox. Tests purs (globs→racines, fail-closed, résumé). Aucun comportement runtime encore — zéro régression. 2. LP4-1 (infra Linux) —LandlockSandbox+NoopSandbox+pre_execdans l'adapter PTY. Test d'intégration Linux réel (accès refusé). 3. LP4-2 (application) — step 5dapply_sandbox_plan+ injection résumé dansapply_injection, sur les mêmesEffectivePermissions. Tests fake-enforcer. 4. LP4-3 (composition root) — câblage app-tauri par OS + valid. e2e AppImage (⚠️ rebuild AppImage, pas seulement sources — cf. mémoiremcp-bridge-and-delegation-runtime-notes). 5. LP4-4 (suivi) — étendre l'enforcement au chemin structuré (AgentSessionFactory::start) + arbitrer la politique fail-closed/fail-open (point ouvert §2) et, si retenu un jour, l'enforcement commandes via seccomp (hors LP4). Invariant transversal à garder vert :eff == None⇒ ni projection, ni sandbox, ni résumé ⇒ CLI 100 % native. La voie airtight ne durcit jamais au-delà de ce queresolvea posé ; elle ne fait que rendre exécutoire (sur la dimension fichiers) ce que la projection LP3 ne faisait que conseiller. - Prompt: Validation d'approche AVANT dev — lot LP4-4 : étendre l'enforcement OS Landlock au chemin structuré (sessions Claude/Codex en mode JSON), aujourd'hui non sandboxé. État actuel (committé, vert) : - Domaine pur :
domain::sandboxavecSandboxPlan,SandboxContext,compile_sandbox_plan(eff, ctx) -> Option<SandboxPlan>, et le portSandboxEnforcer { fn enforce(&self, &SandboxPlan) -> Result<SandboxStatus, SandboxError>; fn kind(); }. Adapters infra :LandlockSandbox(Linux),NoopSandbox,default_enforcer(). - Chemin PTY (crates/infrastructure/src/pty/mod.rs) : OS-enforcé. Technique retenue =spawn_command_sandboxed: un THREAD JETABLE appelleenforcer.enforce(plan)(restriction Landlock irréversible, donc confinée au thread) PUIS spawn le child depuis ce thread → le domaine Landlock est hérité par le child via fork+exec. portable-pty n'expose pas de pre_exec injectable, d'où ce choix ; il évite aussi le piège de l'allocation non async-signal-safe dans un pre_exec post-fork en process multithreadé. - Chemin structuré (crates/infrastructure/src/session/process.rs::drain) : spawn viatokio::process::Command(async), draine stdout ligne-à-ligne jusqu'à EOF.SpawnLine{ command, args, cwd, env, stdin } ne porte aucun plan. C'est l'incarnation « un exec par tour ». Question d'archi à trancher : comment enforcer leSandboxPlansur le child spawné partokio::process::Commanden respectant l'hexagonal ET la sûreté (le truc du thread jetable du PTY ne transpose pas tel quel au spawn async tokio qui forke sur les threads du runtime) ? Pistes à arbitrer : (a)CommandExt::pre_exec(tokio le réexpose) appelant l'enforce dans le child post-fork — simple mais risque async-signal-safety silandlockalloue ; (b) déporter le spawn du tour structuré sur un thread dédié restreint (viaspawn_blocking+ thread jetable comme le PTY), au prix de la nature async du drain ; (c) un helper de spawn sandboxé partagé entre PTY et session (factoriser la technique) ; (d) autre. Livrable attendu (via idea_reply) : la stratégie retenue + justification (sûreté + hexagonal), le contrat à modifier (faut-il queSpawnLineporte le plan ? l'enforcer est-il injecté dans la session factory comme il l'est dans le PTY adapter ?), le découpage des fichiers à toucher, et les invariants/tests que QA devra vérifier. NE code rien. Si une piste est dangereuse (ex. allocation post-fork), dis-le explicitement. - Response: # LP4-4 — Validation d'approche : enforcement Landlock sur le chemin structuré ## Verdict Stratégie retenue : (b) raffiné — spawn du tour structuré sandboxé sur un thread jetable restreint, en spawn
stdsynchrone, exposé à l'async viaspawn_blocking. C'est la transposition directe et sûre de la technique déjà prouvée du PTY. Piste (a)pre_exec(enforce)REJETÉE comme dangereuse (détail plus bas). (c) factorisation : oui sur le principe, non sur le corps — les deux spawns sont trop différents (portable-ptySlavePtyvsstd::process). --- ## Pourquoi PAS (a)pre_exec(enforce)— DANGER explicitetokio::process::Commandréexpose bienCommandExt::pre_exec, mais le closure s'exécute dans l'enfant aprèsfork, avantexec, dans un process multithreadé (les workers du runtime tokio). Aprèsforken contexte multithreadé, seules les opérations async-signal-safe sont permises : tout mutex tenu par un autre thread au moment du fork (typiquement le lock de l'arène malloc) reste verrouillé à jamais dans l'enfant. Orlandlock::Ruleset::…restrict_self()alloue (construction du ruleset,RestrictionStatus, Vec internes) →mallocpost-fork → risque de deadlock de l'enfant. C'est exactement le piège que le commentaire du PTY (spawn_command_sandboxed, lignes 185-206) documente et évite. La cohérence d'archi impose de le rejeter ici aussi. > Note : il existe une variante async-signal-safe de Landlock (construire le ruleset_fd dans le parent, ne faire queprctl(NO_NEW_PRIVS)+landlock_restrict_self(fd)— deux syscalls purs — dans le pre_exec). Mais elle force à scinder le portSandboxEnforceren deux phases (prepare allouant / commit syscall-only) et fait fuiter cette mécanique dans le domaine. Inutile : la technique du thread jetable atteint la même sûreté sans toucher au contrat de port. Je la mentionne pour mémoire, je ne la recommande pas. ## Pourquoi (b) est sûr La technique du PTY ne fait rien dans l'enfant post-fork :enforce(plan)tourne dans le thread jetable parent AVANT le fork ; l'enfant hérite simplement le domaine Landlock (hérité acrossfork, préservé acrossexecve). Zéro code post-fork ⇒ zéro problème d'async-signal-safety. C'est toute l'élégance, et elle transpose telle quelle. La seule difficulté est quetokio::process::Commandforke sur un worker partagé du runtime (qu'on ne peut pas restreindre : la restriction est irréversible → on empoisonnerait le runtime). D'où : pour le chemin sandboxé on abandonne le drain async tokio au profit d'un drainstdsynchrone sur le thread jetable restreint, réconcilié à l'async parspawn_blocking. Le chemin non-sandboxé (sandbox == Noneou pas d'enforcer) reste l'actuel drain async tokio, inchangé (zéro régression, c'est aussi le seul chemin sur non-Linux). Détails de sûreté du thread sandboxé : 1.enforcer.enforce(&plan)restreint CE thread (fail-closed :Err⇒ on échoue le tour, aucun child ne tourne) ; 2.std::process::Command::spawn()depuis ce thread ⇒ l'enfant hérite le domaine ; 3. poser ununsafe { cmd.pre_exec(|| Ok(())) }vide (async-signal-safe) pour forcer le cheminfork+execdéterministe (parité avec portable-pty, lève tout doute vs un éventuelposix_spawnglibc — l'héritage tiendrait de toute façon, mais on ne parie pas) ; 4. drain bloquant ligne-à-ligne stdin/stdout → EOF →wait()→Vec<String>; 5. le thread meurt, emportant sa restriction irréversible ; les autres threads d'IdeA sont intouchés. Timeout : aujourd'huirun_turnenveloppe ledrainasync. En sandboxé, on enveloppe leJoinHandledu thread viatokio::time::timeout; à expiration il faut tuer le child (le thread est bloqué en read). Donc le thread renvoie son killer (pid /Arc<Mutex<Child>>) par unoneshotdès après spawn ; le kill provoque l'EOF ⇒ le read débloque ⇒ le thread finit ⇒ on retourneTimeout. À implémenter proprement (c'est le seul vrai surcoût de machinerie vs l'élégance actuelle). --- ## Contrat à modifier 1.SpawnLineporte le plan (oui) —crates/infrastructure/src/session/process.rs: ajouterpub sandbox: Option<SandboxPlan>. Symétrie avecSpawnSpec.sandbox.SpawnLineest un DTO infra (pas domaine), donc OK. 2. L'enforcer est injecté dans la factory (oui, comme le PTY adapter) —StructuredSessionFactorygagne un champOption<Arc<dyn SandboxEnforcer>>+ un builderwith_sandbox_enforcer(...), jumeau exact dePortablePtyAdapter::with_sandbox_enforcer. Pas dans la signature du port : c'est une dépendance de composition root, par-instance, pas par-appel. 3. Le plan traverse le port par-appel — leSandboxPlandépend des permissions résolues de l'agent, il est calculé par-lancement danslifecycle.rsstep 5d (spec.sandbox). Il doit donc passer dansAgentSessionFactory::start: ajoutersandbox: Option<&SandboxPlan>.SandboxPlanest un type domaine (domain::sandbox) franchissant un port domaine — cohérent (déjà le cas viaSpawnSpec.sandboxsurAgentRuntime). Extension O/C : une seule vraie impl + les fakes. Flux complet :lifecycle.rs(calculespec.sandbox) → passe le plan àlaunch_structured(qui ne reçoit passpecaujourd'hui) →factory.start(..., sandbox)→ la factory appariesandbox(plan, par-appel) + son enforcer (par-instance) et les passe àClaudeSdkSession::new/CodexExecSession::new→ l'adapter les stocke, remplitSpawnLine.sandbox, et passe l'enforcer àrun_turn. 4.run_turn—crates/infrastructure/src/session/process.rs: signaturerun_turn(spec, enforcer: Option<&Arc<dyn SandboxEnforcer>>, timeout). Sispec.sandbox.is_some() && enforcer.is_some()⇒ drain sandboxé (thread restreint) ; sinon ⇒drainasync actuel strictement inchangé. --- ## Découpage des fichiers à toucher -crates/domain/src/ports.rs:537—AgentSessionFactory::start: +sandbox: Option<&SandboxPlan>. -crates/infrastructure/src/session/process.rs— champSpawnLine.sandbox;run_turnreçoit l'enforcer ; nouveaudrain_sandboxed(thread jetable + std spawn + pre_exec vide + timeout par kill). -crates/infrastructure/src/session/factory.rs— champOption<Arc<dyn SandboxEnforcer>>+with_sandbox_enforcer;startapparie plan+enforcer et les injecte dans les ctors d'adapters. -crates/infrastructure/src/session/claude.rs(build_spawn_line~L187,send~L195) &codex.rs(~L162/L195) — stocker plan+enforcer, remplirSpawnLine.sandbox, passer l'enforcer àrun_turn. -crates/application/src/agent/lifecycle.rs—launch_structured(~L1620) reçoit le plan (spec.sandbox) et le relaie àfactory.start. -crates/app-tauri/src/state.rs:408—StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer()). - Fakes à mettre à jour (nouvelle signaturestart) :crates/domain/tests/structured_session_d0.rs,crates/application/tests/structured_launch_d3.rs,crates/application/tests/orchestrator_service.rs. --- ## Invariants & tests pour la QA 1. Parité avec le PTY (le test pivot) : réplique depty_spawn_enforces_sandbox_plan_end_to_endcôté structuré, avec un fake CLI /shqui émet une ligne JSONL et tente d'écrire hors grant (doit être bloqué kernel) et dans grant (doit réussir). Zéro token (aucun vrai claude/codex). Gardé derrièrelandlock_is_enforced()(skip propre sur kernel sans Landlock). 2. Companion négatif : même factory+enforcer maisspec.sandbox == None⇒ l'écriture hors-grant réussit (prouve que le blocage vient du plan, pas d'une restriction ambiante). 3. Fail-closed : postureDenysur kernel sans Landlock ⇒enforceErr⇒run_turnrenvoie une erreur et aucun child ne tourne (assert : le marqueur de sortie du fake CLI n'existe pas). 4. No-op par défaut :eff == None⇒compile_sandbox_planNone⇒spec.sandbox None⇒ chemin async tokio actuel, comportement natif (régression nulle). Vérifier que la suite structurée existante (conformance, D0/D3) reste verte sans modification de comportement. 5. Confinement de l'irréversibilité : après un tour sandboxé, un autre tour (autre session, plan plus large) sur le même process IdeA n'est PAS affecté par la restriction du tour précédent (le thread jetable est mort). Test : deux tours successifs avec des grants disjoints, chacun voit son propre périmètre. 6. Timeout sous sandbox : un fake CLI qui ne ferme jamais stdout ⇒run_turn(timeout)tue le child et renvoieTimeout, sans laisser de zombie ni de thread bloqué. 7. Resume préservé : leconversation_idest toujours capté correctement après un tour sandboxé (la restriction FS ne casse pas le parsing dusession_id). ## ⚠️ Risque produit à signaler (hors mécanisme, mais bloquant à valider) Les CLI structurées (claude/codex = binaires Node) ont des besoins FS ambiants bien plus lourds qu'unsh: lecture de~/.claude/~/.codex(credentials, cache de session pour le resume),node_modules, libs système, écriture de caches temporaires.compile_sandbox_planne fence en écriture/lecture que les classes explicitement posées et garde les lectures globalement ouvertes — mais si une policy pose unDeny/posture restrictive touchant$HOME, le resume peut casser (la CLI n'écrit plus son cache de session). Recommandation : LP4-4 livre le mécanisme validé au fake CLI (zéro token, points 1-7), et on flag qu'une validation e2e manuelle d'un vrai tour claude/codex sous un plan représentatif doit confirmer que le run dir + le home de la CLI restent atteignables avant d'activer le sandbox en prod pour le chemin structuré. C'est un sujet de composition du plan (run_dir reachability, déjà réservé dansSandboxContext.run_dirmais non consommé par la traduction pure), pas du mécanisme d'enforcement — à traiter en lot suivant si besoin. --- Résumé : (b) avec thread jetable restreint + std spawn +spawn_blocking, fail-closed identique au PTY ;SpawnLine.sandboxajouté ; enforcer injecté par construction dans la factory ; plan passé par-appel viaAgentSessionFactory::start(.., sandbox). (a) rejetée (alloc post-fork). Ne rien coder avant ce GO — QA doit pouvoir s'appuyer sur les 7 invariants ci-dessus. - Prompt: Arbitrage LP4-4 — une divergence est remontée par DevBackend sur ton cadrage, elle touche un invariant crate-wide. Verdict requis. Ton cadrage demandait, dans
drain_sandboxed(chemin structuré), ununsafe { cmd.pre_exec(|| Ok(())) }VIDE pour forcer std sur le chemin déterministe fork+exec (parité avec portable-pty). Blocage : la crateinfrastructureest#. Unforbidne peut pas être relâché par un#[allow]local. Le PTY n'avait jamais eu besoin d'unsafe car il hérite le fork+exec du pre_exec INTERNE de portable-pty ; sur le chemin structuré on pilotestd::processen direct, donc le pre_exec serait à nous → unsafe → interdit. DevBackend a retenu (et livré, build+tests verts) l'option (a) : RETIRER le pre_exec. Son argument :landlock_restrict_selfrestreint le thread courant et toute sa descendance ; l'héritage est assuré par le noyau à travers fork/clone/vfork ET préservé par execve, Y COMPRIS via posix_spawn (= clone+execve), car l'enforcement vit au niveau des credentials de la tâche, hors d'atteinte de l'espace utilisateur. Le fork+exec n'était donc qu'une garantie de DÉTERMINISME du syscall de spawn, pas de sécurité ; enforce()-avant-spawn-sur-le-même-thread suffit. Les deux options sur la table : (a) rester comme livré : zéro unsafe, héritage noyau (choix par défaut de DevBackend). (b) passer lib.rs de#![forbid(unsafe_code)]à#![deny(unsafe_code)]+#[allow(unsafe_code)]scoppé sur drain_sandboxed pour rétablir le pre_exec vide. Question : valides-tu (a) — l'héritage de la restriction Landlock est-il bien garanti par le noyau indépendamment de fork+exec vs posix_spawn, rendant le pre_exec inutile à la sécurité — ou exiges-tu (b) (relâcher l'invariant unsafe-code de la crate) ? Si (a), confirme qu'on garde#![forbid(unsafe_code)]intact. Réponds avec le verdict + justification courte ; c'est juste cet arbitrage, pas une re-revue complète. - Response: # Verdict LP4-4 : (a) validée — on garde
#![forbid(unsafe_code)]intact DevBackend a raison, et son choix est même cohérent avec mon cadrage initial : j'y avais explicitement écrit que l'héritage tient sans le pre_exec (« even without it inheritance holds across posix_spawn's clone »), le pre_exec vide n'étant qualifié que d'« assurance de déterminisme », jamais de garantie de sécurité. Le retirer ne retire donc aucune propriété de sûreté. ## Justification technique (l'héritage est bien garanti par le noyau) - L'enforcement Landlock est attaché aux credentials de la tâche (le domaine Landlock vit dans lacred, sous NO_NEW_PRIVS — querestrict_selfpose lui-même). C'est de l'état noyau, hors d'atteinte de l'espace utilisateur. -landlock_restrict_selfrestreint le thread appelant ; tout enfant créé par ce thread hérite ses credentials → donc le domaine Landlock. L'héritage se fait à traversfork/clone/vforket est préservé parexecve. -posix_spawn(glibc) =clone(CLONE_VM|CLONE_VFORK)+execveémis depuis le thread appelant dans le même process : l'enfant hérite les creds exactement comme via fork+exec. Le mécanisme de spawn ne change que le déterminisme du syscall, pas l'héritage. La seule précondition de sûreté — le syscall de spawn doit être émis par le thread restreint — est respectée par le design :enforce(plan)puisCommand::spawn()sur le même thread jetable. ✔ ## Confirmation empirique décisive Le test e2e de parité (écriture hors-grant bloquée kernel / dans-grant réussie) passe sur le chemin structuré avec le spawn réellement utilisé. C'est la preuve in vivo que l'héritage tient indépendamment de fork+exec vs posix_spawn — exactement l'invariant en débat. Build + suite verts ⇒ rien à rouvrir. ## Arbitrage de l'invariant crate-wide Relâcher#![forbid(unsafe_code)]→deny + #[allow]scoppé pour ne récupérer qu'une garantie de déterminisme de spawn (sans valeur de sécurité) serait un mauvais échange : on dégraderait une protection statique crate-wide pour une commodité nulle côté sûreté. Non. Décision : on reste en (a).crates/infrastructure/src/lib.rs:12#![forbid(unsafe_code)]reste INTACT. Pas de pre_exec, pas d'unsafe. Une seule exigence pour QA, à garder verte en permanence (déjà couverte par le test de parité) : l'invariant « écriture hors-grant bloquée sur le chemin structuré » est le garde-fou qui détecterait toute régression d'héritage si le mécanisme de spawn venait à changer. Tant qu'il est vert, (a) est prouvée.