Tâche : ajouter UN test fonctionnel anti-régression de la communication inter-agent (round-trip question/réponse) au plus haut niveau de fidélité possible SANS lancer de vrai CLI IA (zéro token). Contexte précis ci-dessous, suis-le à la lettre.

## Où
Fichier : crates/app-tauri/src/state.rs, dans le module de test existant `#[cfg(test)] mod mcp_serve_peer_tests` (commence vers la ligne 1893). Ce module pilote déjà la fn privée `serve_peer<S>` sur un `tokio::io::duplex` (pas de socket, pas de process). Réutilise au maximum ses helpers existants : `build_service`, `spawn_peer`, `handshake_line`, `tools_call_line`, `read_one_response`, `project()`, `project_id_arg`, `McpServer::new`.

## Le trou à combler
Le module couvre handshake+tools/list, propagation du requester, mismatch projet, pairs concurrents — MAIS PAS le round-trip `idea_ask_agent` → `idea_reply` à travers `serve_peer`. C'est la glue critique : l'identité de l'agent répondeur vient de la ligne de handshake (`requester`), et c'est elle qui permet à `idea_reply` de corréler la réponse au ticket en attente. Référence du comportement attendu : `crates/infrastructure/tests/mcp_server.rs::ask_agent_returns_target_reply_inline` (qui, lui, court-circuite le transport via handle_raw/for_requester ; ton test doit passer par serve_peer + handshake réel).

## Problème à régler d'abord
Le `build_service` actuel du module construit l'`OrchestratorService` via `OrchestratorService::new(...)` SANS câbler le médiateur d'entrée ni le mailbox ni le registre de conversations. Or `ask_agent` exige `with_input_mediator(...)` + (idéalement) `with_conversations(...)`, sinon il renvoie « la messagerie inter-agents n'est pas disponible ».
=> Ajoute dans le module un `build_service_with_mailbox(contexts) -> (Arc<OrchestratorService>, Arc<TestMailbox>, Arc<TerminalSessions>)` en PORTANT les fakes déjà éprouvés depuis `crates/application/tests/orchestrator_service.rs` (sections après la ligne ~806) : `TestMailbox` (impl `domain::mailbox::AgentMailbox`), `TestMediator` (impl `domain::input::InputMediator`, écrit le tour dans le PTY lié + délègue l'enqueue au TestMailbox), `TestConversations` (impl `domain::conversation::ConversationRegistry`). Câble : `.with_input_mediator(mediator, mailbox).with_conversations(conversations)`. Garde le profil Claude complet déjà présent dans `build_service` (adaptateur structuré + capacité MCP) pour passer la garde F2. Tu peux factoriser le profil pour éviter la duplication. Ajoute aussi un helper `seed_live_pty(sessions, agent_id, session_id)` (copie de celui d'orchestrator_service.rs) pour que la cible soit déjà vivante en PTY et qu'`ask_agent` la réutilise sans lancer de process.

## Le test à écrire (un seul, simple)
`async fn ask_reply_round_trips_over_serve_peer_via_handshake_requester()` :
1. `let proj = project();` ; `let contexts = FakeContexts::new();` ; `let target_id = contexts.seed_agent("architect");`
2. `let (service, mailbox, sessions) = build_service_with_mailbox(contexts);`
3. `seed_live_pty(&sessions, target_id, <SessionId quelconque>);` (cible vivante → pas de spawn)
4. `let server = Arc::new(McpServer::new(service, proj.clone()));`
5. Peer A (le demandeur = humain) : `spawn_peer(Arc::clone(&server), project_id_arg(&proj))`. Écris `handshake_line(&project_id_arg(&proj), "")` (requester vide ⇒ ask d'origine humaine, le plus simple : évite la garde de cycle et le besoin que le demandeur soit un agent enregistré), puis `tools_call_line(7, "idea_ask_agent", json!({"target":"architect","task":"What is the answer?"}))`. NE bloque pas le test : lis la réponse de A dans une task spawnée OU lis-la après avoir débloqué via B (voir étape 7).
6. Attends (borné par TIMEOUT, via boucle `mailbox.pending(&target_id) == 1` + `tokio::task::yield_now().await`) que A ait enqueué son ticket et soit en attente.
7. Peer B (la cible qui répond) : `spawn_peer(Arc::clone(&server), project_id_arg(&proj))`. Écris `handshake_line(&project_id_arg(&proj), &target_id.to_string())` (CRUCIAL : le requester du handshake = l'id de la cible, c'est ce qui fait que `idea_reply` corrèle au mailbox de la cible), puis `tools_call_line(8, "idea_reply", json!({"result":"the answer is 42"}))`. Lis la réponse de B : `isError == false`.
8. Lis la réponse de A : `isError == false` et le texte inline == "the answer is 42" (le texte est dans result["content"][0]["text"], cf. `result_text` dans mcp_server.rs — réplique ce petit helper si besoin).
GARDE-FOU obligatoire : borne chaque attente/lecture par `tokio::time::timeout(TIMEOUT, …)` (TIMEOUT existe déjà dans le module) pour qu'une régression échoue vite au lieu de hang.

## Contraintes
- Respecte l'archi hexagonale : les fakes implémentent les ports du domaine, aucune dépendance nouvelle.
- Code lisible, commenté dans le style du module (doc-comments expliquant POURQUOI le handshake requester est le point testé).
- Lance le test et rends-moi la SORTIE RÉELLE de `cargo test -p app-tauri-lib mcp_serve_peer_tests` (ou le nom de crate exact). Si ça ne compile pas / échoue, débogue jusqu'au vert et rends le diagnostic.

Rends ton résultat via idea_reply : (a) le diff/chemin du test ajouté, (b) la sortie cargo test réelle (vert ou rouge avec détail), (c) toute difficulté rencontrée.


TESTS (lot T1 + couverture des nouvelles surfaces Codex). Le code de production est déjà implémenté par DevBackend et le workspace compile + tests existants verts. Ta mission : prouver, SANS lancer de vrai CLI (zéro token), que la délégation inter-agents marche AUSSI pour un profil Codex, et couvrir les nouvelles surfaces.

## Ce qui a été ajouté (à tester)
- Domaine `crates/domain/src/profile.rs` : variante `McpConfigStrategy::TomlConfigHome { target, home_env }` + constructeur `toml_config_home(...)` ; `AgentProfile::materializes_idea_bridge()` (whitelist : Claude+ConfigFile(".mcp.json"), Codex+TomlConfigHome) ; struct partagée `McpServerWiring` + encodeur TOML.
- Application `crates/application/src/agent/lifecycle.rs` : `apply_mcp_config` bras `TomlConfigHome` (écrit `{runDir}/<target>` via fs.write, sémantique clobber/non-clobber selon runtime ; pousse `(home_env, parent(target))` dans `spec.env`).
- Application `crates/application/src/orchestrator/service.rs` : `guard_mcp_bridge_supported` ré-exprimée via `materializes_idea_bridge()`.
- Catalogue `crates/application/src/agent/catalogue.rs` : profil Codex intégré porte `Codex` + `toml_config_home(".codex/config.toml","CODEX_HOME")`.
- app-tauri `crates/app-tauri/src/state.rs` : `is_codex_mcp_profile`, `migrate_codex_run_dir`, `mcp_server_entry_toml`.

## Travail demandé
1. **Audite d'abord la couverture existante** (DevBackend a déjà posé des tests unitaires, ex. catalogue `mcp_tests`, profile.rs). Ne duplique pas — complète seulement les trous. Vérifie qu'il existe des tests pour :
   - D1 : (de)sérialisation `TomlConfigHome` (tag "strategy"), rejet target ".."/absolu, rejet home_env invalide ; `materializes_idea_bridge()` vrai/faux par couple (dont Codex sans mcp ⇒ false, Codex+ConfigFile ⇒ false).
   - D2 : encodeur TOML (`[mcp_servers.idea]`, args ordonnés, échappement chemin avec espaces/backslash, transport).
   - A1 : fake `FileSystem` reçoit le write au bon chemin `{runDir}/.codex/config.toml` avec TOML attendu ; `spec.env` contient `("CODEX_HOME", "{runDir}/.codex")` ; clobber si runtime=Some, non-clobber si None.
   - A2 : garde ⇒ Codex+TomlConfigHome = Ok ; Codex sans mcp / Codex+ConfigFile = Invalid ; Claude+.mcp.json = Ok (non-régression) ; profil inconnu = Ok.
   - I1 : pendant Codex de `reconcile_*_repairs_legacy_files_on_disk` (réécrit `.codex/config.toml`, rafraîchit exe/endpoint, idempotent au 2e passage).
   Ajoute les tests manquants dans le crate/module idoine, en miroir des tests Claude équivalents.

2. **Lot T1 — test fonctionnel dual Claude/Codex** (le livrable clé) dans `crates/app-tauri/src/state.rs`, module `mcp_e2e_loopback_tests`. Les 4 tests `*_over_real_loopback` (notamment `ask_then_reply_round_trips_inline_over_real_loopback`) doivent tourner À LA FOIS avec un profil Claude ET un profil Codex. Le round-trip sous le pont (loopback réel + fakes) est IDENTIQUE ; la seule différence testée = garde + profil. Approche : factorise le corps des tests sur un paramètre de profil (ex. enum/petite fn `claude_profile()` / `codex_profile()` produisant l'`AgentProfile` adéquat — Codex : structured=Codex, mcp=TomlConfigHome(".codex/config.toml","CODEX_HOME"), transport Stdio) et instancie chaque test pour les deux (deux fns nommées, ou rstest si déjà dépendance — sinon deux fns explicites `*_claude`/`*_codex`, pas de nouvelle dépendance). Le handshake (`requester`/`project`), `idea_ask_agent` et `idea_reply` restent inchangés. Ajoute un cas explicite qui assert que la garde ACCEPTE une cible Codex (là où elle refusait avant). N'exécute JAMAIS un binaire `codex` : fakes uniquement, `seed_live_pty` pour la cible.
   GARDE-FOU : borne chaque attente/lecture par `tokio::time::timeout` (TIMEOUT existe dans le module).

3. Lance et rends la SORTIE RÉELLE de : `cargo test -p domain`, `cargo test -p application`, `cargo test -p app-tauri --lib`. Boucle jusqu'au vert. Si rouge, diagnostique et corrige les TESTS (si c'est le code de prod qui est en cause, signale-le précisément pour que je renvoie à DevBackend — ne réécris pas le code de prod toi-même).

## À rendre via idea_reply
(a) liste des tests ajoutés (fichier::nom) ; (b) sortie cargo réelle des 3 commandes (vert exigé) ; (c) confirmation que le dual Claude/Codex passe pour les 4 round-trips ; (d) tout point nécessitant un retour DevBackend.


REPRISE CIBLÉE — tes 2 tours précédents n'ont pas produit de tests visibles. Scope RÉDUIT au strict minimum à fort impact pour tenir dans CE tour. N'audite plus, n'ajoute que CES DEUX tests, lance-les, réponds vite via idea_reply.

Le code de prod est déjà en place et vert. Ne touche PAS au code de prod.

### Test 1 — la garde accepte Codex (unitaire, application)
Dans `crates/application/tests/` (crée `codex_bridge_guard.rs` si besoin, ou ajoute au fichier de tests de l'orchestrateur existant). But : prouver `AgentProfile::materializes_idea_bridge()` (domaine) :
- Claude + `McpConfigStrategy::config_file(".mcp.json")` + `StructuredAdapter::Claude` ⇒ `true`.
- Codex + `McpConfigStrategy::toml_config_home(".codex/config.toml","CODEX_HOME")` + `StructuredAdapter::Codex` ⇒ `true` (LE point clé : avant, Codex était refusé).
- Codex SANS mcp ⇒ `false`. Codex + `config_file(".mcp.json")` ⇒ `false`.
Construis les `AgentProfile` via `AgentProfile::new(...).with_structured_adapter(...).with_mcp(McpCapability::new(...))` (regarde `crates/application/src/agent/catalogue.rs` lignes ~63-89 pour le modèle exact). Test unitaire pur, pas d'I/O.

### Test 2 — round-trip Codex sur le vrai loopback (fonctionnel, app-tauri)
Dans `crates/app-tauri/src/state.rs`, module `mcp_e2e_loopback_tests`. Le test `ask_then_reply_round_trips_inline_over_real_loopback` (~ligne 3546) existe pour Claude. Ajoute SON JUMEAU Codex : `ask_then_reply_round_trips_inline_over_real_loopback_codex`.
- Copie le corps, mais fais en sorte que le profil de la cible soit un profil **Codex** (structured=Codex, mcp=`toml_config_home(".codex/config.toml","CODEX_HOME")`, transport Stdio). Regarde comment `build_service` du module construit son profil Claude (`with_structured_adapter(StructuredAdapter::Claude).with_mcp(...)`) et ajoute une variante `build_service_codex` (ou paramètre) qui produit le même service mais avec un profil Codex pour l'agent cible. Tout le reste (handshake `requester`=id cible, `idea_ask_agent`, `idea_reply`, `seed_live_pty`, assertions inline "the answer is 42") est IDENTIQUE.
- Le but testé = la garde laisse passer Codex ET le round-trip aboutit. JAMAIS de vrai binaire codex : fakes uniquement. GARDE-FOU : borne par TIMEOUT comme l'original.

### Rends via idea_reply (vite)
(a) fichier::nom des 2 tests ; (b) sortie réelle de `cargo test -p application` ET `cargo test -p app-tauri --lib` (les 2 tests verts) ; (c) si rouge à cause du code de prod, dis-le précisément (je renvoie à DevBackend). Ne fais rien d'autre.


TESTS UNIQUEMENT — anti-régression du chemin Codex inter-agents. Le code de PROD est déjà en place et le workspace est VERT (je viens de le vérifier : `cargo test -p domain -p application` et `cargo test -p app-tauri --lib` passent). NE TOUCHE PAS au code de prod. Tes 3 tours précédents n'ont laissé AUCUN test dans l'arbre — cette fois, ajoute réellement les fichiers/fns ci-dessous, lance cargo, et rends la sortie réelle via idea_reply. J'ai vérifié chaque ancre.

## BLOC A — tests domaine (faciles, purs) dans `crates/domain/src/profile.rs`, module `#[cfg(test)] mod tests` qui commence ligne 734. Il NE contient AUCUN test pour les surfaces Codex. Ajoute :
1. `toml_config_home_round_trips_with_tagged_strategy` : sérialise un `McpConfigStrategy::toml_config_home(".codex/config.toml","CODEX_HOME").unwrap()`, vérifie le JSON tagué (`"strategy":"tomlConfigHome"` camelCase, champs `target`/`homeEnv` — vérifie l'orthographe exacte des champs dans la déf. de la variante `TomlConfigHome` ligne ~204 et l'attribut serde du enum), et round-trip identique.
2. `toml_config_home_rejects_absolute_and_parent_target` : `toml_config_home("/abs/x","CODEX_HOME")` et `toml_config_home("../escape","CODEX_HOME")` ⇒ `Err` (miroir de `config_file_rejects_absolute_target`/`config_file_rejects_parent_traversal` déjà présents).
3. `toml_config_home_rejects_invalid_home_env` : home_env vide ou avec caractère illégal ⇒ `Err` (miroir de `env_rejects_invalid_name`). Vérifie la règle exacte de validation dans le constructeur `toml_config_home` (ligne ~254).
4. `materializes_idea_bridge_matrix` : construis des `AgentProfile` via `AgentProfile::new(...).with_structured_adapter(...).with_mcp(McpCapability::new(strategy, McpTransport::Stdio))` (modèle exact : `crates/application/src/agent/catalogue.rs` lignes 60-90) et assert :
   - Claude + `config_file(".mcp.json")` ⇒ `true`
   - Codex + `toml_config_home(".codex/config.toml","CODEX_HOME")` ⇒ `true`  ← LE point clé (Codex était refusé avant)
   - Codex SANS `.with_mcp(...)` ⇒ `false`
   - Codex + `config_file(".mcp.json")` ⇒ `false`
   (réf. impl `materializes_idea_bridge` ligne ~723.)
5. `mcp_server_wiring_encodes_expected_toml` : `McpServerWiring` (déf. ligne ~300, encodeur `to_config_toml` ligne ~368, helper `toml_string` ligne ~406). Construis un wiring avec un chemin contenant un espace et un backslash, appelle `to_config_toml()`, assert : présence de `[mcp_servers.idea]`, args dans l'ordre, et échappement correct du chemin. Regarde la signature réelle du constructeur de `McpServerWiring` avant d'écrire.

## BLOC B — jumeau Codex du round-trip e2e dans `crates/app-tauri/src/state.rs`, module `mod mcp_e2e_loopback_tests` (ligne 2960).
- Le `build_service` du module (ligne 3320) force un profil **Claude** (`with_structured_adapter(StructuredAdapter::Claude)` ligne 3341 + `.with_mcp(McpCapability::new(McpConfigStrategy::config_file(".mcp.json")...))` ligne 3342).
- Le test Claude existe : `ask_then_reply_round_trips_inline_over_real_loopback` (ligne 3546).
- AJOUTE une fn `build_service_codex(contexts)` (copie de `build_service` mais profil cible = Codex : `with_structured_adapter(StructuredAdapter::Codex)` + `.with_mcp(McpCapability::new(McpConfigStrategy::toml_config_home(".codex/config.toml","CODEX_HOME").unwrap(), McpTransport::Stdio))`). Factorise si tu veux, mais NE casse pas le test Claude.
- AJOUTE le jumeau `ask_then_reply_round_trips_inline_over_real_loopback_codex` : corps IDENTIQUE à l'original mais via `build_service_codex`. Tout le reste inchangé (handshake `requester`=id cible, `idea_ask_agent`, `idea_reply`, `seed_live_pty` ligne 3400, assertions inline "the answer is 42", bornage par `TIMEOUT` ligne 3036). JAMAIS de vrai binaire codex : fakes uniquement.
- But prouvé = la garde laisse passer Codex ET le round-trip aboutit.

## Lance et rends la SORTIE RÉELLE de :
- `cargo test -p domain` (les 5 tests du bloc A verts)
- `cargo test -p app-tauri --lib mcp_e2e_loopback_tests` (le jumeau codex vert + non-régression Claude)
Boucle jusqu'au vert. Si rouge à cause du code de prod (improbable), dis-le PRÉCISÉMENT, ne réécris pas la prod.

## Rends via idea_reply OBLIGATOIREMENT :
(a) liste `fichier::nom` des tests ajoutés ; (b) la sortie cargo réelle des 2 commandes (copier-coller) ; (c) confirmation que le jumeau Codex passe ; (d) tout point bloquant. Si tu n'appelles pas idea_reply, je ne reçois rien.


VERT sur les 2 blocs. Aucun code de prod touché (tests uniquement). Cette fois les fichiers SONT dans l'arbre.

## (a) Tests ajoutés (fichier::nom)

BLOC A — `crates/domain/src/profile.rs`, module `mcp_tests` (avant l'accolade fermante l. 1018) :
- `profile::mcp_tests::toml_config_home_round_trips_with_tagged_strategy`
- `profile::mcp_tests::toml_config_home_rejects_absolute_and_parent_target`
- `profile::mcp_tests::toml_config_home_rejects_invalid_home_env`
- `profile::mcp_tests::materializes_idea_bridge_matrix`
- `profile::mcp_tests::mcp_server_wiring_encodes_expected_toml`

BLOC B — `crates/app-tauri/src/state.rs`, module `mcp_e2e_loopback_tests` :
- helper `build_service_codex(contexts)` (jumeau de `build_service`, profil Codex + `StructuredAdapter::Codex` + `McpConfigStrategy::toml_config_home(".codex/config.toml","CODEX_HOME")` / `McpTransport::Stdio`). `build_service` (Claude) intact.
- `state::mcp_e2e_loopback_tests::ask_then_reply_round_trips_inline_over_real_loopback_codex`

## (b) Sortie cargo réelle

### `cargo test -p domain` (BLOC A)
```
test profile::mcp_tests::materializes_idea_bridge_matrix ... ok
test profile::mcp_tests::mcp_server_wiring_encodes_expected_toml ... ok
test profile::mcp_tests::toml_config_home_rejects_absolute_and_parent_target ... ok
test profile::mcp_tests::toml_config_home_rejects_invalid_home_env ... ok
test profile::mcp_tests::toml_config_home_round_trips_with_tagged_strategy ... ok
test result: ok. 88 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
```
(suites d'intégration domaine également toutes vertes : 11/12/11/47/72/16/20/16/4 — 0 échec.)

### `cargo test -p app-tauri --lib mcp_e2e_loopback_tests` (BLOC B)
```
running 6 tests
test state::mcp_e2e_loopback_tests::handshake_requester_propagates_over_real_loopback ... ok
test state::mcp_e2e_loopback_tests::list_agents_round_trips_over_real_loopback ... ok
test state::mcp_e2e_loopback_tests::ask_then_reply_round_trips_inline_over_real_loopback ... ok
test state::mcp_e2e_loopback_tests::ask_then_reply_round_trips_inline_over_real_loopback_codex ... ok
test state::mcp_e2e_loopback_tests::orphan_reply_is_typed_error_over_real_loopback ... ok
test state::mcp_e2e_loopback_tests::malformed_jsonrpc_after_handshake_errors_and_server_survives ... ok
test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 34 filtered out
```

## (c) Confirmation jumeau Codex
`ask_then_reply_round_trips_inline_over_real_loopback_codex` PASSE : la garde laisse passer une cible Codex (`StructuredAdapter::Codex` + `TomlConfigHome`) ET le round-trip `idea_ask_agent` → `idea_reply` aboutit inline ("the answer is 42"). Non-régression Claude : `ask_then_reply_round_trips_inline_over_real_loopback` toujours vert. Aucun vrai binaire codex lancé (fakes : FakeRuntime/FakePty).

## (d) Points bloquants / écarts
1. Un seul ajustement de TEST a été nécessaire (pas de prod) : le champ `home_env` de la variante `TomlConfigHome` sérialise en snake_case `"home_env"`, PAS `"homeEnv"`. Raison : `#[serde(rename_all = "camelCase")]` sur l'enum renomme les *variantes* (`tomlConfigHome` ✓), pas les champs d'une variante struct (il faudrait `rename_all_fields`). C'est le comportement de prod existant — j'ai aligné l'assertion du test dessus avec un commentaire explicatif, sans toucher la prod. Tout le reste (variante taguée `"strategy":"tomlConfigHome"`, `target`, round-trip identique) conforme.
2. Aucun blocage côté prod : la garde `materializes_idea_bridge` accepte bien `(Codex, TomlConfigHome)` et refuse `(Codex, sans mcp)` / `(Codex, ConfigFile)` — couvert par `materializes_idea_bridge_matrix`.


LP0 — Agent de test du binôme permissions. DevBackend a livré le module domaine `crates/domain/src/permission.rs` (PUR, zéro I/O). `cargo test -p domain` est vert (17 tests de base déjà écrits par le dev). Ta mission : écrire la BATTERIE EXHAUSTIVE de tables de vérité pour durcir la fonction `resolve` et la sémantique de décision, puis l'exécuter et rapporter le résultat réel.

API à tester (ré-exportée depuis le crate `domain`) :
- `resolve(project: Option<&PermissionSet>, agent: Option<&PermissionSet>) -> Option<EffectivePermissions>`
- `EffectivePermissions::decide_file(cap, path) -> Posture` et `::decide_bash(cmd) -> Posture`
- Constructeurs validants : `PermissionRule::file(...)`, `PermissionRule::bash(...)`, `Glob::new`, `PathScope::new`, `CommandMatcher::{exact,prefix,glob}`, `PermissionSet::new`.
- Erreurs typées `PermissionError` : EmptyGlob, InvalidGlob, EmptyCommandMatcher, PathNotRelativeSafe, BashRuleHasPaths, FileRuleHasCommands, NotAFileCapability.

SÉMANTIQUE FIGÉE PAR L'ARCHITECT (à encoder en tests, ne pas dévier) :
1. resolve(None, None) == None (rien posé ⇒ rien projeté). Toute autre combinaison ⇒ Some.
2. HÉRITAGE + OVERRIDE : règles projet + règles agent superposées.
3. DENY-WINS : un Deny qui matche (projet OU agent, niveau règle fichier OU niveau CommandRule) gagne sur tout Allow, à tous niveaux, non surchargeable par un allow plus spécifique.
4. fallback résolu = le plus restrictif des deux (ordre Allow < Ask < Deny) ; l'agent resserre, jamais ne desserre un deny projet.
5. RÈGLE BASH À `commands` NON VIDE : le `effect` de niveau règle est INERTE — seules les CommandRule individuelles décident ; les commandes non matchées retombent sur `fallback`. (Interprétation A, confirmée. AJOUTE une ligne de table figeant explicitement « rule-level effect ignoré quand commands non vide » pour verrouiller contre régression.)
6. RÈGLE BASH À `commands` VIDE = verdict blanket (son effect) pour toute commande.

COUVERTURE ATTENDUE (tables) :
- Matrice héritage : {None, vide, allow, deny} projet × {None, vide, allow, deny} agent → posture effective attendue, pour fichier ET bash.
- Deny-wins : deny projet vs allow agent plus spécifique ; deny agent vs allow projet ; deny au niveau CommandRule vs allow blanket ; etc.
- fallback tighten : toutes paires (Allow/Ask/Deny) × (Allow/Ask/Deny).
- Globs : `*`, `**`, `?`, `[...]`, négation `[!...]`, segments, chemins relatifs profonds ; cas limites de matching.
- CommandMatcher : Exact vs Prefix vs Glob, chevauchements, casse, espaces/arguments.
- Invariants/erreurs : chaque variante de PermissionError déclenchée par le bon constructeur (bash+paths, fichier+commands, glob vide/invalide, path absolue/.., matcher vide).
- Cas limites resolve : sets vides des deux côtés, règles contradictoires même scope, multiples règles même capacité.

Écris ces tests dans le crate `domain` (module de tests ou fichier de tests d'intégration, au choix le plus idiomatique). NE MODIFIE PAS le code de production : si un test révèle un bug, NE le corrige pas — produis un RAPPORT D'ERREURS clair (entrée, attendu, obtenu) que je renverrai à DevBackend.

Réponds via idea_reply avec : nombre de tests ajoutés, la sortie réelle de `cargo test -p domain`, et — si rouge — le rapport d'erreurs détaillé par cas.


Test de communication inter-agent. Peux-tu confirmer que tu reçois bien ce message ? Réponds avec : ton rôle en une phrase, et le résultat de 17 × 3 pour vérifier que le canal aller-retour fonctionne.


Message bien reçu, le canal aller-retour fonctionne. Mon rôle : je suis l'agent de test (QA) d'IdeA, j'écris et exécute les tests unitaires des features et je relaie fidèlement les résultats jusqu'au vert. Vérification : 17 × 3 = 51.


[Test inter-agents] Bonjour QA. Ceci est un test du système inter-agents IdeA. Si tu reçois ce message, réponds simplement via idea_reply avec le texte : "QA OK - reçu via inter-agents". Merci.


Petit test de conversation inter-agent. Peux-tu me calculer le résultat de (347 × 89) + 1256 et me le renvoyer via idea_reply ? Merci !


[Test inter-agent depuis Main] Réponds simplement via idea_reply avec le texte : "QA OK — pont inter-agent fonctionnel". C'est un ping de vérification du bridge MCP (toi tu n'étais pas encore lancé), aucune autre action requise.


[Test ping inter-agent] Réponds simplement avec le mot "PONG-2" et l'heure courante. C'est un test de latence de la conversation inter-agent, pas besoin d'analyser du code.


[Ping inter-agent depuis Main] Test du pont MCP inter-agents. Si tu reçois ce message, réponds via idea_reply avec : (1) "QA OK — pont inter-agent fonctionnel", (2) le résultat de 144 ÷ 12, et (3) l'heure courante. Aucune autre action requise.


QA OK — pont inter-agent fonctionnel. (2) 144 ÷ 12 = 12. (3) Heure courante : 2026-06-15 11:56:12 CEST.


Test de conversation inter-agent. Calcule 47 + 58 et réponds-moi simplement avec le résultat via idea_reply.


47 + 58 = 105


Tests du lot LP3-1 (domaine) — Port `PermissionProjector` + `ProjectorKey` + champ profil. DevBackend vient de l'implémenter ; ça compile (`cargo build -p domain` et `-p application` OK). À toi d'écrire et exécuter les tests unitaires.

CE QUI A ÉTÉ AJOUTÉ :
- crates/domain/src/permission.rs : `ProjectorKey` (enum fermé Claude|Codex, serde camelCase → "claude"/"codex"), `ProjectionContext<'a>`, `ProjectedFile` (variants Replace / MergeToml), `PermissionProjection` (+ ctor `empty()`), trait `PermissionProjector`.
- crates/domain/src/profile.rs : `AgentProfile` a un nouveau champ `pub projector: Option<ProjectorKey>` (`#[serde(default, skip_serializing_if = "Option::is_none")]`), builder `with_projector`, `new` initialise à None.
- crates/application/src/agent/catalogue.rs : builtins claude/codex posent leur projector.

TESTS À COUVRIR (cohérents avec le style des tests existants dans ces fichiers — regarde les modules `#[cfg(test)]` déjà présents) :
1. ProjectorKey : sérialisation/désérialisation stable → "claude"/"codex" (round-trip serde_json).
2. AgentProfile COMPAT : un JSON de profil SANS le champ `projector` désérialise → `projector: None` (c'est l'exigence de compat la plus importante). Et round-trip d'un profil avec projector = Some(...).
3. AgentProfile : `skip_serializing_if` → quand projector est None, la clé est ABSENTE du JSON sérialisé.
4. Builder `with_projector` pose bien la valeur.
5. Builtins : le profil builtin claude a `Some(ProjectorKey::Claude)`, codex a `Some(ProjectorKey::Codex)`, et Gemini/Aider ont `None`.
6. `PermissionProjection::empty()` rend bien files/args/env vides (c'est la valeur-invariant pour eff==None).

Le trait `PermissionProjector` n'a pas encore d'impl concrète (LP3-2) : tu peux soit ne pas le tester directement, soit écrire un projecteur factice de test si utile pour valider la forme du contrat — à ton appréciation, ne sur-teste pas.

Exécute `cargo test -p domain` (et `-p application` si tu touches au catalogue). Réponds via idea_reply avec : la liste des tests ajoutés, leur emplacement, et la SORTIE RÉELLE de cargo test (pass/fail). Si quelque chose échoue, donne un rapport d'erreur clair (je le renverrai à DevBackend).


✅ LP3-1 (domaine) — TOUS LES TESTS VERTS. 9 tests ajoutés, suite complète `cargo test -p domain` et `-p application` : 0 échec.

TESTS AJOUTÉS (par emplacement)

1) crates/domain/src/permission.rs (module `#[cfg(test)] mod tests`)
   - `projector_key_serialises_to_stable_camel_case` : ProjectorKey::Claude→"\"claude\"", Codex→"\"codex\"" (forme fil stable, point 1).
   - `projector_key_round_trips` : round-trip serde_json des 2 variantes + désérialisation des littéraux "claude"/"codex" → variante attendue.
   - `permission_projection_empty_is_fully_empty` : `PermissionProjection::empty()` → files/args/env tous vides (invariant eff==None, point 6).

2) crates/domain/src/profile.rs (module `mcp_tests`)
   - `profile_default_has_no_projector` : `AgentProfile::new(...)` → projector None.
   - `profile_without_projector_omits_key_in_json` : skip_serializing_if → clé `projector` ABSENTE quand None (point 3).
   - `legacy_json_without_projector_deserialises_to_none` : JSON de profil SANS champ `projector` → None (COMPAT, point 2).
   - `with_projector_sets_and_round_trips_camel_case` : builder pose Some(Claude), sérialise `"projector":"claude"`, round-trip identique (points 2 & 4).

3) crates/application/src/agent/catalogue.rs (module `mcp_tests`)
   - `claude_and_codex_seed_their_projector_key` : builtin claude→Some(Claude), codex→Some(Codex) (point 5).
   - `gemini_and_aider_have_no_projector` : Gemini/Aider→None (point 5).

Le trait `PermissionProjector` n'est PAS testé directement (pas d'impl concrète avant LP3-2 ; pas de projecteur factice ajouté pour ne pas sur-tester — la forme du contrat est déjà couverte par PermissionProjection::empty()).

SORTIE RÉELLE (cargo test, ciblage par nom)
  test permission::tests::permission_projection_empty_is_fully_empty ... ok
  test permission::tests::projector_key_round_trips ... ok
  test permission::tests::projector_key_serialises_to_stable_camel_case ... ok
  test profile::mcp_tests::legacy_json_without_projector_deserialises_to_none ... ok
  test profile::mcp_tests::profile_default_has_no_projector ... ok
  test profile::mcp_tests::profile_without_projector_omits_key_in_json ... ok
  test profile::mcp_tests::with_projector_sets_and_round_trips_camel_case ... ok
  test agent::catalogue::mcp_tests::claude_and_codex_seed_their_projector_key ... ok
  test agent::catalogue::mcp_tests::gemini_and_aider_have_no_projector ... ok

Suites globales : `cargo test -p domain` → toutes les cibles `test result: ok. 0 failed` (lib 20+ tests, structured_session_d0 16, window 4, doctests 0). `cargo test -p application` → `0 failed` sur toutes les cibles. Aucune régression. Rien à renvoyer à DevBackend — lot LP3-1 validé.


Tests du lot LP3-2 (infrastructure) — `ClaudePermissionProjector` et `CodexPermissionProjector`. DevBackend a extrait la logique de traduction (verbatim) derrière le trait `PermissionProjector`. Build `cargo build -p infrastructure` OK. À toi d'écrire/exécuter les tests.

EMPLACEMENT : crates/infrastructure/src/permission/ (mod.rs, claude.rs, codex.rs). Mets les tests en `#[cfg(test)]` dans claude.rs / codex.rs (ou un mod tests dans permission/), cohérent avec le style infra existant. Référence utile : les tests de traduction d'origine étaient dans application/src/agent/lifecycle.rs ~l.2957-3068 — réutilise/relocalise leur intention.

CONTRAT testé (rappel) :
- `project(None, ctx)` → `PermissionProjection::empty()` (files/args/env vides) pour LES DEUX projecteurs. Cas invariant produit, à couvrir explicitement.
- Claude `project(Some(eff), ctx)` → 1 fichier `Replace { rel_path: ".claude/settings.local.json", contents: <json> }`, args/env vides ; `owned_replace_paths() == [".claude/settings.local.json"]`.
- Codex `project(Some(eff), ctx)` → 1 fichier `MergeToml { rel_path: ".codex/config.toml", managed_keys: ["sandbox_mode","approval_policy"], contents: toml partiel }` + args `["--sandbox", <mode>, "--ask-for-approval", <policy>]` ; `owned_replace_paths() == []`.

CAS À COUVRIR :
Claude :
1. Posture Allow / Ask / Deny → `defaultMode` attendu dans le JSON (bypassPermissions / acceptEdits / plan). Vérifie le mapping exact tel qu'implémenté.
2. deny-wins : un eff avec deny spécifique + allow large → l'entrée deny apparaît dans la liste deny du JSON.
3. additionalDirectories contient bien `ctx.project_root` (et JSON-escaping correct si le chemin contient un caractère à échapper — teste avec un chemin contenant un backslash/guillemet si pertinent).
4. Garde-fous `merge_default_deny` (sudo/rm -rf/mkfs/dd/shutdown/reboot) présents dans la liste deny.
5. Le JSON produit est un JSON VALIDE (parse-le avec serde_json::Value pour ne pas figer le format au caractère près — préfère des assertions sémantiques sur la valeur parsée plutôt que des comparaisons de chaîne brutes quand c'est possible).

Codex :
6. Mapping posture→sandbox_mode : Deny→read-only, Ask/Allow→workspace-write. Et approval_policy : Allow→never, Ask/Deny→on-request. Couvre les 3 postures.
7. Les args reflètent les mêmes valeurs que le TOML (cohérence args ↔ contents).
8. owned_replace_paths vide.

Construis les `EffectivePermissions` d'entrée via l'API domaine (resolve / PermissionSet / PermissionRule) comme dans les tests permission existants. Privilégie des assertions sémantiques robustes (JSON parsé, contains sur les listes) plutôt que des golden strings fragiles, sauf si un golden est clairement plus lisible.

Exécute `cargo test -p infrastructure`. Réponds via idea_reply : tests ajoutés + emplacement + SORTIE RÉELLE cargo test. Tout échec → rapport d'erreur clair pour DevBackend.


✅ LP3-2 (infrastructure) — TOUS LES TESTS VERTS. 11 tests ajoutés, `cargo test -p infrastructure` : 0 échec, aucune régression (147 tests lib + 0 fail sur toutes les cibles d'intégration).

TESTS AJOUTÉS (par emplacement)

A) crates/infrastructure/src/permission/claude.rs (`#[cfg(test)] mod tests`, 8 tests)
   - `project_none_is_empty` : `project(None, ctx)` → files/args/env vides (invariant produit).
   - `owned_replace_paths_is_the_settings_file` : == [".claude/settings.local.json"].
   - `default_mode_maps_each_posture` (cas 1) : Allow→"bypassPermissions", Ask→"acceptEdits", Deny→"plan" (assertion sur JSON parsé `permissions.defaultMode`).
   - `specific_deny_with_broad_allow_appears_in_deny_list` (cas 2, deny-wins) : Write Deny `.ideai/**` + Write Allow `**` → la liste `deny` contient `Edit(.ideai/**)` ET `Write(.ideai/**)` (le Write se déploie en Edit+Write), et le `**` reste dans `allow`.
   - `additional_directories_contains_project_root_escaped` (cas 3) : root = `C:\Users\a"b\proj` (backslash + guillemet) → après parse JSON, `additionalDirectories[0]` == chemin brut verbatim (prouve l'échappement correct).
   - `default_deny_guardrails_are_present` (cas 4) : sudo / rm -rf / / rm -rf ~ / $HOME* / mkfs* / dd if=* / shutdown* / reboot* présents dans `deny`.
   - `produced_settings_has_expected_static_shape` (cas 5) : doc parsé via serde_json → `enabledMcpjsonServers[0]=="idea"`, `skipDangerousModePermissionPrompt==true`, `sandbox.enabled==false`.
   - `empty_rules_fall_back_to_broad_default_allow` : eff sans règle → `allow == [Read,Edit,Write,Bash]`.
   NB : toutes les assertions Claude passent par serde_json::from_str (pas de golden string fragile) ; le helper `project_json` vérifie au passage la forme du contrat (1 `Replace { rel_path == ".claude/settings.local.json" }`, args/env vides) et que le contenu est un JSON valide.

B) crates/infrastructure/src/permission/codex.rs (`#[cfg(test)] mod tests`, 3 tests)
   - `project_none_is_empty` : `project(None, ctx)` → files/args/env vides (invariant produit).
   - `owned_replace_paths_is_empty` : == [] (config.toml co-owned, jamais supprimé).
   - `posture_maps_sandbox_and_approval_in_file_and_args` (cas 6+7+8) : sur les 3 postures
       Deny  → sandbox "read-only",       approval "on-request"
       Ask   → sandbox "workspace-write",  approval "on-request"
       Allow → sandbox "workspace-write",  approval "never"
     Vérifie pour chacune : 1 `MergeToml { rel_path == ".codex/config.toml", managed_tables vide, managed_keys == ["sandbox_mode","approval_policy"] }`, le `contents` contient bien `sandbox_mode = "<mode>"` et `approval_policy = "<policy>"`, ET args == ["--sandbox", <mode>, "--ask-for-approval", <policy>] (cohérence args↔contents), env vide.

Les `EffectivePermissions` d'entrée sont construits via l'API domaine (`resolve(Some(&PermissionSet::new(rules, fallback)), None)` + `PermissionRule::file`), comme les tests `permission` existants.

SORTIE RÉELLE (ciblage par nom, lib)
  test permission::claude::tests::project_none_is_empty ... ok
  test permission::claude::tests::owned_replace_paths_is_the_settings_file ... ok
  test permission::claude::tests::default_mode_maps_each_posture ... ok
  test permission::claude::tests::specific_deny_with_broad_allow_appears_in_deny_list ... ok
  test permission::claude::tests::additional_directories_contains_project_root_escaped ... ok
  test permission::claude::tests::default_deny_guardrails_are_present ... ok
  test permission::claude::tests::produced_settings_has_expected_static_shape ... ok
  test permission::claude::tests::empty_rules_fall_back_to_broad_default_allow ... ok
  test permission::codex::tests::project_none_is_empty ... ok
  test permission::codex::tests::owned_replace_paths_is_empty ... ok
  test permission::codex::tests::posture_maps_sandbox_and_approval_in_file_and_args ... ok
  test result: ok. 11 passed; 0 failed; 0 ignored; 0 measured; 136 filtered out

Suite complète `cargo test -p infrastructure` : lib 147 passed; 0 failed, et toutes les cibles d'intégration `0 failed`. Rien à renvoyer à DevBackend — lot LP3-2 validé.


Tests du lot LP3-3 (application) — l'étape `apply_permission_projection` + registre + découplage MCP dans LaunchAgent. DevBackend a câblé et a déjà fait passer `cargo test -p application` (en adaptant les anciens tests). À toi d'AJOUTER les tests qui prouvent le nouveau comportement de bout en bout (niveau application, FS mocké).

Contexte API exposée :
- `application::PermissionProjectorRegistry` : `new()`, `with(Arc<dyn PermissionProjector>)`, `insert(..)`, `get(key)`.
- `LaunchAgent::with_permission_projectors(Arc<PermissionProjectorRegistry>)` (builder optionnel ; absent ⇒ aucune projection).
- Projecteurs concrets dans `infrastructure` : `ClaudePermissionProjector`, `CodexPermissionProjector` (utilise-les pour peupler le registre dans les tests, ou des doubles de test si plus simple — à ton appréciation).
- Sélection : `profile.projector` sinon fallback legacy (CLAUDE.md⇒Claude ; StructuredAdapter::Codex / TomlConfigHome⇒Codex).
- Étape insérée après apply_injection + apply_mcp_config, avant le split structuré/PTY.

Regarde d'abord les tests d'intégration existants de LaunchAgent (crates/application/tests/agent_lifecycle.rs et le FS mock utilisé, `fs.seed_writes()` etc.) pour réutiliser les fixtures/mocks en place.

CAS À COUVRIR :
1. Sélection de clé : (a) profil avec `projector=Some(Claude)` → projecteur Claude utilisé ; (b) fallback legacy : profil sans projector mais convention-file CLAUDE.md → Claude ; (c) fallback legacy : profil sans projector mais StructuredAdapter::Codex (ou TomlConfigHome) → Codex ; (d) profil non projetable + pas de fallback → aucune projection.
2. Clobber `Replace` : profil Claude lancé 2× → `.claude/settings.local.json` est RÉÉCRIT (clobber), pas conservé tel quel. Prouve l'inversion vs l'ancien non-clobbering.
3. Merge `MergeToml` (Codex) : un `.codex/config.toml` préexistant contenant une table/clé NON gérée (ex. une entrée user ou mcp_servers) → après projection, sandbox_mode/approval_policy sont upsertées ET la clé non gérée est préservée. Idempotence : 2 projections ne dupliquent pas les clés.
4. Fold args/env : profil Codex → le `spec` final porte les args `--sandbox …/--ask-for-approval …` (vérifie via le mock/spawn capturé), pour le chemin PTY ET structuré si testable.
5. **Découplage MCP (le cas clé du lot)** : profil Codex SANS configuration MCP → la sandbox est tout de même projetée (args + .codex/config.toml). C'est la preuve que la projection ne dépend plus de apply_mcp_config.
6. No-op : (a) registre absent (builder non appelé) → aucun fichier de permission écrit (`fs.seed_writes()` / writes vides) ; (b) eff == None (aucune permission posée pour le projet/agent) → projection vide.
7. eff résolu : permissions posées au niveau projet/agent → bien reflétées dans le fichier projeté (au moins un smoke : posture Deny → mode plan côté Claude).

Mets ces tests là où c'est cohérent (tests d'intégration agent_lifecycle.rs ou un nouveau tests/permission_projection.rs). Exécute `cargo test -p application` (et `-p infrastructure` si tu réutilises les projecteurs concrets — vérifie que rien ne casse). Réponds via idea_reply : tests ajoutés + emplacement + SORTIE RÉELLE. Tout échec → rapport clair pour DevBackend (c'est le lot le plus à risque, sois rigoureux sur le découplage MCP et le clobber).


✅ LP3-3 (application) — TOUS LES TESTS VERTS. 11 tests d'intégration ajoutés, `cargo test -p application` ET `-p infrastructure` : 0 échec, aucune régression.

EMPLACEMENT : crates/application/tests/agent_lifecycle.rs (réutilise les fakes en place ; section « LP3-3 — permission projection wiring » en fin de fichier).

CHOIX ARCHITECTURAL (important) : l'application ne dépend QUE des ports domaine (cf. description du crate). Je n'ai donc PAS ajouté `infrastructure` en dev-dependency. À la place, deux **doubles fidèles** `FakeClaudeProjector` / `FakeCodexProjector` (impl du trait domaine `PermissionProjector`) reproduisant le mapping réel posture→mode (Allow→bypassPermissions, Ask→acceptEdits, Deny→plan ; Deny→read-only/on-request, Ask→workspace-write/on-request, Allow→workspace-write/never). La fidélité de traduction réelle est déjà couverte par LP3-2 ; ces tests prouvent le CÂBLAGE applicatif (sélection de clé, clobber Replace, merge MergeToml, fold args/env, découplage MCP, no-op). Ajouts utilitaires aux fakes existants : `FakeFs.seed_read` + lecture « last-write-wins » (pour merge/idempotence), `FakePermissionStore`, `full_registry()`, `perm_doc(posture)`, `codex_profile()`, helper `launch_with_projection(...)`.

TESTS AJOUTÉS (11)
1) Sélection de clé :
   - `projection_selects_claude_from_explicit_projector_field` (1a) : projector=Some(Claude) gagne même avec convention GEMINI.md (le champ explicite prime sur l'heuristique) → seed Claude écrit, pas de config Codex.
   - `projection_falls_back_to_claude_from_convention_file` (1b) : pas de projector + CLAUDE.md → Claude.
   - `projection_falls_back_to_codex_from_structured_adapter` (1c) : pas de projector + StructuredAdapter::Codex → Codex (config + args --sandbox).
   - `projection_noop_for_unprojectable_profile` (1d) : GEMINI.md, ni projector ni signal Codex → aucune projection (aucun fichier, aucun arg).
2) `claude_replace_seed_is_clobbered_on_relaunch` : 2 lancements (session retirée entre les deux pour lever la garde singleton), seed pré-marqué existant → 2 écritures sur le MÊME chemin (clobber prouvé, inversion vs régime non-clobber MCP).
3) `codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged` : `.codex/config.toml` préexistant avec `user_key`+table `[mcp_servers.idea]` non gérés → après projection, sandbox_mode/approval_policy upsertés ET user_key+table préservés ; 2e projection → 1 seule occurrence de chaque clé gérée (idempotence), user_key toujours là.
4) `codex_projection_folds_args_into_spawn_spec` : args `--sandbox workspace-write` / `--ask-for-approval on-request` (posture Ask) présents dans l'ordre CLI dans le spec spawné (chemin PTY ; le chemin structuré hérite du même spec car le fold précède le split — pas de factory structurée câblée dans ces fixtures).
5) `codex_sandbox_projected_without_any_mcp_capability` (CAS CLÉ découplage MCP) : profil Codex SANS McpCapability (`profile.mcp.is_none()` vérifié) → la sandbox est tout de même projetée (`.codex/config.toml` écrit, `sandbox_mode="read-only"` posture Deny) + args foldés. Prouve que la projection ne dépend plus de apply_mcp_config.
6) No-op :
   - `no_registry_means_no_projection` (6a) : registre absent (builder non appelé) → aucun fichier de permission, même avec policy posée.
   - `no_policy_posed_means_empty_projection` (6b) : `ProjectPermissions::default()` (project_defaults=None) ⇒ resolve_for=None ⇒ projection vide même registre câblé.
7) `resolved_deny_posture_reflected_as_plan_mode` : policy projet Deny → resolve_for → projecteur → JSON parsé `permissions.defaultMode=="plan"` ET `additionalDirectories[0]=="/home/me/proj"` (le project_root a bien transité par le ProjectionContext).

SORTIE RÉELLE
  cargo test -p application --test agent_lifecycle :
    test result: ok. 59 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
    (dont les 11 ci-dessus : projection_selects_claude_from_explicit_projector_field, projection_falls_back_to_claude_from_convention_file, projection_falls_back_to_codex_from_structured_adapter, projection_noop_for_unprojectable_profile, claude_replace_seed_is_clobbered_on_relaunch, codex_mergetoml_upserts_managed_keys_and_preserves_unmanaged, codex_projection_folds_args_into_spawn_spec, codex_sandbox_projected_without_any_mcp_capability, no_registry_means_no_projection, no_policy_posed_means_empty_projection, resolved_deny_posture_reflected_as_plan_mode — tous ... ok)
  cargo test -p application (toutes cibles) : chaque `test result: ok. … 0 failed`.
  cargo test -p infrastructure (réutilisé indirectement, vérif non-régression) : toutes cibles `0 failed` (lib 147, etc.).

Rien à renvoyer à DevBackend — lot LP3-3 validé. Note : le découplage MCP (cas 5) et le clobber (cas 2) sont prouvés explicitement comme demandé.


Tests du lot LP3-4 (application) — nettoyage des fichiers de permission orphelins au swap cross-profile dans `ChangeAgentProfile`. DevBackend a câblé l'étape 5b (entre invalidate_engine_link et relaunch). `cargo test -p application` déjà vert ; à toi d'ajouter les tests de swap qui prouvent le nettoyage.

EMPLACEMENT : crates/application/tests/ (le fichier des tests ChangeAgentProfile existants ; réutilise les fixtures). Regarde d'abord les tests de swap existants pour réutiliser les fakes.

POINT IMPORTANT (signalé par DevBackend) : `FileSystem::remove_file` a une impl PAR DÉFAUT no-op (Ok sans rien supprimer). Pour ASSERTER une suppression, ton fake FileSystem doit OVERRIDE `remove_file` (enregistrer les chemins supprimés, et réellement retirer de son état interne). Ajoute ça au fake utilisé par les tests de swap.

Câblage à reproduire dans les tests : injecter le MÊME registre via `ChangeAgentProfile::with_permission_projectors(...)` (et `LaunchAgent::with_permission_projectors(...)` pour la relance). Tu peux réutiliser les doubles fidèles `FakeClaudeProjector`/`FakeCodexProjector` créés en LP3-3 (Claude → owned_replace_paths=[".claude/settings.local.json"] ; Codex → []).

SCÉNARIOS À COUVRIR :
1. **Claude→Codex** : avant swap, `.claude/settings.local.json` existe dans le run dir de l'agent. Après swap → ce fichier est SUPPRIMÉ (présent dans les remove enregistrés par le fake / absent de l'état FS), ET la config Codex (`.codex/config.toml` + args) est projetée par la relance. C'est le scénario phare.
2. **Claude→Claude** (changement de profil même famille) : `owned(ancien) − owned(nouveau)` = vide → `.claude/settings.local.json` N'EST PAS supprimé (il est re-clobbé par la relance, pas retiré).
3. **Codex→Claude** : Codex n'a pas de Replace → rien supprimé côté nettoyage ; la relance écrit `.claude/settings.local.json`. (Le `.codex/config.toml` reste, jamais supprimé — vérifie qu'il n'est pas dans les remove.)
4. **No-op** : (a) registre absent (builder non appelé) → aucune suppression ; (b) profil précédent introuvable (supprimé entre-temps) → nettoyage sauté, pas d'échec.
5. **Best-effort** : suppression d'un fichier déjà absent → le swap réussit quand même (pas d'erreur propagée).
6. **NON-RÉGRESSION P8d** : sur un swap, vérifie que l'id de paire (conversation_id stable) / le handoff restent comme avant — réutilise/garde vert le(s) test(s) de swap existant(s) qui couvrent ça (ne les casse pas ; ajoute une assertion si pertinent).

Exécute `cargo test -p application`. Réponds via idea_reply : tests ajoutés + emplacement + modifs au fake FileSystem + SORTIE RÉELLE. Tout échec → rapport clair pour DevBackend. Sois rigoureux sur le scénario 1 (Claude→Codex) et le 2 (Claude→Claude ne supprime pas).


✅ LP3-4 (application) — TOUS LES TESTS VERTS. 7 tests de swap ajoutés, `cargo test -p application` : 0 échec sur les 28 cibles, aucune régression (dont les tests P8d existants restés verts).

EMPLACEMENT : crates/application/tests/change_agent_profile.rs (réutilise les fixtures swap en place ; nouvelle section « LP3-4 — cleanup of orphan permission files at a cross-profile swap »).

MODIFS AU FAKE FILESYSTEM (comme signalé par DevBackend)
- `FakeFsInner` : ajout d'un champ `removed: Vec<String>`.
- Override de `remove_file` (l'impl par défaut du port est no-op) : enregistre le chemin dans `removed` ET retire réellement l'entrée de `files` → une suppression est assertable, et l'idempotence (remove d'un absent) reste Ok.
- Accesseurs ajoutés : `removed()` (liste ordonnée des chemins supprimés), `has_file(path)` (présence dans l'état). + `FakePty::last_spawn_args()` pour asserter les args foldés par la relance.

CÂBLAGE TEST (fidèle au prod) : nouvelle fixture `fixture_with_projection(agent, profiles, registry, perm_doc)` qui injecte le MÊME `full_registry()` via `ChangeAgentProfile::with_permission_projectors(...)` ET `LaunchAgent::with_permission_projectors(...)`, plus `LaunchAgent::with_permission_store(Allow)` pour que la projection de la relance soit non-vide. Doubles fidèles `FakeClaudeProjector` (owned_replace_paths=[".claude/settings.local.json"]) / `FakeCodexProjector` (owned=[], MergeToml `.codex/config.toml` + args --sandbox/--ask-for-approval) — application gardée sans dépendance à infrastructure.

TESTS AJOUTÉS (7)
1) `swap_claude_to_codex_removes_claude_seed_and_projects_codex` (PHARE) : `.claude/settings.local.json` pré-existant dans le run dir stable → après swap : présent dans `removed()` ET absent de l'état FS ; la relance projette `.codex/config.toml` (sandbox_mode=workspace-write) + args `--sandbox` dans le spawn.
2) `swap_claude_to_claude_does_not_remove_seed` : owned(old)−owned(new)=∅ → le seed n'est PAS supprimé (absent de `removed()`) et reste présent (re-clobbé par la relance, jamais retiré).
3) `swap_codex_to_claude_removes_nothing_and_keeps_codex_config` : Codex n'a pas de Replace → `removed()` vide ; `.codex/config.toml` pré-existant toujours présent et JAMAIS dans `removed()` ; la relance écrit le seed Claude.
4a) `swap_without_registry_removes_nothing` : fixture par défaut SANS registre sur le swap → aucune suppression même sur Claude→Codex avec seed présent.
4b) `swap_with_unknown_previous_profile_skips_cleanup` : l'agent porte pid(1) mais le store ne connaît que pid(2)/pid(3) (ancien profil supprimé) → cleanup sauté, `removed()` vide, swap réussit sans erreur.
5) `swap_claude_to_codex_succeeds_when_seed_absent` : seed NON semé → la suppression est tout de même tentée (best-effort idempotent : présente dans `removed()`) et le swap réussit.
6) `swap_with_cleanup_preserves_pair_id_and_handoff` (NON-RÉGRESSION P8d) : swap Claude→Codex live avec cleanup qui fire ET handoff semé sous l'id de paire du leaf → après swap : seed supprimé, l'id de paire (conversation_id) est PRÉSERVÉ sur le leaf persisté, agent_was_running remis à false, et le handoff est ré-injecté dans le convention file de la relance (preuve que le cleanup ne perturbe pas l'id de paire/handoff). Les tests P8d préexistants (`live_swap_relaunches_with_preserved_pair_id_and_no_engine_resume`, `swap_preserves_uuid_pair_id_and_clears_engine_cache`, etc.) restent verts, inchangés.

POINT D'AJUSTEMENT (pas un bug) : le `FakeRuntime` partagé de ce fichier matérialise toujours le convention file en `CLAUDE.md` quel que soit le profil ; le test 6 lit donc `CLAUDE.md` (le contenu/handoff est ce qui importe, pas le nom de fichier figé par le fake).

SORTIE RÉELLE
  cargo test -p application --test change_agent_profile :
    test result: ok. 19 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
    (dont : swap_claude_to_codex_removes_claude_seed_and_projects_codex, swap_claude_to_claude_does_not_remove_seed, swap_codex_to_claude_removes_nothing_and_keeps_codex_config, swap_without_registry_removes_nothing, swap_with_unknown_previous_profile_skips_cleanup, swap_claude_to_codex_succeeds_when_seed_absent, swap_with_cleanup_preserves_pair_id_and_handoff — tous ok)
  cargo test -p application (toutes cibles) : 28× `test result: ok. … 0 failed`, aucune régression. Aucun warning sur les deux fichiers de test touchés.

Rien à renvoyer à DevBackend — lot LP3-4 validé. Scénarios 1 (Claude→Codex supprime + projette) et 2 (Claude→Claude ne supprime pas) prouvés explicitement.


Ping de diagnostic. Calcule 9 + 6 et réponds-moi uniquement avec le résultat via idea_reply.


[Test de communication inter-agent] Ping. Réponds simplement via idea_reply avec « pong QA OK » pour confirmer que le canal fonctionne.


pong QA OK


[LP4-0 — suite de tests unitaires domaine] DevBackend a livré le sous-lot LP4-0 (domaine pur) sur le chantier permissions/enforcement OS. Écris et exécute une suite de tests unitaires exhaustive, puis relaie le résultat réel (vert/rouge avec sortie).

Cible : `crates/domain/src/sandbox.rs` (fn pure `compile_sandbox_plan`) et `crates/domain/src/permission.rs` (fn pure `render_permission_summary`). Tout est pur, zéro I/O, pas de fake nécessaire.

Invariants à couvrir impérativement :
1. `compile_sandbox_plan(None, ctx) == None` (rien posé ⇒ natif). Et `Some(eff)` avec une politique posée mais `allowed` vide ⇒ `Some` (pas `None`) : présence de politique ≠ grants non vides.
2. Mapping capabilities : Read→RO, Write→RW, Delete→RW. `ExecuteBash` n'est JAMAIS traduit en grant (borne Landlock = fichiers only). Vérifie qu'une politique purement Bash ne produit aucun PathGrant.
3. Fail-closed (le cœur du lot) : un glob Allow est réduit à son préfixe statique (chemin littéral avant le 1er métacaractère `*`/`?`/`[`). Le grant est ABANDONNÉ si un Deny a un préfixe statique qui chevauche (égal, ancêtre OU descendant). Cas clés à tester : Allow `**` + un seul Deny fichier ⇒ aucun grant racine ; Allow `src/**` + Deny `src/secret/**` ⇒ grant `src` abandonné (descendant) ; Allow `src/**` + Deny `other/**` ⇒ grant `src` conservé (disjoint). Teste aussi l'union d'accès (RO+RW) sur une même racine.
4. `default_posture` du plan reflète bien la posture résiduelle résolue.
5. `render_permission_summary(None) == None` ; et pour `Some`, le bloc Markdown DOIT mentionner explicitement que les fichiers sont OS-enforced/Landlock when supported ET que les commandes (ExecuteBash) restent advisory / NOT OS-locked (assertions sur le contenu).

Place les tests là où c'est cohérent avec le style existant du crate domain (tests inline `#[cfg(test)] mod tests` dans sandbox.rs, et dans permission.rs pour le résumé). Exécute `cargo test -p domain` et confirme le workspace reste vert. Réponds via idea_reply avec : nombre de tests ajoutés, sortie de `cargo test -p domain` (résumé pass/fail), et tout écart constaté entre le comportement réel et les invariants ci-dessus (si un invariant n'est pas respecté, rapport d'erreur précis pour DevBackend).


LP4-0 — Suite de tests unitaires domaine : VERT. Aucun écart constaté, tous les invariants sont respectés.

## Tests ajoutés : 17
- **sandbox.rs** (14 tests, nouveau `#[cfg(test)] mod tests`) :
  - `none_eff_yields_no_plan` (inv.1 : None ⇒ None)
  - `policy_with_no_allow_still_yields_some_plan` (inv.1 : politique posée + allowed vide ⇒ Some, testé fallback-seul ET deny-seul)
  - `read_maps_to_ro`, `write_and_delete_map_to_rw` (inv.2 : Read→RO, Write/Delete→RW)
  - `bash_only_policy_produces_no_path_grant` (inv.2 : ExecuteBash jamais traduit, 0 PathGrant)
  - `root_glob_with_single_file_deny_drops_root_grant` (inv.3 : Allow `**` + 1 Deny fichier ⇒ aucun grant racine)
  - `descendant_deny_drops_overlapping_allow` (inv.3 : Allow `src/**` + Deny `src/secret/**` ⇒ `src` abandonné)
  - `disjoint_deny_keeps_allow` (inv.3 : Allow `src/**` + Deny `other/**` ⇒ `src` conservé)
  - `ancestor_deny_also_drops_allow` (inv.3 : symétrie, deny ancêtre abandonne aussi)
  - `sibling_prefix_is_not_a_descendant` (inv.3 : `src2` ≠ descendant de `src`, conscience des bornes de composant)
  - `accesses_union_on_a_shared_root` (inv.3 : RO+RW fusionnés sur 1 grant)
  - `static_prefix_of_literal_file_is_the_file_itself` (préfixe statique d'un littéral)
  - `default_posture_mirrors_resolved_fallback` (inv.4 : Allow/Ask/Deny)
  - `trailing_slash_on_project_root_is_normalised` (pas de `//`)
- **permission.rs** (3 tests, ajoutés au mod tests existant) :
  - `summary_is_none_when_nothing_posed` (inv.5 : None ⇒ None)
  - `summary_states_files_os_enforced_and_commands_advisory` (inv.5 : assertions contenu — "OS-enforced", "Landlock", "advisory", "NOT OS-locked", "ExecuteBash", scope + matcher + posture affichés)
  - `summary_handles_empty_rule_lists_per_section` (sections vides honnêtes + boundary)

## `cargo test -p domain` (résumé)
- lib : **139 passed; 0 failed** (dont les 17 ci-dessus, vérifiés par nom)
- tests d'intégration + doc-tests : tous OK, 0 failed
- Tous les binaires : `0 failed`

## Workspace
`cargo test --workspace` : tout vert, **0 failed**, aucune erreur de compilation, aucun warning. Non-régression confirmée.

Note pour DevBackend (observation, pas un bug) : dans `compile_sandbox_plan`, les Deny servant de barrières (`deny_roots`) sont collectés indépendamment de la capability — un Deny `Write` clôture donc aussi un Allow `Read` sur la même racine. C'est conforme à l'esprit fail-closed (sous-approximation conservatrice) et bien couvert par les tests ; je le signale uniquement au cas où la sémantique voulue serait par-capability. Si c'est intentionnel (probable vu la doc), rien à faire.


[LP4-0 — réécriture des tests après affinage sémantique « barrières Deny par classe d'accès »] Décision d'orchestration appliquée par DevBackend : dans `compile_sandbox_plan` (`crates/domain/src/sandbox.rs`), les barrières Deny sont désormais PAR CLASSE D'ACCÈS — RO (alimentée par Allow Read, fermée seulement par Deny Read) et RW (alimentée par Allow Write+Delete, fermée seulement par Deny Write+Delete). Un Deny d'une autre classe n'a aucun effet. ExecuteBash : toujours ignoré.

DevBackend a marqué 3 de tes tests `#[ignore = "OBSOLETE … 2db80d41 … QA to rewrite"]` car ils encodaient l'ancienne sémantique capability-blind (un Deny Write faisait tomber un Allow Read). À réécrire pour qu'ils soient probants sous la nouvelle sémantique, puis retirer le `#[ignore]` :

1. `root_glob_with_single_file_deny_drops_root_grant` → utilise une barrière de MÊME classe : `Allow Read **` + `Deny Read <fichier>` ⇒ le grant racine RO doit être abandonné (fail-closed intra-classe). Garde l'esprit « un deny intra-classe fait tomber la racine ».
2. `descendant_deny_drops_overlapping_allow` → `Allow Read src/**` + `Deny Read src/secret/**` ⇒ grant `src` (RO) abandonné. ET ajoute l'assertion DUALE qui protège la décision produit : `Allow Read src/**` + `Deny Write src/secret/**` (classe différente, aucun Deny Read) ⇒ grant `src` CONSERVE le RO. C'est le cas clé d'autonomie à verrouiller.
3. `ancestor_deny_also_drops_allow` → `Allow Read src/sub/**` + `Deny Read src/**` ⇒ grant abandonné (barrière ancêtre même classe).

Renforce aussi les 2 tests « affaiblis » signalés (`disjoint_deny_keeps_allow`, `sibling_prefix_is_not_a_descendant`) en utilisant une barrière de MÊME classe que l'allow, sinon ils prouvent la disjointness pour une mauvaise raison.

Ajoute idéalement un test croisé explicite : sur une même racine, `Allow Read` + `Allow Write` + `Deny Write` même sous-arbre ⇒ le grant survit en RO seul (RW tombé, RO conservé) — la preuve directe de la granularité par classe.

Exécute `cargo test -p domain` puis `cargo test --workspace`. Confirme 0 ignored restant sur sandbox.rs et 0 failed. Réponds via idea_reply avec : tests réécrits/ajoutés, sortie pass/fail, et confirmation qu'aucun `#[ignore]` LP4-0 ne subsiste.


LP4-0 — Réécriture sous sémantique « barrières Deny par classe d'accès » : VERT. Aucun `#[ignore]` LP4-0 ne subsiste.

## Tests réécrits (3 ex-ignorés, désormais probants en barrière MÊME classe)
1. `root_glob_with_single_file_deny_drops_root_grant` → renommé `root_glob_with_same_class_deny_drops_root_grant` : `Allow Read **` + `Deny Read secret.txt` ⇒ grant racine RO abandonné (fail-closed intra-classe RO).
2. `descendant_deny_drops_overlapping_allow` → renommé `descendant_same_class_deny_drops_overlapping_allow` : `Allow Read src/**` + `Deny Read src/secret/**` ⇒ grant `src` (RO) abandonné.
3. `ancestor_deny_also_drops_allow` → renommé `ancestor_same_class_deny_also_drops_allow` : `Allow Read src/sub/**` + `Deny Read src/**` ⇒ grant abandonné (barrière ancêtre même classe).

## Tests renforcés (barrière de MÊME classe que l'allow, sinon disjointness prouvée pour une mauvaise raison)
- `disjoint_deny_keeps_allow` → `disjoint_same_class_deny_keeps_allow` : `Allow Read src/**` + `Deny Read other/**` ⇒ `src` conservé en RO.
- `sibling_prefix_is_not_a_descendant` : passe la barrière en `Deny Read src/**` (vs Allow Read `src2/**`) ⇒ `src2` conservé par conscience des bornes de composant, pas par mismatch de classe.

## Tests ajoutés (verrous de la décision produit / granularité par classe)
- `other_class_deny_does_not_amputate_read_allow` (assertion DUALE clé d'autonomie) : `Allow Read src/**` + `Deny Write src/secret/**` (classe différente) ⇒ `src` CONSERVE le RO.
- `same_root_drops_rw_but_keeps_ro_under_a_write_deny` (preuve directe de la granularité par classe) : `Allow Read src/**` + `Allow Write src/**` + `Deny Write src/secret/**` ⇒ grant `src` survit en RO seul (RW tombé, RO conservé).

ExecuteBash : toujours couvert par `bash_only_policy_produces_no_path_grant` (0 PathGrant). Invariants 1/2/4 et bords (sibling, préfixe statique, trailing slash, union RO|RW) inchangés et verts.

## Sortie pass/fail
- `cargo test -p domain` (lib) : **141 passed; 0 failed; 0 ignored**. Bloc sandbox : **16 tests sandbox::tests, tous ok** (liste vérifiée par nom). Intégration + doc-tests : 0 failed.
- `cargo test --workspace` : tout vert — 80 lignes « test result », **toutes 0 failed**, aucune erreur de compilation.
- `grep -rn "#[ignore]"` sur `crates/domain/src/` : **aucun attribut `#[ignore]`** (seules des occurrences du mot « ignore » en commentaires subsistent). **0 ignored restant sur sandbox.rs confirmé.**


[LP4-1 — passe de tests d'intégration de l'enforcement Landlock] DevBackend a livré LP4-1 : `crates/infrastructure/src/sandbox/{mod,landlock,noop}.rs`. `LandlockSandbox` (Linux) applique un `SandboxPlan` en restreignant le thread courant (mécanisme : thread jetable + héritage du domaine Landlock au fork/exec, car portable-pty n'expose pas de pre_exec). Son test `landlock_write_only_plan_fences_writes_to_the_grant` passe déjà réellement sur ce kernel.

Renforce/complète la couverture d'intégration (gated `#[cfg(target_os="linux")]` + garde runtime : skip propre si Landlock indisponible, comme les tests SSH/WSL). Couvre impérativement :

1. **PROPRIÉTÉ DE SÛRETÉ CRITIQUE — IdeA n'est jamais sandboxé.** Après un `enforce()` exécuté sur un thread jetable (comme le fait l'adapter), le THREAD principal/process de test (qui simule IdeA) doit toujours pouvoir écrire/lire HORS des racines du plan. Prouve que la restriction est confinée au thread jetable et n'a pas fui sur le process IdeA. C'est le garde-fou n°1 (un Landlock posé sur IdeA serait catastrophique).
2. **Dimension READ (RO).** Un plan avec un grant RO sur une racine donnée : depuis l'enfant restreint, lire un fichier SOUS la racine accordée ⇒ OK ; lire un fichier HORS racine ⇒ refusé (EACCES). Complète la dimension write déjà testée. (Note remontée par DevBackend, à confirmer par le test : dès qu'un grant RO est posé, TOUTES les lectures hors racines deviennent fermées — donc un plan read-restreint réaliste devra inclure les chemins système ; c'est un concern LP4-2, mais documente le comportement observé par un test.)
3. **Plan vide / bash-only** ⇒ `Enforced`, aucune restriction effective, pas d'erreur.
4. **NoopSandbox** ⇒ `kind()==Unsupported`, `enforce` (y compris posture Deny) ⇒ `Ok(Unsupported)`, jamais Err.
5. **Fail-closed posture Deny** : si tu peux simuler/forcer un cas où Landlock ne s'applique pas (sinon documente pourquoi non testable sur ce kernel), vérifie que posture `Deny` ⇒ `Err(KernelTooOld)` alors que posture non-Deny ⇒ `Ok(Unsupported)`.

Exécute `cargo test -p infrastructure` puis `cargo test --workspace`. Réponds via idea_reply avec : tests ajoutés, lesquels ont réellement tourné vs skippés sur ce kernel, sortie pass/fail, et tout écart de comportement (notamment si la propriété de sûreté n°1 n'est PAS vérifiée — rapport d'erreur immédiat pour DevBackend, c'est bloquant).


LP4-1 — Passe de tests d'intégration de l'enforcement Landlock : VERT. La propriété de sûreté n°1 (IdeA jamais sandboxé) est VÉRIFIÉE. Aucun écart.

## Tests ajoutés (4)
- `sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed` — **PROPRIÉTÉ DE SÛRETÉ n°1**. enforce() lancé sur un thread jetable (comme l'adapter), grant RW sur `granted`. On prouve d'abord que le thread jetable EST sandboxé (write hors racine = PermissionDenied), PUIS que le thread parent (≈ process IdeA) écrit ET lit toujours HORS racines ⇒ le domaine Landlock ne fuit pas sur IdeA.
- `sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant` — **dimension READ (RO)**. Fichiers pré-créés avant enforce. Grant RO sur `granted` : lecture SOUS la racine = OK ; lecture HORS = PermissionDenied (EACCES). Confirme et documente le comportement signalé par DevBackend : dès qu'un grant RO est posé, la classe read est *handled* ⇒ TOUTES les lectures hors racines sont fermées (un plan read-restreint réaliste devra inclure les chemins système — concern LP4-2).
- `sandbox::landlock::tests::empty_plan_under_deny_posture_is_enforced_without_restriction` — **plan vide / bash-only sous posture Deny**. Un plan `allowed` vide (= bash-only après compile LP4-0) avec `Posture::Deny` ⇒ `Enforced` sans aucune restriction et SANS `KernelTooOld` (la branche fail-closed n'est atteinte que si une classe a réellement été *handled*).
- `sandbox::tests::noop_enforcer_is_unsupported_and_never_errors` (renforcé) — **NoopSandbox** : `kind()==Unsupported` ; `enforce` ⇒ `Ok(Unsupported)` jamais Err, désormais bouclé sur les 3 postures (Allow/Ask/Deny) ET avec un grant présent.

(Existants conservés : `landlock_write_only_plan_fences_writes_to_the_grant`, `empty_plan_is_a_noop_enforced`, `default_enforcer_matches_the_build_target`.)

## Réellement tourné vs skippé sur ce kernel
- **Tout a réellement tourné — 0 skip.** Aucune ligne « skipping: Landlock not available » émise (vérifié avec `--nocapture | grep -i skip` ⇒ « no tests skipped »). Les gardes runtime de skip sont en place (comme SSH/WSL) mais inutiles ici : ce kernel supporte Landlock, l'enforcement est réel (FullyEnforced) — write hors-grant et read hors-grant renvoient bien PermissionDenied.

## Invariant 5 (fail-closed posture Deny) — NON testable sur ce kernel, documenté
- La branche `Err(KernelTooOld)` n'est atteinte que si `restrict_self()` renvoie `RulesetStatus::NotEnforced` ET posture==Deny. Sur un kernel Landlock-capable, `restrict_self` renvoie toujours FullyEnforced/PartiallyEnforced, jamais NotEnforced. Impossible de forcer NotEnforced sans injecter/mocker le crate `landlock` (l'adapter ne prend pas de dépendance injectable pour le statut). Le pendant « pas d'OS sandbox + non-Deny ⇒ Ok(Unsupported) » est, lui, couvert par NoopSandbox. Recommandation (non bloquante) pour DevBackend si on veut tester cette branche : extraire le mapping `(RulesetStatus, Posture) -> Result<SandboxStatus, SandboxError>` en fn pure et la tester en table.

## Sortie pass/fail
- `cargo test -p infrastructure` (lib) : **154 passed; 0 failed; 0 ignored**. Bloc sandbox : 8 tests, tous ok (write-only, read-only, safety-confinement, empty, empty+Deny, noop×3-postures, default_enforcer).
- `cargo test -p infrastructure` (tous binaires + doc-tests) : **0 failed**.
- `cargo test --workspace` : tout vert, **0 failed**, aucune erreur de compilation.

Aucune violation de la propriété de sûreté n°1. Rien de bloquant à remonter.


Test bout-en-bout de l'enforcement OS (lot LP4-3, sandbox Landlock), à ajouter et faire passer. Contexte : la chaîne policy → plan → PTY est désormais câblée. DevBackend vient de livrer (non committé) :
- composition root : `PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer())`
- launch path (`crates/application/src/agent/lifecycle.rs`, étape 5d) : `spec.sandbox = compile_sandbox_plan(effective_permissions.as_ref(), &SandboxContext{ project_root, run_dir })`.
- l'enforcement effectif vit dans `PortablePtyAdapter::spawn` (chemin PTY brut), via `spawn_command_sandboxed` (thread restreint par Landlock puis fork).

Objectif : UN test d'intégration de haute fidélité qui prouve que l'enforcement est réellement actif bout-en-bout, SANS lancer de vrai CLI IA (zéro token).

Piste recommandée (la plus fidèle, déjà éprouvée par les tests Landlock existants dans `crates/infrastructure/src/sandbox/landlock.rs`) : passer par `PortablePtyAdapter` réel avec un enforcer Landlock, un `SpawnSpec` dont `sandbox = Some(plan)` où le plan autorise un répertoire et un `Deny` effectif sur un autre, et une commande shell inoffensive (`sh -c 'echo x > /chemin/interdit'`) ; asserter que l'écriture échoue (fichier absent / exit non nul) alors qu'une écriture dans le chemin autorisé réussit. Skippe proprement (`#[cfg(target_os="linux")]` + détection de l'ABI Landlock comme le font les tests existants) si le kernel ne supporte pas Landlock, pour ne pas rendre la CI rouge sur un runner sans Landlock.

Réutilise au maximum les helpers/patterns des tests Landlock existants (regarde `sandbox/landlock.rs` tests : read_only_plan_fences_reads_to_the_grant, landlock_write_only_plan_fences_writes_to_the_grant, enforcement_is_confined_to_the_enforcing_thread). Le test doit cibler le chemin PTY (pas structuré).

À la fin : exécute le test et donne la sortie réelle. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : le fichier de test ajouté, ce qu'il prouve, la stratégie de skip, et la sortie brute d'exécution. Si ça échoue, rapport d'erreur clair avec la sortie — on renverra à DevBackend.


✅ VERT — test bout-en-bout de l'enforcement OS (lot LP4-3) ajouté et passant, enforcement Landlock réellement actif sur le chemin PTY (pas skippé sur ce kernel).

## Fichier modifié
`crates/infrastructure/src/pty/mod.rs` — nouveau module de test `#[cfg(all(test, target_os = "linux"))] mod sandbox_e2e_tests` (en plus du module `tests` existant, intact). 2 tests + helpers.

## Ce que ça prouve
Test principal **`pty_spawn_enforces_sandbox_plan_end_to_end`** — exerce la chaîne réelle complète, ZÉRO CLI IA / ZÉRO token :
`PortablePtyAdapter::new().with_sandbox_enforcer(default_enforcer())` (= LandlockSandbox) → `SpawnSpec { sandbox: Some(plan) }` avec `plan` = `PathGrant{ allowed RW }` seul, posture `Ask` → `adapter.spawn(...)` → branche `(Some(plan), Some(enforcer))` → `spawn_command_sandboxed` (thread restreint par Landlock puis fork/exec).
- Commande inoffensive : `sh -c "echo outside > <denied>/out.txt; echo inside > <allowed>/in.txt"`.
- **Ordre volontaire** : l'écriture HORS-grant d'abord, l'écriture DANS le grant ensuite. L'apparition du marqueur `allowed/in.txt` est donc un signal *happens-after* prouvant que la tentative hors-grant a déjà eu lieu → assertion déterministe, pas de course avec l'enfant.
- Asserts : (1) `allowed/in.txt` finit par exister (écriture autorisée passe ; reads non gouvernés car seul RW posé → `sh`/libc chargent normalement) ; (2) `denied/out.txt` **n'existe PAS** → l'écriture hors-grant a été bloquée par le kernel = « SANDBOX BREACH » sinon.

Test compagnon **`pty_spawn_without_plan_does_not_sandbox`** — même adapter+enforcer mais `sandbox: None` → l'écriture dans le même dossier réussit. Garde-fou anti faux-positif : prouve que le blocage du 1er test vient bien du *plan enforcé*, pas d'une restriction PTY ambiante ou d'un échec d'écriture sans rapport.

## Stratégie de skip (CI/kernel sans Landlock)
- Tout le module est `#[cfg(target_os = "linux")]` (pas de compilation hors Linux).
- Helper `landlock_is_enforced()` qui sonde l'ABI exactement comme les tests adapter existants : `LandlockSandbox::enforce(plan RW)` sur un **thread jetable** (restriction irréversible → jamais sur le thread de test), skip si `SandboxStatus::Unsupported`. Le test principal `return` proprement avec message `eprintln!` si non enforcé → CI verte sur runner sans Landlock LSM.
- Helpers réutilisés/calqués sur `sandbox/landlock.rs` : `fresh_dir(tag)` (temp dir unique, zéro dép tempfile), enforce sur thread jetable, posture `Ask`. Attente bornée via `wait_for(path, 5s)`.

## Sortie brute d'exécution
```
$ cargo test -p infrastructure --lib sandbox_e2e_tests -- --nocapture --test-threads=1
    Finished `test` profile [unoptimized + debuginfo] target(s) in 1.53s
     Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4)

running 2 tests
test pty::sandbox_e2e_tests::pty_spawn_enforces_sandbox_plan_end_to_end ... ok
test pty::sandbox_e2e_tests::pty_spawn_without_plan_does_not_sandbox ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 154 filtered out; finished in 0.05s
```

Preuve que l'enforcement a **réellement** tourné (et non été skippé) sur ce kernel — les tests adapter Landlock existants ne logguent AUCUN « skipping » :
```
$ cargo test -p infrastructure --lib sandbox::landlock -- --nocapture
test sandbox::landlock::tests::read_only_plan_fences_reads_to_the_grant ... ok
test sandbox::landlock::tests::landlock_write_only_plan_fences_writes_to_the_grant ... ok
test sandbox::landlock::tests::enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed ... ok
test result: ok. 5 passed; 0 failed; 0 ignored; 0 measured; 151 filtered out; finished in 0.00s
```

Pas de commit (figé par l'orchestrateur, comme demandé). Aucune régression introduite : ajout de tests uniquement, module `tests` PTY existant inchangé.


Tests bout-en-bout du lot LP4-4 — enforcement Landlock sur le chemin STRUCTURÉ (sessions Claude/Codex mode JSON). DevBackend a livré (non committé), build + suites existantes vertes. L'Architecte a validé l'approche et défini 7 invariants à couvrir. Objectif : prouver l'enforcement réellement actif bout-en-bout sur le chemin structuré, ZÉRO token (aucun vrai claude/codex — utilise un fake CLI / `sh` qui émet une ligne JSONL).

Chaîne réelle livrée : `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())` (composition root) ; `AgentSessionFactory::start(.., sandbox: Option<&SandboxPlan>)` apparie plan (par-appel) + enforcer (par-instance) ; `SpawnLine.sandbox: Option<SandboxPlan>` ; `run_turn(spec, timeout, enforcer)` route vers `run_turn_sandboxed`/`drain_sandboxed` (`#[cfg(target_os="linux")]`, thread jetable restreint par enforce() AVANT le spawn std, puis std::process::Command::spawn depuis ce thread → héritage Landlock ; timeout via oneshot killer + tokio::time::timeout ; fail-closed sur Err d'enforce). Pas de pre_exec (forbid(unsafe_code) ; héritage credentials garanti par le noyau).

Réutilise les patterns/helpers des tests Landlock existants (`crates/infrastructure/src/sandbox/landlock.rs` tests + le module `sandbox_e2e_tests` ajouté dans `pty/mod.rs` au lot LP4-3) : `landlock_is_enforced()` (skip propre kernel sans Landlock), enforce sur thread jetable, `fresh_dir`, attente bornée.

LES 7 INVARIANTS À COUVRIR (de l'Architecte) :
1. PARITÉ (test pivot) : fake CLI/`sh` qui émet une ligne JSONL et tente d'écrire HORS grant (doit être bloqué kernel) et DANS grant (doit réussir). Cible le chemin structuré (run_turn_sandboxed via la factory réelle).
2. COMPANION NÉGATIF : même factory+enforcer mais sandbox==None ⇒ écriture hors-grant RÉUSSIT (prouve que le blocage vient du plan, pas d'une restriction ambiante).
3. FAIL-CLOSED : posture Deny sur kernel sans Landlock ⇒ enforce Err ⇒ run_turn renvoie erreur (Start) et AUCUN child ne tourne (marqueur de sortie absent).
4. NO-OP PAR DÉFAUT : eff==None ⇒ plan None ⇒ chemin async tokio actuel, comportement natif (vérifie que conformance/D0/D3 restent verts sans changement de comportement).
5. CONFINEMENT IRRÉVERSIBILITÉ : deux tours successifs (sessions différentes) avec grants DISJOINTS ⇒ chacun voit son propre périmètre, le tour précédent n'affecte pas le suivant (thread jetable mort).
6. TIMEOUT SOUS SANDBOX : fake CLI qui ne ferme jamais stdout ⇒ run_turn(timeout) tue le child et renvoie Timeout, sans zombie ni thread bloqué.
7. RESUME PRÉSERVÉ : le conversation_id/session_id est toujours capté correctement après un tour sandboxé (la restriction FS ne casse pas le parsing).

Tout derrière `#[cfg(target_os="linux")]` + skip via `landlock_is_enforced()`. À la fin : exécute et donne la sortie BRUTE. NE committe PAS (figé par l'orchestrateur). Rends via idea_reply : fichier(s) de test ajoutés, mapping test→invariant (les 7), stratégie de skip, et sortie d'exécution réelle. Si un invariant ne passe pas, rapport d'erreur clair avec sortie brute → je renvoie à DevBackend.


✅ VERT — Tests bout-en-bout LP4-4 (enforcement Landlock sur le chemin STRUCTURÉ, sessions Claude/Codex JSON) ajoutés et passants. Les 7 invariants de l'Architecte couverts. ZÉRO token (aucun vrai claude/codex : `sh` qui émet une ligne JSONL + tente des écritures FS, ou le `FakeCli` scriptable existant).

## Fichiers
- **Ajouté** : `crates/infrastructure/src/session/sandbox_e2e.rs` — 7 tests + helpers, derrière `#[cfg(all(test, target_os = "linux"))]`.
- **Modifié** : `crates/infrastructure/src/session/mod.rs` — déclaration `#[cfg(all(test, target_os = "linux"))] mod sandbox_e2e;` (uniquement la ligne de module, suites existantes intactes).

## Mapping test → invariant (les 7)
1. **PARITÉ (pivot)** → `pty_structured_run_turn_enforces_plan_end_to_end` : `run_turn` + plan RW(allowed) + enforcer Landlock. Asserte : la ligne JSONL est **drainée** (parsing intact sous sandbox), écriture in-grant réussit, écriture hors-grant **bloquée noyau** (fichier absent). Exact pendant du pivot PTY LP4-3, mais via `process::run_turn_sandboxed`.
2. **COMPANION NÉGATIF** → `structured_run_turn_without_plan_does_not_sandbox` : même enforcer câblé mais `sandbox==None` ⇒ écriture hors-grant RÉUSSIT ⇒ le blocage (1) vient du plan, pas d'une restriction ambiante.
3. **FAIL-CLOSED** → `structured_run_turn_fail_closed_no_child_on_enforce_err` : enforcer double `AlwaysFailEnforcer` (renvoie `SandboxError::KernelTooOld`, simule fidèlement « Deny + kernel sans Landlock » de façon **déterministe**, indépendamment du kernel de CI). `run_turn` ⇒ `AgentSessionError::Start` ET le marqueur que le child aurait écrit reste **absent** (aucun enfant lancé).
4. **NO-OP PAR DÉFAUT** → `structured_run_turn_none_plan_is_native_path` : `plan==None` (même avec enforcer fourni) ⇒ chemin async tokio historique, lignes drainées + écriture arbitraire réussit (aucune restriction). Non-régression confirmée par la suite `session::` complète (cf. ci-dessous).
5. **CONFINEMENT/IRRÉVERSIBILITÉ** → `structured_two_turns_disjoint_grants_are_confined` : deux `run_turn` successifs à grants DISJOINTS (A puis B). Chacun écrit dans son grant (ok) et dans l'autre (bloqué). Le tour B écrit bien dans B ⇒ la restriction du thread jetable de A **n'a pas bavé**.
6. **TIMEOUT SOUS SANDBOX** → `structured_run_turn_timeout_under_sandbox` : `sleep 30` (stdout jamais fermé) via la branche sandboxée, `run_turn(timeout 250ms)` ⇒ `AgentSessionError::Timeout` rapidement (asserté < 10s, pas d'attente des 30s) ⇒ killer oneshot + `tokio::time::timeout` OK, pas de thread bloqué.
7. **RESUME PRÉSERVÉ** → `structured_sandboxed_turn_preserves_conversation_id` : via la **fabrique réelle** `StructuredSessionFactory::new().with_sandbox_enforcer(default_enforcer())`, profil Claude sur `FakeCli` (init+result), `start(.., Some(&plan))`. Sous enforcement actif, `send()` rend exactement un `Final` et `conversation_id()` ⇒ `Some("conv-sbx-1")` ⇒ la restriction FS ne casse pas le parsing/la capture d'id.

## Stratégie de skip
- Tout le module est `#[cfg(all(test, target_os = "linux"))]` (rien hors Linux).
- Helper `landlock_is_enforced()` (calqué sur `sandbox/landlock.rs` et `pty::sandbox_e2e_tests`) : `enforce` d'un plan RW sur **thread jetable** (restriction irréversible ⇒ jamais sur le thread de test), skip propre (`eprintln!` + `return`) si `SandboxStatus::Unsupported`.
- Gardés par `landlock_is_enforced()` (exigent un fencing réel) : invariants **1, 5, 7**. Indépendants du kernel (déterministes sans Landlock) : **2** (contrôle natif), **3** (fake enforcer), **4** (plan None), **6** (timeout — branche sandboxée prise sous posture Ask, enforce Ok même sans Landlock).
- Helpers réutilisés : `fresh_dir`, posture `Ask`, ligne JSONL `result` réaliste (format Claude vérifié), `FakeCli` de `conformance`.

## Sortie brute d'exécution
```
$ cargo test -p infrastructure --lib session::sandbox_e2e -- --nocapture --test-threads=1
    Finished `test` profile [unoptimized + debuginfo] target(s) in 5.01s
     Running unittests src/lib.rs (target/debug/deps/infrastructure-2cc30d2a1793b4e4)

running 7 tests
test session::sandbox_e2e::pty_structured_run_turn_enforces_plan_end_to_end ... ok
test session::sandbox_e2e::structured_run_turn_fail_closed_no_child_on_enforce_err ... ok
test session::sandbox_e2e::structured_run_turn_none_plan_is_native_path ... ok
test session::sandbox_e2e::structured_run_turn_timeout_under_sandbox ... ok
test session::sandbox_e2e::structured_run_turn_without_plan_does_not_sandbox ... ok
test session::sandbox_e2e::structured_sandboxed_turn_preserves_conversation_id ... ok
test session::sandbox_e2e::structured_two_turns_disjoint_grants_are_confined ... ok

test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 156 filtered out; finished in 0.27s
```
**Aucune ligne « skip » imprimée** (run `--nocapture`) ⇒ les invariants gardés (1, 5, 7) ont **réellement** posé l'enforcement sur ce kernel (Landlock actif), pas été contournés.

Non-régression (invariant 4 : conformance/D0/D3 + voisins) :
```
=== session ===   test result: ok. 56 passed; 0 failed; ...
=== sandbox ===   test result: ok. 7 passed;  0 failed; ...
=== pty ===       test result: ok. 5 passed;  0 failed; ...
```

Pas de commit (figé par l'orchestrateur, comme demandé). Ajout de tests uniquement + 1 ligne de déclaration de module ; aucun code de prod touché.