49 KiB
49 KiB
upTo: 7de50c76-7f1b-439b-8d0d-0f03ec9199ec
objective: IMPLÉMENTATION (code de production uniquement — PAS les nouveaux tests, c'est QA qui les écrit ; mais ne casse aucun test existant). Cadrage validé par l'Architect. Objectif : permettre la délégation
Objectif : IMPLÉMENTATION (code de production uniquement — PAS les nouveaux tests, c'est QA qui les écrit ; mais ne casse aucun test existant). Cadrage validé par l'Architect. Objectif : permettre la délégation
- Response: Lot LP3-5 (composition root) terminé : la projection des permissions est désormais ACTIVE dans l'app.
cargo build --workspaceOK,cargo test --workspace= 1262 passed / 0 failed. Le vitest frontend a 4 échecs PRÉ-EXISTANTS (non causés par ce lot — détail plus bas). OÙ + COMMENT (crates/app-tauri/src/state.rs, dans le builder du composition root) 1) Registre construit une seule fois, juste avantLaunchAgent:rust let permission_projectors = Arc::new( PermissionProjectorRegistry::new() .with(Arc::new(ClaudePermissionProjector) as Arc<dyn domain::PermissionProjector>) .with(Arc::new(CodexPermissionProjector) as Arc<dyn domain::PermissionProjector>), );(projecteurs concrets importés deinfrastructure::{ClaudePermissionProjector, CodexPermissionProjector}; registre importé deapplication::PermissionProjectorRegistry.with(...)keye chaque projecteur par sonProjectorKey— Claude/Codex.) 2) MÊMEArcinjecté dans LES DEUX use cases (source unique de vérité, comme prévu en LP3-4) : -LaunchAgent::new(...).… .with_permission_store(Arc::clone(&permission_store_port)) // déjà présent .with_handoff_provider(…).with_provider_session_provider(…) .with_permission_projectors(Arc::clone(&permission_projectors))// AJOUTÉ -ChangeAgentProfile::new(...).with_structured(…) .with_permission_projectors(Arc::clone(&permission_projectors))// AJOUTÉ Style/patterns identiques aux autres injections (handoff provider, provider session, structured). PERMISSION STORE CÂBLÉ AU LAUNCH — CONFIRMÉFsPermissionStoreétait DÉJÀ instancié (state.rs:511) et injecté dans le LaunchAgent de prod via.with_permission_store(Arc::clone(&permission_store_port))(state.rs:662). Doncresolve_effective_permissionsa bien une source : la projection n'est PAS vide en prod (elle dépend du contenu de.ideai/permissions.json; sans policy posée ⇒eff == None⇒ projection vide = prompting natif conservé, conforme à l'invariant produit). Rien à ajouter de ce côté. Aucun contrat des lots précédents modifié. Le registre LaunchAgent interne deChangeAgentProfile(relance composée) reçoit la projection via leLaunchAgentpartagé ; le nettoyage au swap utilise le registre injecté directement. SORTIES RÉELLES -cargo build --workspace→ Finisheddev… OK (0 erreur, 0 warning). -cargo test --workspace→ TOTAL 1262 passed, 0 failed, 0 ignored. Tous les binaires de test (domain/application/infrastructure/app-tauri) verts, dont change_agent_profile, agent_lifecycle, permission_usecases, permission_store, mcp_server, orchestrator_, dto_. AucunFAILED/panic. -cd frontend && npx vitest run→ Test Files 3 failed | 36 passed (39) ; Tests 4 failed | 349 passed (353). ANALYSE DES 4 ÉCHECS VITEST (NON imputables à LP3-5) Je n'ai touché AUCUN fichier frontend (ce lot est 100 % Rust). Les 4 échecs sont PRÉ-EXISTANTS, hérités du chantier frontend « permissions » en cours dans l'arbre de travail (présents dès le début de ma session) : - fichiers frontend déjà modifiés/non-suivis AVANT mon intervention :M frontend/src/adapters/index.ts|input.ts|mock/index.ts,M frontend/src/domain/index.ts,M frontend/src/ports/index.ts,M features/projects/ProjectsView.tsx,M features/terminals/useWritePortal.ts, et NON-SUIVIS?? frontend/src/adapters/permission.ts,?? frontend/src/features/permissions/. - échecs :mock.test.ts > exposes all thirteen gateways(le mock expose maintenant une gateway permissions de plus ⇒ le compte attendu n'est pas à jour),LayoutGrid.chat.test.tsx(2, routage cellule terminal/chat),agentAlreadyRunning.test.tsx(1, notice R0d). Tous dans des composants/adapters que LP3 ne touche pas et liés au feature permissions UI côté DevFrontend, pas à la projection backend. Conclusion : côté backend (mon périmètre), workspace 100 % vert et projection câblée bout-en-bout. Les 4 rouges vitest relèvent du lot frontend « permissions » en cours (à confier à DevFrontend) et ne sont pas une régression introduite par LP3-5. Si tu veux, je peux signaler précisément à DevFrontend le testexposes all thirteen gatewaysà mettre à jour (probable « fourteen » avec la gateway permissions). - Prompt: Ping de diagnostic. Calcule 5 + 7 et réponds-moi uniquement avec le résultat via idea_reply.
- Response: 12
- Prompt: Ping de diagnostic #2. Calcule 8 + 8 et réponds-moi uniquement avec le résultat via idea_reply.
- Response: 16
- Prompt: [Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong DevBackend OK » pour confirmer que le canal fonctionne.
- Response: pong DevBackend OK
- Prompt: [Chantier permissions — état des lieux] J'ai audité le chantier permissions : domaine (LP0, modèle pur + resolve deny-wins/postures), store FsPermissionStore (LP1), use cases CRUD+resolve (LP2), projecteurs Claude/Codex + câblage launch-path/registry (LP3-3/4), composition root + commandes Tauri (LP3-5), et UI PermissionsPanel (projet + override agent).
cargo test --workspaceet les tests frontend permissions sont 100% verts. Tout est encore non commité. Question : d'après ta connaissance du chantier (ta conversation, ta mémoire, le code), reste-t-il un lot ouvert ou un gap concret à traiter sur les permissions (ex: LP4, edge cases non couverts, projection non testée end-to-end, parité Codex/Claude, sandbox OS réelle, validation/erreurs UI) ? Si oui, liste précisément le PROCHAIN lot à faire avec son périmètre. Si le chantier est complet, dis-le clairement. Réponds via idea_reply. - Response: Bilan permissions — DevBackend Le chantier que tu décris (LP0 domaine → LP3-5 commandes Tauri + UI) correspond à la voie « projection vers la config CLI » et il est bien complet/vert. Mais ce n'est PAS tout le chantier permissions : il reste un lot ouvert, documenté noir sur blanc dans le code lui-même. ## Le gap concret : enforcement OS réel (sandbox), out of scope à dessein Le domaine déclare explicitement que l'application OS-sandbox est hors périmètre du lot livré. Trois modules le marquent : -
domain/src/permission.rs:12— « OS-sandbox application … out of scope here » -domain/src/fileguard.rs:16etinfrastructure/src/fileguard/mod.rs:17— « airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of scope » -application/src/orchestrator/context_guard.rs:23— idem Autrement dit, ce qui est livré configure Claude (settings.local.jsonallow/deny/ask) et Codex (sandbox/approval modes viaconfig.toml) — c'est de la permission advisory, qui dépend du bon vouloir de la CLI. Un agent qui contourne sa CLI (ou unbashdirect) garde un accès FS brut. La couche airtight (révocation réelle des accès) n'existe pas encore. C'est exactement ce que dit la mémoire archi : permissions = sandbox OS (Landlock Linux) + résumé injecté. La moitié « sandbox OS » manque. ## PROCHAIN lot proposé — LP4 : enforcement OS (Landlock Linux d'abord) Périmètre : 1. Port domaineSandboxEnforcer(trait pur) : prendEffectivePermissionsrésolues → un plan d'enforcement OS (rulesets path RO/RW/exec, deny-wins déjà garanti par le domaine). Aucune I/O dans le domaine. 2. Adapter infra Linux : impl Landlock (cratelandlock) appliquée au process agent au spawn (pré-exec), pas à IdeA. Adapters par OS, pas par CLI (conforme àagent-permissions-architecture). Windows/macOS = stubs no-op explicites pour ce lot. 3. Câblage launch-path : appliquer le ruleset au moment du spawn PTY, en cohérence avec la projection déjà écrite (mêmeEffectivePermissions). 4. Résumé injecté : générer un résumé lisible des permissions effectives et l'injecter dans le contexte.mdde l'agent (l'autre moitié du couple « sandbox + résumé » — à confirmer s'il existe déjà ; je n'en ai pas trouvé de générateur dédié). 5. Tests : domaine/app 100 % sans I/O (fakes) ; un test d'intégration Linux réel (sous-process tente un accès hors scope → refusé). ⚠️ Ce lot demande un cadrage Architect d'abord (nouveau port + frontière process/OS) : je ne devine pas le contrat. ## Deux réserves mineures sur le « 100 % vert » - Parité Claude/Codex : OK au niveau projecteurs (testés purs) ; mais vérifier que la projection Codex couvre bien le mapping posture→approval et sandbox (pas juste l'un des deux). - Validation e2e réelle (AppImage) : tous les tests sont app-level avec fakes + store infra. Aucune validation binaire réelle (Claude/Codex lisant vraiment la config écrite). C'est le gap e2e habituel du projet, à faire avant de déclarer la voie projection « prod-ready ». ## Note process Tout est non commité (crates/{domain,application,infrastructure}/…/permission*,frontend/src/features/permissions/). Vu que c'est vert et autonome, un commit de jalon avant d'ouvrir LP4 serait sain — décision sortante qui te revient. Conclusion : le chantier n'est PAS complet. Voie « projection CLI » = livrée/verte ; lot ouvert restant = LP4 enforcement OS (Landlock) + résumé injecté, à cadrer par Architect. - Prompt: [LP4-0 — implémentation domaine pur, enforcement OS] L'Architecte a cadré LP4. Implémente UNIQUEMENT le sous-lot LP4-0 (domaine pur, zéro I/O, zéro dépendance landlock, aucun comportement runtime branché — donc zéro régression). Contrat : 1. Nouveau module
crates/domain/src/sandbox.rs: -SandboxPlan { allowed: Vec<PathGrant>, default_posture: Posture }-PathGrant { abs_root: String, access: PathAccess }oùPathAccess= bitflags Ro|Rw|Exec -SandboxContext<'a> { project_root: &'a str, run_dir: &'a str }-SandboxKind { Landlock, Unsupported },SandboxStatus { Enforced, Unsupported, Degraded(String) },SandboxError(kernel trop vieux en fail-closed, etc.) - traitSandboxEnforcer: Send + Sync { fn kind(&self) -> SandboxKind; fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError>; }(contrat Liskov : enforce appelé uniquement post-fork/pré-exec dans l'enfant ; un adapter Unsupported renvoie Ok(Unsupported) sans rien faire) - fn purecompile_sandbox_plan(eff: Option<&EffectivePermissions>, ctx: &SandboxContext) -> Option<SandboxPlan>. INVARIANT :eff == None ⇒ None(rien posé ⇒ pas de sandbox ⇒ natif). Traduction globs→racines absolues calculée FAIL-CLOSED : un Deny tombant dans un Allow sans frontière de répertoire qui les sépare ⇒ on N'ACCORDE PAS le parent (on perd un allow plutôt que de laisser fuiter un deny). Pose cette sur-/sous-approximation conservatrice comme invariant testable. 2.crates/domain/src/permission.rs: fn purerender_permission_summary(eff: Option<&EffectivePermissions>) -> Option<String>(bloc Markdown ; None⇒None ; doit mentionner explicitement que les règles de COMMANDE (ExecuteBash) restent advisory/non verrouillées OS, alors que les fichiers sont verrouillés quand supporté). 3.crates/domain/src/ports.rs: ajoutesandbox: Option<SandboxPlan>àSpawnSpec(None ⇒ pas d'enforcement). Mets à jour tous les constructeurs/sites existants pour rester compilable. 4. Exporte les nouveaux types danscrates/domain/src/lib.rs. Borne de réalité à respecter : Landlock ne verrouille QUE les fichiers ;ExecuteBash(matching argv) n'est PAS enforceable ⇒ reste advisory. Ne touche PAS au modèle existant ni à resolve ni aux projecteurs LP3. N'implémente PAS d'adapter Landlock ni de câblage lifecycle (c'est LP4-1/LP4-2). Décision produit déjà tranchée pour plus tard : fallback fail-open+warning sauf posture Deny (à encoder côté adapter en LP4-1, pas ici). Assure quecargo build -p domainetcargo test --workspacerestent verts (ajoute juste de quoi compiler ; QA écrira la suite de tests exhaustive). Quand c'est fait, réponds via idea_reply avec : fichiers créés/modifiés, signatures publiques exactes livrées, et confirmation que le workspace compile + tests existants au vert. - Response: LP4-0 livré — domaine pur, zéro I/O, zéro dépendance landlock, zéro câblage runtime.
cargo build -p domainOK,cargo test --workspace100% vert (0 failed), 0 nouveau warning clippy. ## Fichiers Créé -crates/domain/src/sandbox.rs(nouveau module pur) Modifiés -crates/domain/src/permission.rs— ajoutrender_permission_summary-crates/domain/src/ports.rs— champsandboxsurSpawnSpec-crates/domain/src/lib.rs—pub mod sandbox;+ re-exports - 11 sites de construction deSpawnSpecmis à jour (sandbox: None) pour rester compilable :infrastructure/src/runtime/mod.rs(×2),application/src/terminal/usecases.rs,app-tauri/src/state.rs(×2), et les fakes de testinfrastructure/tests/{pty_adapter,orchestrator_watcher,mcp_server}.rs,application/tests/{agent_lifecycle,orchestrator_service,change_agent_profile,structured_launch_d3}.rs. (application/tests/profile_usecases.rs=unreachable!(), rien à changer.) ## Signatures publiques exactescrates/domain/src/sandbox.rs:rust pub struct PathAccess(u8); impl PathAccess { pub const RO: Self; // 0b001 (read) pub const RW: Self; // 0b010 (write/delete) pub const EXEC: Self; // 0b100 (réservé, jamais émis par le compilateur) pub const fn empty() -> Self; pub const fn contains(self, other: Self) -> bool; pub const fn union(self, other: Self) -> Self; pub fn insert(&mut self, other: Self); pub const fn is_empty(self) -> bool; pub const fn bits(self) -> u8; } // + impl BitOr / BitOrAssign pub struct PathGrant { pub abs_root: String, pub access: PathAccess } pub struct SandboxPlan { pub allowed: Vec<PathGrant>, pub default_posture: Posture } pub struct SandboxContext<'a> { pub project_root: &'a str, pub run_dir: &'a str } pub enum SandboxKind { Landlock, Unsupported } pub enum SandboxStatus { Enforced, Unsupported, Degraded(String) } pub enum SandboxError { KernelTooOld(String), Ruleset(String) } // thiserror pub trait SandboxEnforcer: Send + Sync { fn kind(&self) -> SandboxKind; fn enforce(&self, plan: &SandboxPlan) -> Result<SandboxStatus, SandboxError>; } pub fn compile_sandbox_plan( eff: Option<&EffectivePermissions>, ctx: &SandboxContext, ) -> Option<SandboxPlan>;crates/domain/src/permission.rs:rust pub fn render_permission_summary(eff: Option<&EffectivePermissions>) -> Option<String>;crates/domain/src/ports.rs:rust pub struct SpawnSpec { /* … champs existants … */ pub sandbox: Option<crate::sandbox::SandboxPlan>, // None ⇒ pas d'enforcement }Re-exportslib.rs:compile_sandbox_plan, PathAccess, PathGrant, SandboxContext, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus+render_permission_summary. ## Invariants encodés (à exploiter par QA) 1.eff == None ⇒ compile renvoie None(rien posé ⇒ natif).Somemême avecallowedvide = il y a une politique. 2. Bash ignoré : seules les 3 capabilities fichier (Read→RO, Write/Delete→RW) produisent des grants.ExecuteBashn'est jamais traduit (Landlock ne verrouille pas l'exec) — borne de réalité respectée. 3. Fail-closed (testable) : chaque glob Allow → son préfixe statique (chemin littéral avant le 1er métacaractère* ? [, slash trimé). Le grant est abandonné si un Deny a un préfixe statique qui chevauche (égal, ou ancêtre/descendant de chemin) — un sandbox additif ne peut pas découper un deny sous-arbre. On perd l'allow plutôt que de laisser fuiter le deny. Cas notable : Allow**(racine projet) est abandonné dès qu'un seul Deny fichier existe. 4.render_permission_summary: bloc Markdown ; mentionne explicitement que les fichiers sont OS-enforced when supported (Landlock) et que les commandes restent advisory, NOT OS-locked. ## Non fait (volontairement, hors périmètre LP4-0) - Aucun adapter Landlock, aucunSandboxEnforcerconcret (LP4-1). - Aucun câblage lifecycle/launch-path :SpawnSpec.sandboxest posé mais jamais lu ⇒ zéro changement de comportement runtime, zéro régression. - Fallback fail-open+warning (sauf posture Deny) : laissé à encoder côté adapter en LP4-1. - Modèle existant,resolve, projecteurs LP3 : non touchés.compile_sandbox_planne consomme pasctx.run_dir(réservé à l'adapter : le run dir doit rester atteignable) — c'est documenté dans le code, pas un oubli. Prêt pour la suite de tests exhaustive de QA, puis LP4-1 (adapter Landlock) après cadrage Architect. - Prompt: [LP4-0 — affinage sémantique des barrières Deny, AVANT commit] Décision d'orchestration sur le point remonté par QA : les barrières Deny doivent être PAR CLASSE D'ACCÈS, pas capability-indépendantes. Raison : autonomie maximale des agents — un Deny Write ne doit pas amputer un Allow Read (sinon on sur-restreint et l'agent se bloque/redemande, contraire à l'objectif produit). Refactore
compile_sandbox_plandanscrates/domain/src/sandbox.rsainsi : 1. Sépare les deux classes d'accès : - Classe RO : alimentée par les AllowRead; barrières = les DenyRead. - Classe RW : alimentée par les AllowWriteETDelete; barrières = les DenyWriteETDelete. (ExecuteBash : toujours ignoré, aucune classe.) 2. Pour CHAQUE classe indépendamment : réduis chaque Allow à son préfixe statique, puis abandonne (fail-closed) le grant SI une barrière de LA MÊME CLASSE a un préfixe statique chevauchant (égal, ancêtre OU descendant). Un Deny d'une autre classe n'a AUCUN effet sur cette classe. 3. Fusionne les racines survivantes enPathGrantparabs_root, en unionnant l'accès : une racine survivant en RO seul ⇒PathAccess::RO; survivant aussi en RW ⇒RO | RW; survivant en RW seul ⇒RW. 4. Invarianteff == None ⇒ Noneet « politique posée ⇒ Some même si allowed vide » : inchangés. Exemple cible à garantir : Allow Readsrc/**+ Deny Writesrc/secret/**(aucun Deny Read) ⇒ le grantsrcCONSERVE le RO (le write-deny ne touche pas la classe RO ; il n'y a pas de write-allow donc pas de RW). Avant ton refactor, ce cas perdait tout le grant — c'est précisément ce qu'on corrige. Mets à jour la doc du module/fonction pour décrire la sémantique par classe (et que c'est le deny-wins par capability+cible, fail-closed à l'intérieur d'une classe). Ne touche à rien d'autre (pas de Landlock, pas de lifecycle). Assurecargo build -p domainOK etcargo test --workspacetoujours vert (certains tests QA existants supposaient l'ancienne sémantique capability-indépendante : signale lesquels deviennent obsolètes pour que QA les réécrive — ne les supprime pas toi-même si ce sont des tests QA, mais liste-les). Réponds via idea_reply avec : diff conceptuel, tests existants impactés à réécrire, et état build. - Response: LP4-0 affiné — barrières Deny par classe d'accès.
cargo build -p domainOK,cargo test --workspacevert (0 failed), 0 warning clippy sur sandbox.rs. Rien d'autre touché (pas de Landlock, pas de lifecycle). ## Diff conceptuel Avant (capability-blind) : on collectait UN seul jeu de barrières = les préfixes statiques de tous les Deny fichier, et tout Allow fichier dont le préfixe chevauchait était abandonné — peu importe la capability. ⇒ unDeny Writeamputait unAllow Read(sur-restriction). Après (par classe) : deux jeux de barrières indépendants. - Classe RO : alimentée parAllow Read; barrières =Deny Readuniquement. - Classe RW : alimentée parAllow WriteETAllow Delete; barrières =Deny WriteETDeny Delete. -ExecuteBash: toujours sans classe, jamais de grant ni de barrière. Pour chaque classe indépendamment : Allow → préfixe statique, abandonné (fail-closed) ssi une barrière de la même classe chevauche (égal / ancêtre / descendant). Une barrière d'une autre classe n'a aucun effet. Puis fusion des racines survivantes parabs_rooten unionnant l'accès : RO seul ⇒RO; RO+RW ⇒RO|RW; RW seul ⇒RW. Sémantiquement : c'est le deny-wins parcapability+cible depermission.rs, transposé à la granularité répertoire de Landlock, fail-closed à l'intérieur de chaque classe. Cas cible garanti (vérifié) :Allow Read src/**+Deny Write src/secret/**(aucun Deny Read) ⇒ grantsrcCONSERVE le RO (pas de RW car pas de Write-allow). Avant : tout le grant était perdu. Implémentation : helpercapability_accessremplacé paraccess_class(cap) -> Option<PathAccess>(None pour ExecuteBash) ; deux Vec de barrièresro_fences/rw_fences; sélection de la classe au moment de l'Allow. Invariantseff==None ⇒ Noneet « politique posée ⇒ Some même si allowed vide » : inchangés. Doc module + doc fonction réécrites pour décrire la sémantique par classe. ## Tests QA existants impactés (danscrates/domain/src/sandbox.rs, mod tests — écrits par QA) 3 tests obsolètes (ils encodaient la sémantique capability-blind via unDeny Writequi faisait tomber unAllow Read). Je ne les ai pas supprimés ; je les ai marqués#[ignore = "OBSOLETE … ticket 2db80d41 … QA to rewrite"]pour garder le workspace vert tout en les laissant compiler et visibles. À réécrire par QA avec une barrière de même classe (Deny Read) : 1.root_glob_with_single_file_deny_drops_root_grant—Allow Read **+Deny Write secret.txt; attendaitallowedvide. Désormais le RO root survit. 2.descendant_deny_drops_overlapping_allow—Allow Read src/**+Deny Write src/secret/**; attendait grantsrcabsent. C'est précisément le cas qu'on corrige → le RO sursrcest conservé. 3.ancestor_deny_also_drops_allow—Allow Read src/sub/**+Deny Write src/**; attendait grantsrc/subabsent. Désormais conservé. 2 tests encore verts mais sémantiquement affaiblis (ils passent toujours, mais leur assertion « gardé » n'est plus due à la disjointness puisque la barrière est d'une autre classe — à renforcer côté QA avec une barrière même-classe pour rester probants) : -disjoint_deny_keeps_allow,sibling_prefix_is_not_a_descendant. Les 6 autres tests sandbox (mapping accès, bash-only, union sur racine partagée, préfixe statique d'un fichier littéral, posture résiduelle, normalisation trailing slash) restent valides et verts. ## État build -cargo build -p domain: OK. -cargo test --workspace: 0 failed, 0 binaire en échec. Module sandbox : 11 passed / 0 failed / 3 ignored. - clippy domain : 0 nouveau warning (les 2 préexistants sont dansfileguard.rs). Prêt pour le commit une fois que QA aura réécrit les 3 tests ignorés sous la nouvelle sémantique (et idéalement renforcé les 2 affaiblis). - Prompt: [LP4-1 — adapter Landlock + hook pre_exec, infra Linux] On enchaîne le chantier permissions/enforcement OS. LP4-0 (domaine pur) est commité (
b05d04a) : portSandboxEnforcer,compile_sandbox_plan,SpawnSpec.sandbox: Option<SandboxPlan>(posé mais jamais lu pour l'instant). Implémente LP4-1 selon le cadrage Architecte. Périmètre LP4-1 (adapters OS + mécanisme pre_exec ; PAS le câblage application step 5d ni la composition root — ce sont LP4-2/LP4-3) : 1. Nouveau modulecrates/infrastructure/src/sandbox/{mod.rs, landlock.rs, noop.rs}: -LandlockSandbox(#[cfg(target_os="linux")]) implSandboxEnforcer: traduitSandboxPlan→ ruleset Landlock via la cratelandlock(ajoute-la au Cargo.toml d'infrastructure, en best-effort/compat ABI).enforce(&self, plan)crée le ruleset, ajoute chaquePathGrantcommepath_beneathavec les access rights correspondant àPathAccess(RO ⇒ lecture ; RW ⇒ lecture+écriture+création+suppression ; EXEC ⇒ exec — mais le domaine n'émet jamais EXEC pour l'instant), puis restreint le THREAD courant (appel destiné à l'enfant post-fork). RenvoieSandboxStatus::Enforced;Degraded(reason)si compat ABI réduit la couverture ; et applique la politique fallback : kernel/ABI sans Landlock ⇒SandboxStatus::UnsupportedSAUF siplan.default_posture == Posture::Denyoù il faut renvoyerErr(SandboxError::KernelTooOld(...))(fail-closed seulement en posture Deny ; fail-open+warning sinon). -NoopSandbox(autres OS / fallback) implSandboxEnforcer:kind()==Unsupported,enforcerenvoie toujoursOk(SandboxStatus::Unsupported), jamais d'erreur. Doit compiler sur toutes plateformes. -mod.rs: sélection cfg-dépendante d'un constructeurdefault_enforcer() -> Arc<dyn SandboxEnforcer>(Landlock sur Linux, Noop ailleurs). Exporte dansinfrastructure/src/lib.rs. 2. Hookpre_execdans l'adapter PTY (portable-pty) : lePortablePtyAdapterreçoit unOption<Arc<dyn SandboxEnforcer>>(nouveau champ + builder additif, défautNone⇒ aucun sandboxing, signaturenewinchangée). Dansspawn, sispec.sandbox.is_some()ET un enforcer est présent, installe viaCommandBuilder/pre_exec(unsafe, Unix only) un hook qui, dans l'enfant post-fork pré-exec, appelleenforcer.enforce(plan); surErr⇒ faire échouer le spawn (l'enfant n'exec pas) ; surOk(Unsupported/Degraded)⇒ continuer (log/warning best-effort). ⚠️ Respecte les contraintes async-signal-safety du pre_exec (pas d'alloc/lock hasardeux ; clone le plan en amont). Siportable-ptyne permet pas un pre_exec arbitraire, signale-le clairement et propose l'alternative la plus propre plutôt que de bricoler. 3. Aucun changement de comportement runtime tant que LP4-2/LP4-3 ne câblent pas : l'enforcer resteNonepartout,spec.sandboxresteNone. ⇒ zéro régression. Tests : ajoute au moins un test d'intégration Linux réel pourLandlockSandbox(un sous-process lancé sous un plan qui interdit l'écriture d'un tmp path ⇒ l'écriture échoue EACCES, et un path autorisé réussit), gated#[cfg(target_os="linux")]+ garde runtime de disponibilité Landlock (skip si indispo, façon tests SSH/WSL). TestNoopSandbox⇒ Unsupported jamais Err. (QA fera la passe d'intégration exhaustive ensuite.) Assurecargo build --workspaceetcargo test --workspaceverts. Réponds via idea_reply avec : fichiers créés/modifiés, version de la cratelandlockajoutée, faisabilité réelle du pre_exec avec portable-pty (oui/non + comment), résultat des tests (et si le test Landlock réel a pu tourner sur ce kernel ou a été skippé), et confirmation zéro régression. - Response: LP4-1 livré — adapters OS Landlock/Noop + mécanisme d'enforcement.
cargo build --workspaceOK,cargo test --workspacevert (0 failed, 0 ignored), 0 nouveau warning clippy. Zéro régression (enforcer None + spec.sandbox None partout ⇒ chemin dormant). ## Fichiers Créés -crates/infrastructure/src/sandbox/mod.rs— sélection cfg +default_enforcer() -> Arc<dyn SandboxEnforcer>+ tests Noop/default -crates/infrastructure/src/sandbox/landlock.rs—LandlockSandbox(#[cfg(target_os="linux")]) + 2 tests d'intégration réels -crates/infrastructure/src/sandbox/noop.rs—NoopSandbox(toutes plateformes) Modifiés -crates/infrastructure/Cargo.toml— dép Linux-onlylandlock = "0.4.5"sous[target.'cfg(target_os = "linux")'.dependencies]-crates/infrastructure/src/lib.rs—pub mod sandbox;+ exportsdefault_enforcer,NoopSandbox,LandlockSandbox(cfg linux) -crates/infrastructure/src/pty/mod.rs— champsandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>+ builder additifwith_sandbox_enforcer(new()inchangé) + chemin de spawn sandboxé ## Version cratelandlock = "0.4.5"(dernière ; ABI jusqu'à V7, on cible V1 +CompatLevel::BestEffort). Pull une seule dép transitveive légère (enumflags2). Linux-only ⇒ Windows/macOS ne compilent rien de Landlock (chemin Noop). ## Faisabilité pre_exec avec portable-pty : NON portable-pty 0.9 n'expose aucun hook pre_exec utilisateur :CommandBuildern'a pas de méthode pre_exec, etSlavePty::spawn_commandinstalle son propre pre_exec interne (setsid / controlling-tty,unix.rs:238) sans point d'extension, en consommant son propreCommandBuilder(pas unstd::process::Commandaccessible). Impossible d'injecterenforce()dans le pre_exec de l'enfant directement. Alternative choisie (propre, pas de bricolage, pas de fork de la crate, pas de binaire helper) : on exploite une propriété kernel garantie — un domaine Landlock est hérité auforket préservé à l'execve. Doncspawn_command_sandboxed: 1. lance un thread jetable dédié, y appelleenforcer.enforce(plan)(restreint CE thread, irréversible mais le thread meurt ensuite — les autres threads d'IdeA restent intacts) ; 2. appelleslave.spawn_command(cmd)depuis ce thread :std::process::Commandfork depuis le thread appelant, et comme portable-pty pose toujours un pre_exec, std est forcé sur le cheminfork+exec(jamaisposix_spawn) ⇒ l'enfant hérite du domaine restreint ; 3. surErr(enforce)(fail-closed) ⇒ le spawn échoue, aucun enfant ne tourne ; surOk(Unsupported/Degraded)⇒ on continue (best-effort). Tout est déplacé (move) dans le thread ⇒ aucune borneSyncrequise du slave ; leChild(Send) revient à l'appelant. (Le câblage de l'enforcer dans la composition root et la pose despec.sandboxrestent LP4-2/LP4-3 — ici le chemin est prêt mais dormant.) ## Sémantique LandlockSandbox (faithful + per-class, miroir de LP4-0) - On handle une classe de droits seulement si le plan pose un grant de cette classe : RO présent ⇒ handlefrom_read; RW présent ⇒ handlefrom_write. ⇒ un plan write-only laisse les lectures globalement libres (libc//etclisibles) et ne clôture que les écritures — exactement l'autonomie per-class. Corollaire documenté : un plan qui restreint les lectures gouverne toutes les lectures ⇒ le plan compilé devra inclure les chemins système (concern LP4-2, pas l'adapter). - RO⇒from_read, RW⇒from_read|from_writesur la racine ; intersection avec le handled set. -restrict_self⇒ FullyEnforced→Enforced; PartiallyEnforced→Degraded(reason); NotEnforced→fallback :UnsupportedSAUF postureDeny⇒Err(KernelTooOld)(fail-closed). Plan vide/bash-only ⇒Enforced(rien à poser). ## Tests -landlock_write_only_plan_fences_writes_to_the_grant(#[cfg(target_os="linux")], garde runtime : skip sistatus==Unsupported) : A TOURNÉ ET PASSÉ sur ce kernel (pas skippé). Écriture dans la racine RW accordée ⇒ OK ; écriture hors racine ⇒ EACCES (PermissionDenied). Enforcement réel validé. -empty_plan_is_a_noop_enforced: plan vide ⇒Enforced, pas d'erreur (sur thread jetable). -noop_enforcer_is_unsupported_and_never_errors:kind()==Unsupported,enforce(même posture Deny) ⇒Ok(Unsupported), jamais Err. -default_enforcer_matches_the_build_target: Linux ⇒ Landlock. - Workspace :cargo build --workspaceOK ;cargo test --workspace0 failed, 0 ignored (les 3 tests LP4-0 jadis ignorés ont été réécrits par QA dansb05d04aet sont verts). ## Zéro régressionPortablePtyAdapter::new(): signature inchangée,sandbox_enforcer = None. Chemin sandboxé pris uniquement sispec.sandbox.is_some() && enforcer.is_some()— jamais le cas tant que LP4-2/LP4-3 ne câblent pas. Tous les tests existants verts. Reste pour LP4-2/LP4-3 : compilation du plan au launch (incl. chemins système read si RO), pose despec.sandbox, injection dedefault_enforcer()dansPortablePtyAdapterà la composition root, et résumé permissions injecté. - Prompt: [LP4-1 — durcissement : rendre testable la branche fail-closed posture Deny] QA a validé LP4-1 (vert, propriété de sûreté n°1 OK). Une seule branche reste non couverte :
(RulesetStatus::NotEnforced, Posture::Deny) ⇒ Err(SandboxError::KernelTooOld), intestable sur un kernel Landlock-capable carrestrict_selfne renvoie jamais NotEnforced ici. C'est la branche sécurité-critique (fail-closed) — il faut la couvrir. Petit refactor sans changement de comportement : danscrates/infrastructure/src/sandbox/landlock.rs, extrais le mapping final en une fonction PURE, p.ex.fn status_from_ruleset(status: RulesetStatus, posture: Posture) -> Result<SandboxStatus, SandboxError>(ou un enum miroir local si tu préfères ne pas dépendre du type de la crate dans la signature — choisis le plus propre/testable sans rien mocker).enforce()appelle cette fn aprèsrestrict_self. Mapping inchangé : - FullyEnforced ⇒ Ok(Enforced) - PartiallyEnforced ⇒ Ok(Degraded(reason)) - NotEnforced + posture==Deny ⇒ Err(KernelTooOld) - NotEnforced + posture!=Deny ⇒ Ok(Unsupported) Ajoute un test en table couvrant les 4 (×3 postures pertinentes) — notamment les deux cas NotEnforced qui prouvent le fail-closed seulement-en-Deny. Garde tout le reste identique.cargo test -p infrastructure+cargo test --workspaceverts. Réponds via idea_reply avec le diff conceptuel, le nom/signature de la fn extraite, et la sortie de test. - Prompt: Reprise du chantier permissions — lot LP4-3 (câblage bout-en-bout de l'enforcement OS). LP4-0/LP4-1/LP4-2 sont faits, committés (dernier commit
17ca65e) et verts. Il reste à ACTIVER la sandbox au runtime ; aujourd'hui tout est construit mais inactif (rien ne peuple le plan, rien n'injecte l'enforcer). Périmètre précis : 1) Composition root — injecter l'enforcer dans le PTY. -crates/app-tauri/src/state.rs:396etcrates/infrastructure/src/remote/mod.rs:41construisentPortablePtyAdapter::new(). - Remplacer parPortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer())(le builder additif existe déjà, le default_enforcer() choisit Landlock sur Linux / Noop ailleurs). 2) Launch path — peuplerSpawnSpec.sandbox. -crates/infrastructure/src/runtime/mod.rs:74et:217posentsandbox: Noneen dur. - Au lancement d'un agent, résoudre sa policy : chargerProjectPermissions(store.ideai/permissions.json— adaptateur déjà en place côté infra/store),resolve_for(agent_id)→EffectivePermissions, puisdomain::sandbox::compile_sandbox_plan(...)pour obtenir leSandboxPlan, et le passer dansSpawnSpec.sandbox(Some). Si la policy résout à None (rien posé), gardersandbox: None(invariant produit : on ne projette rien, comportement natif conservé). - Vérifie le branchement des dépendances (le runtime doit pouvoir lire le store de permissions ; suis le même chemin d'injection que les autres adaptateurs du runtime). Si une dépendance manque dans la construction du runtime, fais remonter clairement ce qui bloque plutôt que de bricoler. 3) Respecte strictement l'archi hexagonale : la résolution policy→plan est PURE (domaine), le runtime ne fait qu'orchestrer lecture store + appel pur + remplissage SpawnSpec. À la fin :cargo build+cargo test -p domain -p infrastructure(et le crate app-tauri si touché) doivent être verts. NE committe PAS toi-même (action figée par l'orchestrateur). Rends via idea_reply un rapport contenant : fichiers modifiés, comment le plan est résolu/injecté, blocages éventuels, et la sortie réelle des tests. Si quelque chose ne passe pas, dis-le avec la sortie brute — on enchaînera avec QA pour le test bout-en-bout (agent lancé avec Deny sur un chemin ne peut pas y écrire). - Response: LP4-3 — câblage bout-en-bout de l'enforcement OS : FAIT, build + tests verts. NON committé (action figée par l'orchestrateur). ## Fichiers modifiés (3) 1. crates/app-tauri/src/state.rs (~396) — composition root :
PortablePtyAdapter::new()→PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer()). 2. crates/infrastructure/src/remote/mod.rs (~41) —LocalHost::new(): même injection viacrate::sandbox::default_enforcer()(chemin remote/local transparent). 3. crates/application/src/agent/lifecycle.rs — launch path (LaunchAgent::execute) : - ajout importuse domain::sandbox::{compile_sandbox_plan, SandboxContext};- nouvelle étape 5d insérée APRÈS la résolution des permissions (étape 3,resolve_effective_permissions→Option<EffectivePermissions>) et la projection advisory LP3 (étape 5c), AVANT le split structuré/PTY :rust spec.sandbox = compile_sandbox_plan( effective_permissions.as_ref(), &SandboxContext { project_root: input.project.root.as_str(), run_dir: run_dir.as_str(), }, );## Comment le plan est résolu / injecté - Résolution policy → plan (PURE, domaine). Le store.ideai/permissions.jsonest déjà lu en étape 3 parresolve_effective_permissions(qui faitstore.load_permissions(project)puisdoc.resolve_for(agent_id)→Option<EffectivePermissions>). La dépendanceOption<Arc<dyn PermissionStore>>était DÉJÀ branchée dansLaunchAgent(viawith_permission_store, utilisée par LP3) — rien à recâbler côté construction du runtime. Je réutilise donc cetteEffectivePermissionsau lieu de re-lire le store. - L'étape 5d appelle simplement la fonction puredomain::sandbox::compile_sandbox_plan(eff, ctx)et stocke le résultat dansspec.sandbox. Zéro I/O dans cette étape : le launch path ne fait qu'orchestrer (lecture store déjà faite → appel pur → remplissage SpawnSpec). Hexagonal respecté. - Invariant produit conservé :eff == None(rien posé) ⇒compile_sandbox_planrenvoieNone⇒spec.sandbox = None⇒ comportement natif, aucune projection OS. Lesandbox: Nonecodé en dur dansruntime/mod.rs(detection_specL80,prepare_invocationL224) reste le défaut neutre de la fonction pureprepare_invocation(qui n'a pas accès aux permissions, par design) ; il est désormais écrasé en aval par l'étape 5d quand une policy existe. La détection (detection_spec) reste volontairement non sandboxée (simple probe--version). - Injection de l'enforcer :default_enforcer()(Landlock sur Linux / Noop ailleurs) est injecté dans lePortablePtyAdapterau composition root. Au spawn,PortablePtyAdapter::spawnn'applique le plan que si(spec.sandbox = Some, enforcer = Some)— sinon spawn nominal. Le branchement bout-en-bout est donc complet pour le chemin PTY. ## Point d'attention (non bloquant, à noter pour QA) L'étape 5d remplitspec.sandboxAVANT le split structuré/PTY, donc le plan est porté dans les deux cas. Mais l'enforcement effectif vit dansPortablePtyAdapter(chemin PTY brut). Le chemin structuré (launch_structured→factory.start, pas depty.spawn) ne consomme pas encorespec.sandbox: un agent structuré (Claude/Codex en mode JSON) n'est PAS OS-sandboxé pour l'instant — il garde sa projection advisory LP3. Le test e2e « Deny sur un chemin ⇒ écriture impossible » doit donc viser un agent en mode terminal/PTY (profil sans structured_adapter) pour valider Landlock. Sandboxer le chemin structuré serait un lot ultérieur (l'AgentSession infra devrait enforcer dans son propre fork/exec). ## Sortie réelle des testscargo build: Finished, OK.cargo test -p domain -p infrastructure -p application -p app-tauri: tous les binaires de test verts, 0 failed partout. Suites notables : - domain :141 passed; 0 failed(lib, dont les 18 testscompile_sandbox_plande sandbox.rs) - infrastructure :154 passed; 0 failed(lib, dont sandbox/noop + permission store) + suites d'intégration (permission_store, etc.) toutes à 0 failed - application :72 passed; 0 failed(lib) +agent_lifecycle,change_agent_profile,permission_usecasestoutes vertes - app-tauri :47 passed; 0 failedAucun warning nouveau introduit. Prêt pour QA (test bout-en-bout Landlock sur agent PTY avec Deny). - Prompt: Lot LP4-4 — implémenter l'enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON), aujourd'hui non sandboxé. L'Architecte a validé l'approche (GO). Implémente exactement ce découpage ; ne réinvente pas la stratégie. STRATÉGIE VALIDÉE (approche b) : transposer la technique du PTY (
spawn_command_sandboxeddanscrates/infrastructure/src/pty/mod.rs). Lepre_exec(enforce)est INTERDIT (landlock alloue → deadlock malloc post-fork en process multithreadé). À la place :enforce(plan)sur un THREAD JETABLE AVANT le fork, puis spawnstd::processsynchrone depuis ce thread (l'enfant hérite le domaine Landlock via fork+exec), réconcilié à l'async parspawn_blocking. Le chemin non-sandboxé (sandbox==None OU pas d'enforcer, et tout non-Linux) reste ledrainasync tokio ACTUEL strictement inchangé (zéro régression). CONTRAT À MODIFIER : 1.crates/domain/src/ports.rs(~537) —AgentSessionFactory::start: ajouter paramsandbox: Option<&SandboxPlan>(SandboxPlan est domaine, franchit déjà le port via SpawnSpec.sandbox — cohérent). 2.crates/infrastructure/src/session/process.rs— ajouterpub sandbox: Option<SandboxPlan>àSpawnLine;run_turnreçoitenforcer: Option<&Arc<dyn SandboxEnforcer>>; nouveaudrain_sandboxed: thread jetable →enforcer.enforce(&plan)(fail-closed : Err ⇒ échec du tour, AUCUN child ne tourne) →std::process::Command::spawn→ poser ununsafe { cmd.pre_exec(|| Ok(())) }VIDE (async-signal-safe) pour forcer le chemin fork+exec déterministe → drain bloquant stdin/stdout→EOF→wait → Vec. Le thread meurt avec sa restriction. TIMEOUT sous sandbox : le thread renvoie son killer (Arc<Mutex> ou pid) via un oneshot juste après spawn ;tokio::time::timeoutsur le JoinHandle ; à expiration kill le child → EOF → le thread finit → renvoyer Timeout (pas de zombie/thread bloqué). 3.crates/infrastructure/src/session/factory.rs—StructuredSessionFactorygagneOption<Arc<dyn SandboxEnforcer>>+ builderwith_sandbox_enforcer(...)(jumeau exact de PortablePtyAdapter::with_sandbox_enforcer) ;startapparie plan (par-appel) + enforcer (par-instance) et les injecte dansClaudeSdkSession::new/CodexExecSession::new. 4.crates/infrastructure/src/session/claude.rs(build_spawn_line ~187, send ~195) &codex.rs(~162/195) — stocker plan+enforcer, remplirSpawnLine.sandbox, passer l'enforcer àrun_turn. 5.crates/application/src/agent/lifecycle.rs—launch_structured(~1620) reçoit le plan (spec.sandbox, déjà calculé en step 5d) et le relaie àfactory.start. 6.crates/app-tauri/src/state.rs(~408) —StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer()). 7. FAKES à mettre à jour (nouvelle signature start) :crates/domain/tests/structured_session_d0.rs,crates/application/tests/structured_launch_d3.rs,crates/application/tests/orchestrator_service.rs. INVARIANT PRODUIT : eff==None ⇒ plan None ⇒ SpawnLine.sandbox None ⇒ drain async tokio inchangé, comportement natif. Respecte l'hexagonal (la résolution policy→plan reste pure dans le domaine ; l'infra ne fait qu'orchestrer + enforcer). À la fin :cargo build+cargo test -p domain -p infrastructure -p application -p app-tauriverts. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichiers modifiés, comment le timeout sous sandbox est géré, et la sortie RÉELLE des tests. Si quelque chose bloque ou ne passe pas, rapport clair avec sortie brute — je renverrai à l'Architecte ou on bouclera. Les tests d'enforcement e2e du chemin structuré seront écrits ensuite par QA (les 7 invariants), tu n'as pas à les écrire, mais assure-toi que les suites existantes restent vertes. - Response: LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ : FAIT, build + tests verts (80 suites, 0 failed). NON committé (figé par l'orchestrateur). ⚠️ UNE DIVERGENCE ASSUMÉE vs le cadrage (le
pre_execvide), détaillée plus bas — à valider par l'Architecte. Tout le reste est conforme au découpage. ## Fichiers modifiés (12) Production (6) : 1.crates/domain/src/ports.rs—AgentSessionFactory::startgagnesandbox: Option<&crate::sandbox::SandboxPlan>(dernier param). 2.crates/infrastructure/src/session/process.rs—SpawnLine.sandbox: Option<SandboxPlan>;run_turn(spec, timeout, enforcer: Option<&Arc<dyn SandboxEnforcer>>); nouveaurun_turn_sandboxed(thread jetable + 2 oneshot killer/résultat +tokio::time::timeout) etdrain_sandboxed(drain bloquant std), tous deux#[cfg(target_os = "linux")]. 3.crates/infrastructure/src/session/factory.rs—StructuredSessionFactoryporteOption<Arc<dyn SandboxEnforcer>>+ builderwith_sandbox_enforcer(...)(jumeau dePortablePtyAdapter) ;startapparie plan (param, par-appel) + enforcer (champ, par-instance) →ClaudeSdkSession::new/CodexExecSession::new. (N'est plusCopy/Debugcar porte unArc<dyn …>;new()n'est plusconst.) 4.crates/infrastructure/src/session/claude.rs— stockesandbox+sandbox_enforcer, remplitSpawnLine.sandbox, passe l'enforcer àrun_turn. 5.crates/infrastructure/src/session/codex.rs— idem Claude. 6.crates/application/src/agent/lifecycle.rs—launch_structuredreçoitsandbox: Option<&SandboxPlan>et le relaie àfactory.start; le call site passespec.sandbox.as_ref()(déjà calculé en step 5d de LP4-3). 7.crates/app-tauri/src/state.rs—StructuredSessionFactory::new().with_sandbox_enforcer(infrastructure::default_enforcer()). Fakes/tests adaptés à la nouvelle signature (5) :domain/tests/structured_session_d0.rs,application/tests/structured_launch_d3.rs,application/tests/orchestrator_service.rs(les 3 fakesstart+ leurs call sites), plusinfrastructure/src/session/{mod.rs,conformance.rs}(SpawnLine literals + tous lesrun_turn/ClaudeSdkSession::new/CodexExecSession::newinternes). ## Gestion du timeout sous sandbox Le thread jetable n'est pas annulable de l'extérieur. Deux canauxtokio::sync::oneshotle réconcilient avec l'async : - killer : juste après le spawn, le thread envoie unArc<Mutex<std::process::Child>>; il a au préalable sortistdoutdu child (.take()) pour drainer SANS tenir le lock — donc le killer peut verrouiller à tout instant. - résultat : le thread envoieResult<Vec<String>, _>à la fin.tokio::time::timeout(dur, done_rx): - fini dans les temps ⇒ on rend le résultat ; - expiration ⇒ onkiller_rx.awaitpuischild.lock().kill()→ l'enfant meurt →stdoutEOF → la boucle de drain du thread sort →child.wait()(reap, pas de zombie) → le thread se termine. On rendAgentSessionError::Timeout. LeJoinHandleest détaché (pas de thread bloqué).done_txlâché sans valeur (panique) ⇒Io.enforceest fail-closed :Err⇒AgentSessionError::StartAVANT tout spawn (aucun enfant ne tourne). ## ⚠️ Divergence : pas depre_execvide (à arbitrer Architecte) Le cadrage demandaitunsafe { cmd.pre_exec(|| Ok(())) }pour forcer std sur le chemin déterministefork+exec. Impossible ici : la crateinfrastructureest# — unforbidne peut PAS être relâché localement par#[allow]. Le PTY n'avait jamais eu besoin d'unsafe: il obtientfork+execgratuitement via lepre_execinterne de portable-pty. Sur le chemin structuré on pilotestd::processen direct, donc lepre_execserait à nous →unsafe→ interdit. J'ai donc retiré lepre_execsans perte de garantie de sécurité :landlock_restrict_selfrestreint le thread courant et toute sa descendance, héritage assuré par le noyau à traversfork/clone/vforkET préservé parexecve— y compris viaposix_spawn(=clone+execvesous le capot), car l'enforcement vit au niveau des credentials de la tâche, hors d'atteinte de l'espace utilisateur. Lefork+execn'était une garantie que de déterminisme, pas de sécurité ; l'enforce()-avant-spawn-sur-le-même-thread suffit. La technique validée (thread jetable, enforce avant spawn, fail-closed, reap) est intacte. Deux options pour l'Architecte s'il veut lefork+execstrict du cadrage : (a) rester comme livré (zéro unsafe, héritage noyau — mon choix par défaut) ; (b) passer lib.rs en#![deny(unsafe_code)]+#[allow(unsafe_code)]scoppé surdrain_sandboxedpour rétablir lepre_exec. J'attends son verdict avant de toucher l'invariant crate-wide. ## Invariant produit conservéeff==None⇒ planNone⇒SpawnLine.sandbox==None⇒run_turnemprunte le drain async tokio inchangé (et tout non-Linux y reste, via#[cfg(target_os="linux")]sur le routage). Zéro régression. Hexagonal respecté : résolution policy→plan pure (domaine, step 5d), l'infra ne fait qu'orchestrer + enforcer ; le plan franchit le port en valeur domaine. ## Sortie réelle des testscargo build: Finished, OK.cargo test -p domain -p infrastructure -p application -p app-tauri: 80 suites, toutesok, 0 failed, 0 panic. Notables : - domain (lib) : 141 passed ;structured_session_d0(fake start nouvelle signature) vert. - infrastructure (lib) : 156 passed (incluait 154 avant LP4-4 ; les nouveaux chemins compilent et tous les tests session/process restent verts) ; suites sandbox/permission toutes 0 failed. - application (lib) : 72 passed ;structured_launch_d3,orchestrator_serviceverts. - app-tauri : 47 passed. Un seul warning, pré-existant et hors périmètre :method writes is never used(orchestrator_service.rs:1641, helper de fixture mort avant LP4-4). Prêt pour QA (7 invariants e2e d'enforcement du chemin structuré : agent structuré lancé avec Deny ⇒ écriture impossible, etc.).