//! **Tests bout-en-bout de l'enforcement Landlock sur le chemin STRUCTURÉ** (lot //! LP4-4). Pair du module `sandbox_e2e_tests` ajouté dans [`crate::pty`] au lot //! LP4-3 (chemin PTY brut) ; ici la cible est la machinerie des sessions structurées //! (Claude/Codex en mode JSON) : `run_turn` → `run_turn_sandboxed` → `drain_sandboxed` //! (thread jetable restreint par `enforce()` AVANT le spawn std, puis héritage du //! domaine Landlock à travers `fork`/`exec`). //! //! **ZÉRO token** : aucun vrai `claude`/`codex` n'est lancé. On substitue soit `sh` //! (qui émet une ligne JSONL puis tente des écritures FS), soit le [`FakeCli`] //! scriptable existant. Tout est derrière `#[cfg(all(test, target_os = "linux"))]` //! et — pour les assertions qui exigent un fencing réel — derrière le garde //! [`landlock_is_enforced`] (skip propre sur un kernel sans Landlock, comme les //! tests de l'adapter et du PTY). //! //! ## Couverture des 7 invariants (Architecte) //! //! 1. **Parité** — `pty_structured_run_turn_enforces_plan_end_to_end` //! 2. **Companion négatif** — `structured_run_turn_without_plan_does_not_sandbox` //! 3. **Fail-closed** — `structured_run_turn_fail_closed_no_child_on_enforce_err` //! 4. **No-op par défaut** — `structured_run_turn_none_plan_is_native_path` //! 5. **Confinement/irréversibilité** — `structured_two_turns_disjoint_grants_are_confined` //! 6. **Timeout sous sandbox** — `structured_run_turn_timeout_under_sandbox` //! 7. **Resume préservé** — `structured_sandboxed_turn_preserves_conversation_id` use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; use domain::sandbox::{ PathAccess, PathGrant, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus, }; use domain::Posture; use super::process::{run_turn, SpawnLine}; // --------------------------------------------------------------------------- // Helpers (calqués sur sandbox/landlock.rs et pty::sandbox_e2e_tests) // --------------------------------------------------------------------------- /// Un répertoire temporaire unique pour un test (zéro dépendance tempfile). fn fresh_dir(tag: &str) -> PathBuf { let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos(); let p = std::env::temp_dir().join(format!("idea-struct-sbx-{tag}-{nanos}")); std::fs::create_dir_all(&p).unwrap(); p } /// Sonde si le kernel courant **applique réellement** Landlock, exactement comme le /// chemin de lancement l'observerait : `enforce` d'un petit plan RW sur un **thread /// jetable** (la restriction est irréversible ⇒ jamais sur le thread de test) et /// renvoie `false` sur [`SandboxStatus::Unsupported`]. Permet le skip propre. fn landlock_is_enforced() -> bool { let probe = fresh_dir("probe"); let plan = SandboxPlan { allowed: vec![PathGrant { abs_root: probe.to_string_lossy().into_owned(), access: PathAccess::RW, }], default_posture: Posture::Ask, }; let status = std::thread::spawn(move || crate::sandbox::LandlockSandbox::new().enforce(&plan)) .join() .unwrap() .expect("enforce ne doit pas échouer sous une posture Ask"); let _ = std::fs::remove_dir_all(&probe); !matches!(status, SandboxStatus::Unsupported) } /// Plan RW sur un seul répertoire, posture `Ask` (jamais fail-closed). fn rw_plan(root: &Path) -> SandboxPlan { SandboxPlan { allowed: vec![PathGrant { abs_root: root.to_string_lossy().into_owned(), access: PathAccess::RW, }], default_posture: Posture::Ask, } } /// Construit un [`SpawnLine`] sur `sh -c` qui **émet une ligne JSONL** sur stdout /// (preuve que le drain/parsing du chemin structuré survit au sandbox) puis tente /// d'écrire HORS grant **puis** DANS le grant. L'écriture hors-grant est tentée en /// premier : si le marqueur in-grant apparaît, la tentative hors-grant a déjà eu lieu. /// `json_line` ne doit contenir que des guillemets doubles (pas de quote simple). fn writing_spawn_line( json_line: &str, denied_marker: &Path, allowed_marker: &Path, plan: Option, ) -> SpawnLine { let script = format!( "printf '%s\\n' '{json}'; echo outside > '{denied}'; echo inside > '{allowed}'", json = json_line, denied = denied_marker.display(), allowed = allowed_marker.display(), ); SpawnLine { command: "sh".to_owned(), args: vec!["-c".to_owned(), script], cwd: "/".to_owned(), env: Vec::new(), stdin: None, sandbox: plan, } } /// Une ligne JSONL `result` réaliste (format Claude vérifié) — sans quote simple. const RESULT_LINE: &str = r#"{"type":"result","subtype":"success","is_error":false,"result":"ok","session_id":"s-1"}"#; // --------------------------------------------------------------------------- // INVARIANT 1 — PARITÉ (test pivot) // --------------------------------------------------------------------------- /// **Pivot.** Le chemin structuré sandboxé (`run_turn` + plan + enforcer Landlock) /// laisse l'écriture DANS le grant réussir, bloque (noyau) l'écriture HORS grant, et /// **draine quand même** la ligne JSONL émise sur stdout (le parsing n'est pas cassé /// par la restriction FS). Strictement équivalent au pivot PTY du lot LP4-3, mais via /// la machinerie `process::run_turn` (chemin Claude/Codex JSON). #[tokio::test] async fn pty_structured_run_turn_enforces_plan_end_to_end() { if !landlock_is_enforced() { eprintln!("skip pty_structured_run_turn_enforces_plan_end_to_end: Landlock indisponible"); return; } let allowed = fresh_dir("p1-allowed"); let denied = fresh_dir("p1-denied"); let allowed_marker = allowed.join("in.txt"); let denied_marker = denied.join("out.txt"); let spec = writing_spawn_line( RESULT_LINE, &denied_marker, &allowed_marker, Some(rw_plan(&allowed)), ); let enforcer = crate::sandbox::default_enforcer(); let lines = run_turn(&spec, None, Some(&enforcer)) .await .expect("run_turn sandboxé réussit sous posture Ask"); // Le drain a bien remonté la ligne JSONL (parsing intact sous sandbox). assert_eq!( lines, vec![RESULT_LINE.to_owned()], "la ligne JSONL doit être drainée malgré la restriction FS" ); // L'écriture in-grant a réussi… assert!( allowed_marker.exists(), "écriture in-grant doit réussir: {allowed_marker:?} absent — le grant a été mal fencé" ); // …et l'écriture hors-grant a été bloquée par le noyau. assert!( !denied_marker.exists(), "BRÈCHE SANDBOX: écriture hors-grant réussie ({denied_marker:?}) — plan NON enforce sur le chemin structuré" ); let _ = std::fs::remove_dir_all(&allowed); let _ = std::fs::remove_dir_all(&denied); } // --------------------------------------------------------------------------- // INVARIANT 2 — COMPANION NÉGATIF // --------------------------------------------------------------------------- /// Même machinerie + enforcer câblé, mais `sandbox == None` ⇒ l'écriture hors-grant /// RÉUSSIT. Prouve que le blocage de l'invariant 1 vient du **plan enforce**, pas /// d'une restriction ambiante du chemin structuré. (Contrôle anti faux-positif.) #[tokio::test] async fn structured_run_turn_without_plan_does_not_sandbox() { let allowed = fresh_dir("p2-allowed"); let denied = fresh_dir("p2-denied"); let allowed_marker = allowed.join("in.txt"); let denied_marker = denied.join("out.txt"); // plan = None ⇒ chemin natif, l'enforcer est ignoré même s'il est fourni. let spec = writing_spawn_line(RESULT_LINE, &denied_marker, &allowed_marker, None); let enforcer = crate::sandbox::default_enforcer(); let lines = run_turn(&spec, None, Some(&enforcer)) .await .expect("run_turn natif réussit"); assert_eq!(lines, vec![RESULT_LINE.to_owned()]); assert!(allowed_marker.exists(), "écriture in-grant réussit (sans plan)"); assert!( denied_marker.exists(), "sans plan, l'écriture hors-grant DOIT réussir: {denied_marker:?} absent — restriction ambiante anormale" ); let _ = std::fs::remove_dir_all(&allowed); let _ = std::fs::remove_dir_all(&denied); } // --------------------------------------------------------------------------- // INVARIANT 3 — FAIL-CLOSED // --------------------------------------------------------------------------- /// Enforcer double qui **échoue toujours** — simule fidèlement le cas réel /// « posture Deny sur kernel sans Landlock » (où `enforce` renvoie /// [`SandboxError::KernelTooOld`]), de façon **déterministe** et indépendante du /// kernel de CI (impossible de forcer `NotEnforced` sur une machine Landlock-capable). #[derive(Debug)] struct AlwaysFailEnforcer; impl SandboxEnforcer for AlwaysFailEnforcer { fn kind(&self) -> SandboxKind { SandboxKind::Landlock } fn enforce(&self, _plan: &SandboxPlan) -> Result { Err(SandboxError::KernelTooOld( "simulé: Landlock indisponible + posture Deny ⇒ fail-closed".to_owned(), )) } } /// **Fail-closed.** Quand `enforce` renvoie `Err`, `run_turn` (chemin sandboxé) doit /// remonter [`AgentSessionError::Start`] et **aucun enfant ne tourne** : le marqueur /// que le child aurait écrit reste absent. C'est le câblage critique de sécurité du /// chemin structuré (équivalent du fail-closed PTY). #[tokio::test] async fn structured_run_turn_fail_closed_no_child_on_enforce_err() { use domain::ports::AgentSessionError; let dir = fresh_dir("p3"); let marker = dir.join("should-not-exist.txt"); // Plan posture Deny (cohérent avec le scénario simulé) + enforcer qui échoue. let plan = SandboxPlan { allowed: vec![PathGrant { abs_root: dir.to_string_lossy().into_owned(), access: PathAccess::RW, }], default_posture: Posture::Deny, }; // Le child écrirait CE marqueur s'il était lancé : il ne doit jamais l'être. let script = format!("echo ran > '{}'", marker.display()); let spec = SpawnLine { command: "sh".to_owned(), args: vec!["-c".to_owned(), script], cwd: "/".to_owned(), env: Vec::new(), stdin: None, sandbox: Some(plan), }; let enforcer: Arc = Arc::new(AlwaysFailEnforcer); let err = run_turn(&spec, None, Some(&enforcer)) .await .expect_err("enforce Err ⇒ run_turn doit échouer (fail-closed)"); assert!( matches!(err, AgentSessionError::Start(_)), "fail-closed doit remonter Start, vu: {err:?}" ); assert!( !marker.exists(), "FAIL-CLOSED VIOLÉ: un enfant a tourné ({marker:?} existe) malgré l'échec d'enforce" ); let _ = std::fs::remove_dir_all(&dir); } // --------------------------------------------------------------------------- // INVARIANT 4 — NO-OP PAR DÉFAUT // --------------------------------------------------------------------------- /// **No-op par défaut.** `plan == None` (effective_permissions None) ⇒ le chemin async /// tokio historique est emprunté, comportement natif inchangé : le drain remonte les /// lignes et **aucune** restriction n'est posée (une écriture arbitraire réussit), /// y compris quand un enforcer est pourtant câblé sur la fabrique. La non-régression /// du gros de la suite structurée (conformance/D0/D3) confirme l'absence d'effet de bord. #[tokio::test] async fn structured_run_turn_none_plan_is_native_path() { let dir = fresh_dir("p4"); let marker = dir.join("native.txt"); let script = format!("printf '%s\\n' '{RESULT_LINE}'; echo ok > '{}'", marker.display()); let spec = SpawnLine { command: "sh".to_owned(), args: vec!["-c".to_owned(), script], cwd: "/".to_owned(), env: Vec::new(), stdin: None, sandbox: None, // ⇐ rien posé ⇒ chemin natif }; let enforcer = crate::sandbox::default_enforcer(); // Enforcer fourni MAIS plan None ⇒ la branche sandboxée n'est pas prise. let lines = run_turn(&spec, None, Some(&enforcer)) .await .expect("chemin natif réussit"); assert_eq!(lines, vec![RESULT_LINE.to_owned()]); assert!( marker.exists(), "sans plan, aucune restriction: l'écriture native doit réussir" ); let _ = std::fs::remove_dir_all(&dir); } // --------------------------------------------------------------------------- // INVARIANT 5 — CONFINEMENT / IRRÉVERSIBILITÉ // --------------------------------------------------------------------------- /// **Confinement.** Deux tours successifs (instances `run_turn` distinctes) avec des /// grants **disjoints** : chacun ne voit que son propre périmètre. Le thread jetable /// du 1er tour meurt avec sa restriction ⇒ le 2e tour n'en hérite pas (et inversement). /// Prouve que l'enforcement ne « bave » pas d'un tour à l'autre ni sur IdeA. #[tokio::test] async fn structured_two_turns_disjoint_grants_are_confined() { if !landlock_is_enforced() { eprintln!("skip structured_two_turns_disjoint_grants_are_confined: Landlock indisponible"); return; } let dir_a = fresh_dir("p5-a"); let dir_b = fresh_dir("p5-b"); let enforcer = crate::sandbox::default_enforcer(); // --- Tour A : grant = dir_a. Écrit dans A (ok) puis B (bloqué). --- let a_in = dir_a.join("in.txt"); let a_into_b = dir_b.join("from-a.txt"); let spec_a = writing_spawn_line(RESULT_LINE, &a_into_b, &a_in, Some(rw_plan(&dir_a))); run_turn(&spec_a, None, Some(&enforcer)) .await .expect("tour A ok"); assert!(a_in.exists(), "tour A: écriture dans son grant (A) doit réussir"); assert!( !a_into_b.exists(), "tour A: écriture dans B (hors grant A) doit être bloquée" ); // --- Tour B : grant = dir_b. Écrit dans B (ok) puis A (bloqué). --- // Si la restriction du tour A avait bavé, l'écriture dans B échouerait ici. let b_in = dir_b.join("in.txt"); let b_into_a = dir_a.join("from-b.txt"); let spec_b = writing_spawn_line(RESULT_LINE, &b_into_a, &b_in, Some(rw_plan(&dir_b))); run_turn(&spec_b, None, Some(&enforcer)) .await .expect("tour B ok"); assert!( b_in.exists(), "tour B: écriture dans son grant (B) doit réussir — la restriction du tour A n'a pas bavé" ); assert!( !b_into_a.exists(), "tour B: écriture dans A (hors grant B) doit être bloquée" ); let _ = std::fs::remove_dir_all(&dir_a); let _ = std::fs::remove_dir_all(&dir_b); } // --------------------------------------------------------------------------- // INVARIANT 6 — TIMEOUT SOUS SANDBOX // --------------------------------------------------------------------------- /// **Timeout sous sandbox.** Un enfant qui ne ferme jamais stdout (`sleep`) lancé via /// le chemin sandboxé : `run_turn(timeout)` doit **tuer** l'enfant et renvoyer /// [`AgentSessionError::Timeout`], rapidement (≪ durée du sleep) — preuve que le killer /// oneshot + `tokio::time::timeout` fonctionnent et qu'aucun thread ne reste bloqué. #[tokio::test] async fn structured_run_turn_timeout_under_sandbox() { use domain::ports::AgentSessionError; let dir = fresh_dir("p6"); // `sleep 30` garde stdout ouvert ⇒ le drain bloquerait sans le killer du timeout. let spec = SpawnLine { command: "sleep".to_owned(), args: vec!["30".to_owned()], cwd: "/".to_owned(), env: Vec::new(), stdin: None, sandbox: Some(rw_plan(&dir)), // ⇒ branche sandboxée (posture Ask ⇒ enforce Ok) }; let enforcer = crate::sandbox::default_enforcer(); let started = Instant::now(); let err = run_turn(&spec, Some(Duration::from_millis(250)), Some(&enforcer)) .await .expect_err("doit expirer"); let elapsed = started.elapsed(); assert!( matches!(err, AgentSessionError::Timeout), "le tour sandboxé doit expirer en Timeout, vu: {err:?}" ); assert!( elapsed < Duration::from_secs(10), "le timeout doit tuer l'enfant promptement (vu {elapsed:?}) — pas d'attente des 30s" ); let _ = std::fs::remove_dir_all(&dir); } // --------------------------------------------------------------------------- // INVARIANT 7 — RESUME PRÉSERVÉ (via la fabrique réelle + adapter Claude) // --------------------------------------------------------------------------- /// **Resume préservé.** Après un tour réellement sandboxé, l'`session_id`/conversation_id /// est toujours capté correctement : la restriction FS ne casse pas le parsing du flux. /// Passe par la **fabrique réelle** `StructuredSessionFactory::with_sandbox_enforcer`, /// un profil Claude branché sur un [`FakeCli`] (init + result), et un plan transmis à /// `start(.., Some(&plan))`. Le fake n'écrit aucun fichier (il imprime sur un pipe, hors /// périmètre Landlock-FS), donc un plan write-only suffit : on prouve que la capture /// d'id survit au domaine actif. #[tokio::test] async fn structured_sandboxed_turn_preserves_conversation_id() { use domain::ids::ProfileId; use domain::ports::{AgentSessionFactory, PreparedContext, ReplyEvent, SessionPlan}; use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter}; use domain::project::ProjectPath; use domain::MarkdownDoc; use super::conformance::FakeCli; use super::factory::StructuredSessionFactory; if !landlock_is_enforced() { eprintln!("skip structured_sandboxed_turn_preserves_conversation_id: Landlock indisponible"); return; } let run_dir = fresh_dir("p7"); let fake = FakeCli::printing(&[ r#"{"type":"system","subtype":"init","session_id":"conv-sbx-1","cwd":"/tmp","tools":[]}"#, r#"{"type":"result","subtype":"success","is_error":false,"result":"réponse","session_id":"conv-sbx-1","num_turns":1}"#, ]); let profile = AgentProfile::new( ProfileId::new_random(), "Claude sandboxé", &fake.command(), Vec::new(), ContextInjection::convention_file("CLAUDE.md").expect("convention file valide"), None, "{agentRunDir}", None, ) .expect("profil valide") .with_structured_adapter(StructuredAdapter::Claude); let factory = StructuredSessionFactory::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()); let ctx = PreparedContext { content: MarkdownDoc::new("# ctx"), relative_path: "CLAUDE.md".to_owned(), }; let cwd = ProjectPath::new(run_dir.to_string_lossy().into_owned()).expect("cwd absolu"); let plan = rw_plan(&run_dir); // plan write-only ⇒ reads/exec du fake non gênés let session = factory .start(&profile, &ctx, &cwd, &SessionPlan::None, Some(&plan)) .await .expect("start sandboxé ok"); // Avant tout tour : aucun id moteur. assert_eq!(session.conversation_id(), None); // Un tour sandboxé : le flux doit porter un Final ET capter l'id. let events: Vec = session.send("salut").await.expect("send ok").collect(); let finals = events .iter() .filter(|e| matches!(e, ReplyEvent::Final { .. })) .count(); assert_eq!(finals, 1, "un Final attendu malgré le sandbox, vu: {events:?}"); // LE POINT : l'id de conversation a bien été capté sous enforcement actif. assert_eq!( session.conversation_id().as_deref(), Some("conv-sbx-1"), "la restriction FS ne doit pas casser la capture du conversation_id (resume)" ); let _ = std::fs::remove_dir_all(&run_dir); }