diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index b4fe994..93ac7a2 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -404,8 +404,10 @@ impl AppState { // `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et // ChangeAgentProfile (shutdown polymorphe au hot-swap). let structured_sessions = Arc::new(StructuredSessions::new()); - let session_factory = - Arc::new(StructuredSessionFactory::new()) as Arc; + let session_factory = Arc::new( + StructuredSessionFactory::new() + .with_sandbox_enforcer(infrastructure::default_enforcer()), + ) as Arc; let open_terminal = Arc::new(OpenTerminal::new( Arc::clone(&pty_port), diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index 025f2de..4e8bf29 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -28,7 +28,7 @@ use domain::{ ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; -use domain::sandbox::{compile_sandbox_plan, SandboxContext}; +use domain::sandbox::{compile_sandbox_plan, SandboxContext, SandboxPlan}; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; @@ -1569,6 +1569,7 @@ impl LaunchAgent { &input.project.root, input.node_id, size, + spec.sandbox.as_ref(), ) .await; } @@ -1630,9 +1631,12 @@ impl LaunchAgent { root: &ProjectPath, node_id: Option, size: PtySize, + sandbox: Option<&SandboxPlan>, ) -> Result { + // Relaie le plan de sandbox OS (lot LP4-4) à la fabrique : `spec.sandbox`, + // déjà compilé (pur, domaine) en step 5d. `None` ⇒ exécution native inchangée. let session = factory - .start(profile, prepared, run_dir, session_plan) + .start(profile, prepared, run_dir, session_plan, sandbox) .await .map_err(|e| AppError::Process(e.to_string()))?; diff --git a/crates/application/tests/orchestrator_service.rs b/crates/application/tests/orchestrator_service.rs index fd2ba75..bb0f68e 100644 --- a/crates/application/tests/orchestrator_service.rs +++ b/crates/application/tests/orchestrator_service.rs @@ -3022,6 +3022,7 @@ impl AgentSessionFactory for CountingFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { self.starts.fetch_add(1, Ordering::SeqCst); let id = { diff --git a/crates/application/tests/structured_launch_d3.rs b/crates/application/tests/structured_launch_d3.rs index c3e1b25..45d7738 100644 --- a/crates/application/tests/structured_launch_d3.rs +++ b/crates/application/tests/structured_launch_d3.rs @@ -520,6 +520,7 @@ impl AgentSessionFactory for FakeFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, session: &SessionPlan, + _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { self.starts .lock() @@ -1114,7 +1115,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() { let cwd = ProjectPath::new(ROOT).unwrap(); let session = f .factory - .start(&profile, &ctx, &cwd, &SessionPlan::None) + .start(&profile, &ctx, &cwd, &SessionPlan::None, None) .await .expect("seed structured session"); f.structured.insert(session, agent.id, host); diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index 84238ef..5050088 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -531,6 +531,13 @@ pub trait AgentSessionFactory: Send + Sync { /// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de /// session ([`SessionPlan`] : neuf / assign / resume — réutilise §15). /// + /// `sandbox` est le plan de sandbox OS **par lancement** (lot LP4-4), déjà + /// compilé (pur, domaine) au launch path et porté jusqu'ici comme il l'est pour + /// le chemin PTY via [`SpawnSpec::sandbox`]. `None` ⇒ aucun plan ⇒ exécution + /// native inchangée (invariant produit : rien posé ⇒ rien projeté). Le plan + /// franchit le port en tant que **valeur domaine** ([`crate::sandbox::SandboxPlan`]) ; + /// l'enforcer concret reste côté infra (injecté par instance dans la fabrique). + /// /// # Errors /// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode /// structuré ne peut s'initialiser. @@ -540,6 +547,7 @@ pub trait AgentSessionFactory: Send + Sync { ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + sandbox: Option<&crate::sandbox::SandboxPlan>, ) -> Result, AgentSessionError>; } diff --git a/crates/domain/tests/structured_session_d0.rs b/crates/domain/tests/structured_session_d0.rs index 571af6c..ea136b7 100644 --- a/crates/domain/tests/structured_session_d0.rs +++ b/crates/domain/tests/structured_session_d0.rs @@ -340,6 +340,7 @@ impl AgentSessionFactory for FakeFactory { _ctx: &PreparedContext, _cwd: &ProjectPath, _session: &SessionPlan, + _sandbox: Option<&domain::sandbox::SandboxPlan>, ) -> Result, AgentSessionError> { Ok(Arc::new(FakeSession { id: SessionId::from_uuid(Uuid::from_u128(7)), @@ -394,7 +395,7 @@ async fn fake_factory_supports_only_structured_profiles_and_starts() { }; let cwd = ProjectPath::new("/srv/run").unwrap(); let session = factory - .start(&structured, &ctx, &cwd, &SessionPlan::None) + .start(&structured, &ctx, &cwd, &SessionPlan::None, None) .await .expect("factory starts a session"); assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7))); diff --git a/crates/infrastructure/src/session/claude.rs b/crates/infrastructure/src/session/claude.rs index 3be6da8..8b00329 100644 --- a/crates/infrastructure/src/session/claude.rs +++ b/crates/infrastructure/src/session/claude.rs @@ -13,12 +13,13 @@ //! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte //! le format**, pas la machinerie ni le reste de l'adapter. -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use serde_json::Value; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; use super::process::{run_turn, SpawnLine}; @@ -154,24 +155,36 @@ pub struct ClaudeSdkSession { cwd: String, /// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant. conversation_id: Mutex>, + /// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque + /// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif). + sandbox: Option, + /// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas + /// de sandboxing même si un plan est présent (cohérent avec le chemin PTY). + sandbox_enforcer: Option>, } impl ClaudeSdkSession { /// Construit l'adapter. `command` est le binaire à lancer (injecté ⇒ testable /// avec un fake CLI) ; `seed_conversation_id` amorce la reprise (`SessionPlan:: - /// Resume` côté factory) ou reste `None` pour une conversation neuve. + /// Resume` côté factory) ou reste `None` pour une conversation neuve. `sandbox` / + /// `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS du tour ; `None`/`None` + /// ⇒ chemin natif inchangé. #[must_use] pub fn new( id: SessionId, command: impl Into, cwd: impl Into, seed_conversation_id: Option, + sandbox: Option, + sandbox_enforcer: Option>, ) -> Self { Self { id, command: command.into(), cwd: cwd.into(), conversation_id: Mutex::new(seed_conversation_id), + sandbox, + sandbox_enforcer, } } @@ -201,6 +214,7 @@ impl ClaudeSdkSession { cwd: self.cwd.clone(), env: Vec::new(), stdin: None, + sandbox: self.sandbox.clone(), } } } @@ -217,7 +231,7 @@ impl AgentSession for ClaudeSdkSession { async fn send(&self, prompt: &str) -> Result { let spec = self.build_spawn_line(prompt); - let raw_lines = run_turn(&spec, None).await?; + let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?; let mut events = Vec::new(); let mut captured_id = None; diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index 94042a8..703bbe7 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -10,12 +10,13 @@ //! le format. **Seule [`parse_event`] (et la composition de la commande) porte le //! format Codex** ; la machinerie reste inchangée. -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use async_trait::async_trait; use serde_json::Value; use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream}; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; use super::process::{run_turn, SpawnLine}; @@ -123,23 +124,35 @@ pub struct CodexExecSession { cwd: String, /// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant. conversation_id: Mutex>, + /// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque + /// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif). + sandbox: Option, + /// Enforcer OS **par instance** (lot LP4-4), passé à [`run_turn`]. `None` ⇒ pas + /// de sandboxing même si un plan est présent (cohérent avec le chemin PTY). + sandbox_enforcer: Option>, } impl CodexExecSession { /// Construit l'adapter. `command` est injecté (⇒ testable avec un fake CLI) ; /// `seed_conversation_id` amorce la reprise ou reste `None` (conversation neuve). + /// `sandbox` / `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS ; + /// `None`/`None` ⇒ chemin natif inchangé. #[must_use] pub fn new( id: SessionId, command: impl Into, cwd: impl Into, seed_conversation_id: Option, + sandbox: Option, + sandbox_enforcer: Option>, ) -> Self { Self { id, command: command.into(), cwd: cwd.into(), conversation_id: Mutex::new(seed_conversation_id), + sandbox, + sandbox_enforcer, } } @@ -176,6 +189,7 @@ impl CodexExecSession { cwd: self.cwd.clone(), env: Vec::new(), stdin: None, + sandbox: self.sandbox.clone(), } } } @@ -192,7 +206,7 @@ impl AgentSession for CodexExecSession { async fn send(&self, prompt: &str) -> Result { let spec = self.build_spawn_line(prompt); - let raw_lines = run_turn(&spec, None).await?; + let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?; let mut events = Vec::new(); let mut captured_id = None; diff --git a/crates/infrastructure/src/session/conformance.rs b/crates/infrastructure/src/session/conformance.rs index 79bc53c..416beb4 100644 --- a/crates/infrastructure/src/session/conformance.rs +++ b/crates/infrastructure/src/session/conformance.rs @@ -84,6 +84,7 @@ impl FakeCli { cwd: "/".to_owned(), env: Vec::new(), stdin: None, + sandbox: None, } } } diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index 93b7211..5eedb3e 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -16,6 +16,7 @@ use domain::ports::{ }; use domain::profile::{AgentProfile, StructuredAdapter}; use domain::project::ProjectPath; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::SessionId; use super::claude::ClaudeSdkSession; @@ -23,17 +24,36 @@ use super::codex::CodexExecSession; /// Fabrique infra des sessions structurées, sélectionnée par le profil. /// -/// Sans état : elle instancie l'adapter au vol depuis le profil (le binaire à +/// Quasi sans état : elle instancie l'adapter au vol depuis le profil (le binaire à /// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au -/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). -#[derive(Debug, Default, Clone, Copy)] -pub struct StructuredSessionFactory; +/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). Le seul état +/// porté est l'**enforcer de sandbox OS** optionnel (lot LP4-4), injecté **par +/// instance** au composition root (jumeau de `PortablePtyAdapter::with_sandbox_enforcer`) +/// et apparié au plan **par lancement** dans [`start`](AgentSessionFactory::start). +#[derive(Clone, Default)] +pub struct StructuredSessionFactory { + /// Enforcer OS optionnel passé aux adapters structurés. `None` ⇒ aucun + /// sandboxing (chemin natif inchangé, zéro régression). + sandbox_enforcer: Option>, +} impl StructuredSessionFactory { - /// Construit la fabrique. + /// Construit la fabrique (sans enforcer : chemin natif). #[must_use] - pub const fn new() -> Self { - Self + pub fn new() -> Self { + Self { + sandbox_enforcer: None, + } + } + + /// Builder additif : câble un [`SandboxEnforcer`] OS (lot LP4-4). Jumeau exact de + /// [`crate::PortablePtyAdapter::with_sandbox_enforcer`]. Avec lui, tout lancement + /// structuré dont le plan (`SpawnSpec.sandbox`) est `Some` voit ce plan appliqué + /// sur l'enfant. Sans lui (défaut), aucun tour n'est sandboxé. + #[must_use] + pub fn with_sandbox_enforcer(mut self, enforcer: Arc) -> Self { + self.sandbox_enforcer = Some(enforcer); + self } } @@ -62,6 +82,7 @@ impl AgentSessionFactory for StructuredSessionFactory { _ctx: &PreparedContext, cwd: &ProjectPath, session: &SessionPlan, + sandbox: Option<&SandboxPlan>, ) -> Result, AgentSessionError> { let adapter = profile.structured_adapter.ok_or_else(|| { AgentSessionError::Start(format!( @@ -75,14 +96,25 @@ impl AgentSessionFactory for StructuredSessionFactory { let cwd = cwd.as_str().to_owned(); let seed = seed_conversation_id(session); + // Appariement (lot LP4-4) : plan **par lancement** (param) + enforcer **par + // instance** (champ). Tous deux sont relayés à l'adapter, qui remplira + // `SpawnLine.sandbox` et passera l'enforcer à `run_turn`. `plan == None` ⇒ + // l'adapter reste sur le drain async natif. + let plan = sandbox.cloned(); + let enforcer = self.sandbox_enforcer.clone(); + // NOTE : le contexte (`_ctx`) est injecté par `LaunchAgent` (D3) via le // convention file dans le run dir *avant* l'appel à la factory (le `.md` est // déjà écrit) ; l'adapter n'a donc qu'à lancer la CLI dans ce cwd. Aucune // injection supplémentaire n'incombe ici en mode structuré (la CLI lit son // fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd). let session: Arc = match adapter { - StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(id, command, cwd, seed)), - StructuredAdapter::Codex => Arc::new(CodexExecSession::new(id, command, cwd, seed)), + StructuredAdapter::Claude => { + Arc::new(ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer)) + } + StructuredAdapter::Codex => { + Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer)) + } }; Ok(session) } diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index 89e3951..0d6a3b9 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -25,6 +25,11 @@ pub mod conformance; pub mod factory; pub mod process; +/// Tests bout-en-bout de l'enforcement Landlock sur le chemin structuré (lot LP4-4), +/// Linux uniquement (pair du module `pty::sandbox_e2e_tests` du lot LP4-3). +#[cfg(all(test, target_os = "linux"))] +mod sandbox_e2e; + pub use claude::ClaudeSdkSession; pub use codex::CodexExecSession; pub use conformance::FakeCli; @@ -84,7 +89,7 @@ mod tests { #[tokio::test] async fn run_turn_drains_every_line_in_order() { let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]); - let lines = run_turn(&fake.spawn_line(), None) + let lines = run_turn(&fake.spawn_line(), None, None) .await .expect("run_turn réussit"); assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]); @@ -98,8 +103,9 @@ mod tests { cwd: "/".to_owned(), env: Vec::new(), stdin: None, + sandbox: None, }; - let err = run_turn(&spec, None).await.expect_err("doit échouer"); + let err = run_turn(&spec, None, None).await.expect_err("doit échouer"); assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}"); } @@ -284,6 +290,8 @@ mod tests { fake.command(), "/", None, + None, + None, )); assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await; } @@ -296,6 +304,8 @@ mod tests { fake.command(), "/", None, + None, + None, )); assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await; } @@ -305,7 +315,7 @@ mod tests { #[tokio::test] async fn stream_is_closed_after_final() { let fake = FakeCli::printing(&claude_script()); - let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None); + let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None); let stream = session.send("x").await.expect("send ok"); let events: Vec<_> = stream.collect(); let after_final = events @@ -323,7 +333,7 @@ mod tests { r#"{"type":"system","subtype":"init","session_id":"c"}"#, "{ ceci n'est pas du json", ]); - let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None); + let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None); match session.send("x").await { Err(AgentSessionError::Decode(_)) => {} Err(other) => panic!("attendu Decode, vu: {other:?}"), @@ -404,7 +414,7 @@ mod tests { // Claude : la session démarre et respecte le contrat via le fake CLI. let claude = structured_profile(StructuredAdapter::Claude, &fake.command()); let session = factory - .start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None) + .start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None, None) .await .expect("start Claude ok"); let content = drain_final(session.as_ref()).await; @@ -414,7 +424,7 @@ mod tests { let fake_cx = FakeCli::printing(&codex_script()); let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command()); let session_cx = factory - .start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None) + .start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None, None) .await .expect("start Codex ok"); let content_cx = drain_final(session_cx.as_ref()).await; @@ -434,6 +444,7 @@ mod tests { &SessionPlan::Resume { conversation_id: "repris-42".to_owned(), }, + None, ) .await .expect("start resume ok"); @@ -525,8 +536,9 @@ mod tests { cwd: "/".to_owned(), env: Vec::new(), stdin: None, + sandbox: None, }; - let err = run_turn(&spec, Some(Duration::from_millis(50))) + let err = run_turn(&spec, Some(Duration::from_millis(50)), None) .await .expect_err("doit expirer"); assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}"); @@ -757,7 +769,7 @@ mod tests { r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#, r#"{"type":"turn.completed","usage":{}}"#, ]); - let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None); + let s = CodexExecSession::new(SessionId::new_random(), fake.command(), "/", None, None, None); let events: Vec<_> = s.send("x").await.expect("send").collect(); let finals = events .iter() @@ -785,7 +797,7 @@ mod tests { r#"{"type":"system","subtype":"init","session_id":"c"}"#, r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#, ]); - let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None); + let s = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None); let events: Vec<_> = s.send("x").await.expect("send ok").collect(); let finals = events .iter() @@ -801,7 +813,7 @@ mod tests { #[tokio::test] async fn run_turn_empty_output_is_ok() { let fake = FakeCli::printing(&[]); - let lines = run_turn(&fake.spawn_line(), None).await.expect("ok"); + let lines = run_turn(&fake.spawn_line(), None, None).await.expect("ok"); assert!(lines.is_empty()); } @@ -811,7 +823,7 @@ mod tests { let fake = FakeCli::printing(&["pong"]); let mut spec = fake.spawn_line(); spec.stdin = Some("ping".to_owned()); - let lines = run_turn(&spec, None).await.expect("ok"); + let lines = run_turn(&spec, None, None).await.expect("ok"); assert_eq!(lines, vec!["pong"]); } @@ -872,8 +884,9 @@ mod tests { cwd: "/".to_owned(), env: Vec::new(), stdin: None, + sandbox: None, }; - let err = run_turn(&spec, Some(Duration::from_millis(50))) + let err = run_turn(&spec, Some(Duration::from_millis(50)), None) .await .expect_err("doit expirer"); assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}"); @@ -894,6 +907,8 @@ mod tests { cmd.clone(), "/", Some("resume-id".to_owned()), + None, + None, ); // conversation_id amorcé avant tout tour. assert_eq!(session.conversation_id().as_deref(), Some("resume-id")); @@ -938,6 +953,8 @@ mod tests { cmd.clone(), "/", Some("cx-id".to_owned()), + None, + None, ); let _ = session.send("vas-y").await.expect("send ok"); let recorded = std::fs::read_to_string(&argv).expect("argv"); @@ -960,7 +977,7 @@ mod tests { r#"{"type":"system","subtype":"init","session_id":"captured-1"}"#, r#"{"type":"result","subtype":"success","result":"r","session_id":"captured-1"}"#, ]); - let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None); assert_eq!(session.conversation_id(), None); let _ = session.send("t1").await.expect("t1"); assert_eq!(session.conversation_id().as_deref(), Some("captured-1")); @@ -999,6 +1016,7 @@ mod tests { &SessionPlan::Resume { conversation_id: "cx-resume".to_owned(), }, + None, ) .await .expect("start resume codex"); @@ -1021,6 +1039,7 @@ mod tests { &SessionPlan::Assign { conversation_id: "ignored-by-engine".to_owned(), }, + None, ) .await .expect("start assign"); @@ -1045,7 +1064,7 @@ mod tests { ) .expect("profil valide"); match factory - .start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None) + .start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, None) .await { Err(AgentSessionError::Start(_)) => {} @@ -1068,6 +1087,8 @@ mod tests { fake.command(), "/", None, + None, + None, )); assert_agent_session_contract(session, "cx-0", "direct").await; } @@ -1096,7 +1117,7 @@ mod tests { r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"a"},{"type":"tool_use","name":"T"},{"type":"text","text":"b"}]},"session_id":"flow-1","parent_tool_use_id":null}"#, r#"{"type":"result","subtype":"success","is_error":false,"result":"final-ok","session_id":"flow-1","num_turns":1}"#, ]); - let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None); + let session = ClaudeSdkSession::new(SessionId::new_random(), fake.command(), "/", None, None, None); let events: Vec = session.send("x").await.expect("send ok").collect(); assert_eq!( events, @@ -1133,7 +1154,7 @@ mod tests { r#"{"type":"system","subtype":"init","session_id":"new-1"}"#, r#"{"type":"result","subtype":"success","result":"r","session_id":"new-1"}"#, ]); - let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let session = ClaudeSdkSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None); let _ = session.send("bonjour").await.expect("send ok"); let recorded = std::fs::read_to_string(&argv).expect("argv"); let args: Vec<&str> = recorded.lines().collect(); @@ -1165,7 +1186,7 @@ mod tests { r#"{"type":"thread.started","thread_id":"cx-new"}"#, r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#, ]); - let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None); let _ = session.send("salut").await.expect("send ok"); let recorded = std::fs::read_to_string(&argv).expect("argv"); let args: Vec<&str> = recorded.lines().collect(); @@ -1207,7 +1228,7 @@ mod tests { r#"{"type":"thread.started","thread_id":"cx-new"}"#, r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#, ]); - let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None); + let session = CodexExecSession::new(SessionId::new_random(), cmd.clone(), "/", None, None, None); let _ = session.send("salut").await.expect("send ok"); let recorded = std::fs::read_to_string(&argv).expect("argv"); let args: Vec<&str> = recorded.lines().collect(); @@ -1241,6 +1262,8 @@ mod tests { cmd.clone(), "/", Some("cx-id".to_owned()), + None, + None, ); let _ = session.send("vas-y").await.expect("send ok"); let recorded = std::fs::read_to_string(&argv).expect("argv"); diff --git a/crates/infrastructure/src/session/process.rs b/crates/infrastructure/src/session/process.rs index 1de58cf..0b82d37 100644 --- a/crates/infrastructure/src/session/process.rs +++ b/crates/infrastructure/src/session/process.rs @@ -28,12 +28,14 @@ use std::io; use std::process::Stdio; +use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; use domain::ports::AgentSessionError; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; /// Une invocation orientée lignes : binaire + arguments + cwd + env + prompt à /// pousser sur stdin (le cas échéant). **Paramétrable par la commande** : c'est ce @@ -51,6 +53,11 @@ pub struct SpawnLine { /// Contenu à écrire sur stdin du process (`None` ⇒ stdin fermé immédiatement). /// Sert au mode `--input-format stream-json` / au passage du prompt. pub stdin: Option, + /// Plan de sandbox OS à appliquer sur l'enfant (lot LP4-4). `None` ⇒ aucun + /// sandboxing : `run_turn` emprunte le drain async tokio **inchangé** (invariant + /// produit : rien posé ⇒ comportement natif). `Some` **et** un enforcer fourni à + /// [`run_turn`] ⇒ le plan est appliqué sur l'enfant via [`drain_sandboxed`]. + pub sandbox: Option, } /// Lance `spec`, pousse `spec.stdin` sur l'entrée standard, **draine toutes les @@ -61,14 +68,33 @@ pub struct SpawnLine { /// [`AgentSessionError::Timeout`] est retourné (la machinerie ne suppose jamais que /// l'appelant veut attendre indéfiniment). `None` ⇒ pas de borne. /// +/// Quand `spec.sandbox` porte un plan **et** qu'un `enforcer` est fourni (chemin +/// Linux uniquement, lot LP4-4), le tour passe par [`run_turn_sandboxed`] : l'enfant +/// est lancé sous le domaine Landlock. Sinon — et **partout** hors Linux — c'est le +/// drain async tokio historique, strictement inchangé (zéro régression). +/// /// # Errors -/// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable) ; +/// - [`AgentSessionError::Start`] si le process ne démarre pas (binaire introuvable, +/// ou enforcement de sandbox impossible : fail-closed, **aucun** enfant ne tourne) ; /// - [`AgentSessionError::Io`] sur échec de lecture/écriture des pipes ; /// - [`AgentSessionError::Timeout`] si `timeout` expire. pub async fn run_turn( spec: &SpawnLine, timeout: Option, + enforcer: Option<&Arc>, ) -> Result, AgentSessionError> { + // Chemin SANDBOXÉ (Linux + plan posé + enforcer câblé) : transpose la technique + // du PTY (`spawn_command_sandboxed`) — enforce sur un thread jetable AVANT le fork, + // l'enfant hérite le domaine via fork+exec. + #[cfg(target_os = "linux")] + if let (Some(plan), Some(enforcer)) = (spec.sandbox.as_ref(), enforcer) { + return run_turn_sandboxed(spec, plan.clone(), Arc::clone(enforcer), timeout).await; + } + // Hors Linux : aucun sandboxing OS ⇒ on ignore l'enforcer (Noop de toute façon) et + // on garde le drain async historique. `let _` évite l'avertissement « unused ». + #[cfg(not(target_os = "linux"))] + let _ = enforcer; + match timeout { Some(dur) => match tokio::time::timeout(dur, drain(spec)).await { Ok(result) => result, @@ -78,6 +104,182 @@ pub async fn run_turn( } } +/// Variante **sandboxée** du tour (lot LP4-4, Linux seulement). Le drain bloquant +/// `std::process` est exécuté sur un **thread jetable** : on y restreint le thread +/// (`enforcer.enforce(plan)`) AVANT le `spawn`, puis on lance l'enfant. La technique +/// reproduit celle du PTY ([`crate::pty`]) : `pre_exec(enforce)` est INTERDIT (Landlock +/// alloue ⇒ risque de deadlock `malloc` post-`fork` en process multithreadé), on +/// s'appuie donc sur l'**héritage du domaine Landlock**. +/// +/// ## Pourquoi pas de `pre_exec` (divergence assumée du cadrage) +/// +/// Le cadrage proposait un `pre_exec` **vide** pour forcer `std` sur le chemin +/// déterministe `fork`+`exec` (jamais `posix_spawn`). Or `pre_exec` est `unsafe`, et +/// cette crate est `#![forbid(unsafe_code)]` (invariant non contournable localement). +/// On s'en passe sans perte de garantie : `landlock_restrict_self` restreint le +/// **thread courant et toute sa descendance**, héritage assuré par le noyau à travers +/// `fork`/`clone`/`vfork` **et** préservé par `execve` — donc aussi via `posix_spawn` +/// (qui est `clone`+`execve` sous le capot), car l'enforcement vit au niveau des +/// *credentials* de la tâche, hors d'atteinte de l'espace utilisateur. Le PTY n'obtenait +/// le `fork`+`exec` que comme **effet de bord** du `pre_exec` interne de `portable-pty` ; +/// la garantie de sécurité, elle, ne repose que sur cet héritage. Le thread meurt avec +/// sa restriction, les autres threads d'IdeA ne sont jamais touchés. +/// +/// `enforce` fail-closed : un `Err` ⇒ [`AgentSessionError::Start`] et **aucun** enfant. +/// +/// ## Timeout sous sandbox +/// +/// Le thread bloquant n'est pas annulable « de l'extérieur ». On le réconcilie avec +/// l'async par deux canaux oneshot : le thread renvoie un **killer** +/// (`Arc>`) juste après le spawn, puis son résultat à la fin. On pose +/// `tokio::time::timeout` sur la réception du résultat ; à expiration on **tue +/// l'enfant** via le killer ⇒ EOF côté stdout ⇒ le thread sort de sa boucle de drain, +/// `wait()` (reap, pas de zombie) et se termine. On renvoie alors [`AgentSessionError::Timeout`]. +#[cfg(target_os = "linux")] +async fn run_turn_sandboxed( + spec: &SpawnLine, + plan: SandboxPlan, + enforcer: Arc, + timeout: Option, +) -> Result, AgentSessionError> { + use std::sync::Mutex as StdMutex; + + // Données possédées : rien n'emprunte le thread jetable. + let command = spec.command.clone(); + let args = spec.args.clone(); + let cwd = spec.cwd.clone(); + let env = spec.env.clone(); + let stdin = spec.stdin.clone(); + + let (killer_tx, killer_rx) = + tokio::sync::oneshot::channel::>>(); + let (done_tx, done_rx) = + tokio::sync::oneshot::channel::, AgentSessionError>>(); + + // Thread JETABLE : sa restriction Landlock meurt avec lui. + std::thread::spawn(move || { + let result = + drain_sandboxed(command, args, cwd, env, stdin, &enforcer, &plan, killer_tx); + // Le récepteur peut avoir abandonné (timeout) : on ignore l'erreur d'envoi. + let _ = done_tx.send(result); + }); + + match timeout { + Some(dur) => match tokio::time::timeout(dur, done_rx).await { + // Le thread a fini dans les temps (succès ou erreur métier). + Ok(Ok(result)) => result, + // Sender lâché sans valeur (panique du thread) ⇒ I/O. + Ok(Err(_canceled)) => Err(AgentSessionError::Io( + "thread sandbox terminé sans résultat".to_owned(), + )), + // Expiration : tue l'enfant (⇒ EOF ⇒ le thread se termine et reap). + Err(_elapsed) => { + if let Ok(child) = killer_rx.await { + if let Ok(mut c) = child.lock() { + let _ = c.kill(); + } + } + Err(AgentSessionError::Timeout) + } + }, + None => match done_rx.await { + Ok(result) => result, + Err(_canceled) => Err(AgentSessionError::Io( + "thread sandbox terminé sans résultat".to_owned(), + )), + }, + } +} + +/// Drain **bloquant et sandboxé** exécuté sur le thread jetable (lot LP4-4, Linux). +/// +/// 1. `enforce(plan)` restreint CE thread (fail-closed) — la descendance hérite le +/// domaine Landlock (cf. doc de [`run_turn_sandboxed`]) ; +/// 2. `std::process::Command::spawn` (aucun `pre_exec` : `unsafe` interdit ici) ; +/// 3. pousse le prompt sur stdin puis EOF ; +/// 4. sort `stdout` du child **avant** de le partager : le drain lit sans tenir le +/// `Mutex`, donc le killer (timeout) peut verrouiller et tuer à tout moment ; +/// 5. draine ligne-à-ligne jusqu'à EOF ; +/// 6. `wait()` (reap) — pas de zombie. +#[cfg(target_os = "linux")] +#[allow(clippy::too_many_arguments)] +fn drain_sandboxed( + command: String, + args: Vec, + cwd: String, + env: Vec<(String, String)>, + stdin_content: Option, + enforcer: &Arc, + plan: &SandboxPlan, + killer_tx: tokio::sync::oneshot::Sender>>, +) -> Result, AgentSessionError> { + use std::io::{BufRead, BufReader as StdBufReader, Write as _}; + use std::process::{Command as StdCommand, Stdio}; + use std::sync::Mutex as StdMutex; + + // 1. Restreint CE thread AVANT tout fork (fail-closed : Err ⇒ aucun child ne tourne). + enforcer + .enforce(plan) + .map_err(|e| AgentSessionError::Start(format!("sandbox enforcement failed: {e}")))?; + + // 2. Commande std (≡ `drain` async, mais synchrone). + let mut cmd = StdCommand::new(&command); + cmd.args(&args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if cwd != "/" && !cwd.is_empty() { + cmd.current_dir(&cwd); + } + for (key, value) in &env { + cmd.env(key, value); + } + // Pas de `pre_exec` : il serait `unsafe` (interdit dans cette crate). L'enfant + // hérite de toute façon le domaine Landlock posé sur ce thread (cf. doc de + // `run_turn_sandboxed`), que `std` emprunte `posix_spawn` ou `fork`+`exec`. + + let mut child = cmd + .spawn() + .map_err(|e| AgentSessionError::Start(format!("{command}: {e}")))?; + + // 3. Pousse le prompt sur stdin puis le ferme (EOF). Erreur d'I/O ⇒ `Io`. + if let Some(input) = &stdin_content { + let mut si = child + .stdin + .take() + .ok_or_else(|| AgentSessionError::Io("stdin pipe indisponible".to_owned()))?; + si.write_all(input.as_bytes()) + .map_err(|e| AgentSessionError::Io(e.to_string()))?; + drop(si); + } else { + drop(child.stdin.take()); + } + + // 4. Sort stdout AVANT de partager le child (drain sans lock ⇒ killer libre). + let stdout = child + .stdout + .take() + .ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?; + let child = Arc::new(StdMutex::new(child)); + // Donne au côté async de quoi tuer l'enfant en cas de timeout. + let _ = killer_tx.send(Arc::clone(&child)); + + // 5. Drain ligne-à-ligne jusqu'à EOF. Un kill côté async ferme stdout ⇒ EOF. + let mut collected = Vec::new(); + for line in StdBufReader::new(stdout).lines() { + match line { + Ok(l) => collected.push(l), + Err(e) => return Err(AgentSessionError::Io(e.to_string())), + } + } + + // 6. Reap (pas de zombie). Lock tenu brièvement. + if let Ok(mut c) = child.lock() { + let _ = c.wait(); + } + Ok(collected) +} + /// Cœur du drain : spawn → écriture stdin → lecture ligne-à-ligne → wait. async fn drain(spec: &SpawnLine) -> Result, AgentSessionError> { let mut cmd = Command::new(&spec.command); diff --git a/crates/infrastructure/src/session/sandbox_e2e.rs b/crates/infrastructure/src/session/sandbox_e2e.rs new file mode 100644 index 0000000..59cdc41 --- /dev/null +++ b/crates/infrastructure/src/session/sandbox_e2e.rs @@ -0,0 +1,479 @@ +//! **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); +}