feat(permissions): LP4-4 — enforcement Landlock sur le chemin structuré
Étend l'enforcement OS au chemin structuré (sessions Claude/Codex mode JSON), jusqu'ici seulement advisory. Approche validée par l'Architecte : transposer la technique du PTY plutôt qu'un pre_exec (rejeté — landlock alloue, deadlock malloc post-fork en process multithreadé). Mécanique (cfg(target_os=linux)) : run_turn_sandboxed/drain_sandboxed exécutent enforce(plan) sur un thread jetable AVANT le spawn std, puis std::process::spawn depuis ce thread ; l'enfant hérite le domaine Landlock via les credentials de la tâche (garanti à travers fork/clone/execve, y compris posix_spawn — pas de pre_exec nécessaire, forbid(unsafe_code) préservé). Fail-closed sur Err d'enforce (aucun child). Timeout sous sandbox : oneshot killer + tokio::time::timeout → kill → EOF → reap (pas de zombie/thread bloqué). Chemin non-sandboxé (plan None / pas d'enforcer / non-Linux) = drain async tokio inchangé. Contrat : SpawnLine.sandbox ; AgentSessionFactory::start(.., sandbox) ; StructuredSessionFactory::with_sandbox_enforcer (jumeau du PTY) ; plan calculé en step 5d de lifecycle relayé à launch_structured ; default_enforcer() injecté au composition root. Tests : 7 invariants e2e (parité, companion négatif, fail-closed, no-op natif, confinement de l'irréversibilité entre tours, timeout, resume préservé) — zéro token (sh/FakeCli). 80 suites vertes, 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -404,8 +404,10 @@ impl AppState {
|
|||||||
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
|
// `profile.structured_adapter`. Injectés dans LaunchAgent (routage §17.4) et
|
||||||
// ChangeAgentProfile (shutdown polymorphe au hot-swap).
|
// ChangeAgentProfile (shutdown polymorphe au hot-swap).
|
||||||
let structured_sessions = Arc::new(StructuredSessions::new());
|
let structured_sessions = Arc::new(StructuredSessions::new());
|
||||||
let session_factory =
|
let session_factory = Arc::new(
|
||||||
Arc::new(StructuredSessionFactory::new()) as Arc<dyn AgentSessionFactory>;
|
StructuredSessionFactory::new()
|
||||||
|
.with_sandbox_enforcer(infrastructure::default_enforcer()),
|
||||||
|
) as Arc<dyn AgentSessionFactory>;
|
||||||
|
|
||||||
let open_terminal = Arc::new(OpenTerminal::new(
|
let open_terminal = Arc::new(OpenTerminal::new(
|
||||||
Arc::clone(&pty_port),
|
Arc::clone(&pty_port),
|
||||||
|
|||||||
@ -28,7 +28,7 @@ use domain::{
|
|||||||
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
|
ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore,
|
||||||
PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
|
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::error::AppError;
|
||||||
use crate::layout::{persist_doc, resolve_doc};
|
use crate::layout::{persist_doc, resolve_doc};
|
||||||
@ -1569,6 +1569,7 @@ impl LaunchAgent {
|
|||||||
&input.project.root,
|
&input.project.root,
|
||||||
input.node_id,
|
input.node_id,
|
||||||
size,
|
size,
|
||||||
|
spec.sandbox.as_ref(),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@ -1630,9 +1631,12 @@ impl LaunchAgent {
|
|||||||
root: &ProjectPath,
|
root: &ProjectPath,
|
||||||
node_id: Option<NodeId>,
|
node_id: Option<NodeId>,
|
||||||
size: PtySize,
|
size: PtySize,
|
||||||
|
sandbox: Option<&SandboxPlan>,
|
||||||
) -> Result<LaunchAgentOutput, AppError> {
|
) -> Result<LaunchAgentOutput, AppError> {
|
||||||
|
// 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
|
let session = factory
|
||||||
.start(profile, prepared, run_dir, session_plan)
|
.start(profile, prepared, run_dir, session_plan, sandbox)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| AppError::Process(e.to_string()))?;
|
.map_err(|e| AppError::Process(e.to_string()))?;
|
||||||
|
|
||||||
|
|||||||
@ -3022,6 +3022,7 @@ impl AgentSessionFactory for CountingFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||||
let id = {
|
let id = {
|
||||||
|
|||||||
@ -520,6 +520,7 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
self.starts
|
self.starts
|
||||||
.lock()
|
.lock()
|
||||||
@ -1114,7 +1115,7 @@ async fn swap_structured_live_session_shuts_down_then_relaunches() {
|
|||||||
let cwd = ProjectPath::new(ROOT).unwrap();
|
let cwd = ProjectPath::new(ROOT).unwrap();
|
||||||
let session = f
|
let session = f
|
||||||
.factory
|
.factory
|
||||||
.start(&profile, &ctx, &cwd, &SessionPlan::None)
|
.start(&profile, &ctx, &cwd, &SessionPlan::None, None)
|
||||||
.await
|
.await
|
||||||
.expect("seed structured session");
|
.expect("seed structured session");
|
||||||
f.structured.insert(session, agent.id, host);
|
f.structured.insert(session, agent.id, host);
|
||||||
|
|||||||
@ -531,6 +531,13 @@ pub trait AgentSessionFactory: Send + Sync {
|
|||||||
/// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de
|
/// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de
|
||||||
/// session ([`SessionPlan`] : neuf / assign / resume — réutilise §15).
|
/// 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
|
/// # Errors
|
||||||
/// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
|
/// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
|
||||||
/// structuré ne peut s'initialiser.
|
/// structuré ne peut s'initialiser.
|
||||||
@ -540,6 +547,7 @@ pub trait AgentSessionFactory: Send + Sync {
|
|||||||
ctx: &PreparedContext,
|
ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
sandbox: Option<&crate::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -340,6 +340,7 @@ impl AgentSessionFactory for FakeFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
_cwd: &ProjectPath,
|
_cwd: &ProjectPath,
|
||||||
_session: &SessionPlan,
|
_session: &SessionPlan,
|
||||||
|
_sandbox: Option<&domain::sandbox::SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
Ok(Arc::new(FakeSession {
|
Ok(Arc::new(FakeSession {
|
||||||
id: SessionId::from_uuid(Uuid::from_u128(7)),
|
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 cwd = ProjectPath::new("/srv/run").unwrap();
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(&structured, &ctx, &cwd, &SessionPlan::None)
|
.start(&structured, &ctx, &cwd, &SessionPlan::None, None)
|
||||||
.await
|
.await
|
||||||
.expect("factory starts a session");
|
.expect("factory starts a session");
|
||||||
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
|
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));
|
||||||
|
|||||||
@ -13,12 +13,13 @@
|
|||||||
//! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte
|
//! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte
|
||||||
//! le format**, pas la machinerie ni le reste de l'adapter.
|
//! 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 async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::SessionId;
|
use domain::SessionId;
|
||||||
|
|
||||||
use super::process::{run_turn, SpawnLine};
|
use super::process::{run_turn, SpawnLine};
|
||||||
@ -154,24 +155,36 @@ pub struct ClaudeSdkSession {
|
|||||||
cwd: String,
|
cwd: String,
|
||||||
/// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant.
|
/// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant.
|
||||||
conversation_id: Mutex<Option<String>>,
|
conversation_id: Mutex<Option<String>>,
|
||||||
|
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||||
|
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
|
||||||
|
sandbox: Option<SandboxPlan>,
|
||||||
|
/// 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<Arc<dyn SandboxEnforcer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ClaudeSdkSession {
|
impl ClaudeSdkSession {
|
||||||
/// Construit l'adapter. `command` est le binaire à lancer (injecté ⇒ testable
|
/// Construit l'adapter. `command` est le binaire à lancer (injecté ⇒ testable
|
||||||
/// avec un fake CLI) ; `seed_conversation_id` amorce la reprise (`SessionPlan::
|
/// 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]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: SessionId,
|
id: SessionId,
|
||||||
command: impl Into<String>,
|
command: impl Into<String>,
|
||||||
cwd: impl Into<String>,
|
cwd: impl Into<String>,
|
||||||
seed_conversation_id: Option<String>,
|
seed_conversation_id: Option<String>,
|
||||||
|
sandbox: Option<SandboxPlan>,
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
conversation_id: Mutex::new(seed_conversation_id),
|
conversation_id: Mutex::new(seed_conversation_id),
|
||||||
|
sandbox,
|
||||||
|
sandbox_enforcer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,6 +214,7 @@ impl ClaudeSdkSession {
|
|||||||
cwd: self.cwd.clone(),
|
cwd: self.cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
stdin: None,
|
||||||
|
sandbox: self.sandbox.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -217,7 +231,7 @@ impl AgentSession for ClaudeSdkSession {
|
|||||||
|
|
||||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||||
let spec = self.build_spawn_line(prompt);
|
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 events = Vec::new();
|
||||||
let mut captured_id = None;
|
let mut captured_id = None;
|
||||||
|
|||||||
@ -10,12 +10,13 @@
|
|||||||
//! le format. **Seule [`parse_event`] (et la composition de la commande) porte le
|
//! le format. **Seule [`parse_event`] (et la composition de la commande) porte le
|
||||||
//! format Codex** ; la machinerie reste inchangée.
|
//! format Codex** ; la machinerie reste inchangée.
|
||||||
|
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::SessionId;
|
use domain::SessionId;
|
||||||
|
|
||||||
use super::process::{run_turn, SpawnLine};
|
use super::process::{run_turn, SpawnLine};
|
||||||
@ -123,23 +124,35 @@ pub struct CodexExecSession {
|
|||||||
cwd: String,
|
cwd: String,
|
||||||
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
|
||||||
conversation_id: Mutex<Option<String>>,
|
conversation_id: Mutex<Option<String>>,
|
||||||
|
/// Plan de sandbox OS **par lancement** (lot LP4-4), porté dans chaque
|
||||||
|
/// [`SpawnLine`]. `None` ⇒ aucun sandboxing (drain async natif).
|
||||||
|
sandbox: Option<SandboxPlan>,
|
||||||
|
/// 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<Arc<dyn SandboxEnforcer>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CodexExecSession {
|
impl CodexExecSession {
|
||||||
/// Construit l'adapter. `command` est injecté (⇒ testable avec un fake CLI) ;
|
/// Construit l'adapter. `command` est injecté (⇒ testable avec un fake CLI) ;
|
||||||
/// `seed_conversation_id` amorce la reprise ou reste `None` (conversation neuve).
|
/// `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]
|
#[must_use]
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: SessionId,
|
id: SessionId,
|
||||||
command: impl Into<String>,
|
command: impl Into<String>,
|
||||||
cwd: impl Into<String>,
|
cwd: impl Into<String>,
|
||||||
seed_conversation_id: Option<String>,
|
seed_conversation_id: Option<String>,
|
||||||
|
sandbox: Option<SandboxPlan>,
|
||||||
|
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
command: command.into(),
|
command: command.into(),
|
||||||
cwd: cwd.into(),
|
cwd: cwd.into(),
|
||||||
conversation_id: Mutex::new(seed_conversation_id),
|
conversation_id: Mutex::new(seed_conversation_id),
|
||||||
|
sandbox,
|
||||||
|
sandbox_enforcer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,6 +189,7 @@ impl CodexExecSession {
|
|||||||
cwd: self.cwd.clone(),
|
cwd: self.cwd.clone(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
stdin: None,
|
||||||
|
sandbox: self.sandbox.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -192,7 +206,7 @@ impl AgentSession for CodexExecSession {
|
|||||||
|
|
||||||
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
||||||
let spec = self.build_spawn_line(prompt);
|
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 events = Vec::new();
|
||||||
let mut captured_id = None;
|
let mut captured_id = None;
|
||||||
|
|||||||
@ -84,6 +84,7 @@ impl FakeCli {
|
|||||||
cwd: "/".to_owned(),
|
cwd: "/".to_owned(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
stdin: None,
|
||||||
|
sandbox: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ use domain::ports::{
|
|||||||
};
|
};
|
||||||
use domain::profile::{AgentProfile, StructuredAdapter};
|
use domain::profile::{AgentProfile, StructuredAdapter};
|
||||||
use domain::project::ProjectPath;
|
use domain::project::ProjectPath;
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
use domain::SessionId;
|
use domain::SessionId;
|
||||||
|
|
||||||
use super::claude::ClaudeSdkSession;
|
use super::claude::ClaudeSdkSession;
|
||||||
@ -23,17 +24,36 @@ use super::codex::CodexExecSession;
|
|||||||
|
|
||||||
/// Fabrique infra des sessions structurées, sélectionnée par le profil.
|
/// 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
|
/// lancer = `profile.command`), de sorte qu'un seul exemplaire injecté au
|
||||||
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`).
|
/// composition root sert tous les agents (jumeau de `CliAgentRuntime`). Le seul état
|
||||||
#[derive(Debug, Default, Clone, Copy)]
|
/// porté est l'**enforcer de sandbox OS** optionnel (lot LP4-4), injecté **par
|
||||||
pub struct StructuredSessionFactory;
|
/// 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<Arc<dyn SandboxEnforcer>>,
|
||||||
|
}
|
||||||
|
|
||||||
impl StructuredSessionFactory {
|
impl StructuredSessionFactory {
|
||||||
/// Construit la fabrique.
|
/// Construit la fabrique (sans enforcer : chemin natif).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub const fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
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<dyn SandboxEnforcer>) -> Self {
|
||||||
|
self.sandbox_enforcer = Some(enforcer);
|
||||||
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -62,6 +82,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
_ctx: &PreparedContext,
|
_ctx: &PreparedContext,
|
||||||
cwd: &ProjectPath,
|
cwd: &ProjectPath,
|
||||||
session: &SessionPlan,
|
session: &SessionPlan,
|
||||||
|
sandbox: Option<&SandboxPlan>,
|
||||||
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
||||||
let adapter = profile.structured_adapter.ok_or_else(|| {
|
let adapter = profile.structured_adapter.ok_or_else(|| {
|
||||||
AgentSessionError::Start(format!(
|
AgentSessionError::Start(format!(
|
||||||
@ -75,14 +96,25 @@ impl AgentSessionFactory for StructuredSessionFactory {
|
|||||||
let cwd = cwd.as_str().to_owned();
|
let cwd = cwd.as_str().to_owned();
|
||||||
let seed = seed_conversation_id(session);
|
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
|
// 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
|
// 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
|
// 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
|
// injection supplémentaire n'incombe ici en mode structuré (la CLI lit son
|
||||||
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
// fichier conventionnel — CLAUDE.md / AGENTS.md — depuis le cwd).
|
||||||
let session: Arc<dyn AgentSession> = match adapter {
|
let session: Arc<dyn AgentSession> = match adapter {
|
||||||
StructuredAdapter::Claude => Arc::new(ClaudeSdkSession::new(id, command, cwd, seed)),
|
StructuredAdapter::Claude => {
|
||||||
StructuredAdapter::Codex => Arc::new(CodexExecSession::new(id, command, cwd, seed)),
|
Arc::new(ClaudeSdkSession::new(id, command, cwd, seed, plan, enforcer))
|
||||||
|
}
|
||||||
|
StructuredAdapter::Codex => {
|
||||||
|
Arc::new(CodexExecSession::new(id, command, cwd, seed, plan, enforcer))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,11 @@ pub mod conformance;
|
|||||||
pub mod factory;
|
pub mod factory;
|
||||||
pub mod process;
|
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 claude::ClaudeSdkSession;
|
||||||
pub use codex::CodexExecSession;
|
pub use codex::CodexExecSession;
|
||||||
pub use conformance::FakeCli;
|
pub use conformance::FakeCli;
|
||||||
@ -84,7 +89,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_turn_drains_every_line_in_order() {
|
async fn run_turn_drains_every_line_in_order() {
|
||||||
let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]);
|
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
|
.await
|
||||||
.expect("run_turn réussit");
|
.expect("run_turn réussit");
|
||||||
assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]);
|
assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]);
|
||||||
@ -98,8 +103,9 @@ mod tests {
|
|||||||
cwd: "/".to_owned(),
|
cwd: "/".to_owned(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
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:?}");
|
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -284,6 +290,8 @@ mod tests {
|
|||||||
fake.command(),
|
fake.command(),
|
||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await;
|
assert_agent_session_contract(session, "claude-conv-1", "réponse Claude").await;
|
||||||
}
|
}
|
||||||
@ -296,6 +304,8 @@ mod tests {
|
|||||||
fake.command(),
|
fake.command(),
|
||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await;
|
assert_agent_session_contract(session, "codex-conv-1", "réponse Codex").await;
|
||||||
}
|
}
|
||||||
@ -305,7 +315,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn stream_is_closed_after_final() {
|
async fn stream_is_closed_after_final() {
|
||||||
let fake = FakeCli::printing(&claude_script());
|
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 stream = session.send("x").await.expect("send ok");
|
||||||
let events: Vec<_> = stream.collect();
|
let events: Vec<_> = stream.collect();
|
||||||
let after_final = events
|
let after_final = events
|
||||||
@ -323,7 +333,7 @@ mod tests {
|
|||||||
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
||||||
"{ ceci n'est pas du json",
|
"{ 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 {
|
match session.send("x").await {
|
||||||
Err(AgentSessionError::Decode(_)) => {}
|
Err(AgentSessionError::Decode(_)) => {}
|
||||||
Err(other) => panic!("attendu Decode, vu: {other:?}"),
|
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.
|
// Claude : la session démarre et respecte le contrat via le fake CLI.
|
||||||
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
|
let claude = structured_profile(StructuredAdapter::Claude, &fake.command());
|
||||||
let session = factory
|
let session = factory
|
||||||
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
.start(&claude, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
||||||
.await
|
.await
|
||||||
.expect("start Claude ok");
|
.expect("start Claude ok");
|
||||||
let content = drain_final(session.as_ref()).await;
|
let content = drain_final(session.as_ref()).await;
|
||||||
@ -414,7 +424,7 @@ mod tests {
|
|||||||
let fake_cx = FakeCli::printing(&codex_script());
|
let fake_cx = FakeCli::printing(&codex_script());
|
||||||
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
|
let codex = structured_profile(StructuredAdapter::Codex, &fake_cx.command());
|
||||||
let session_cx = factory
|
let session_cx = factory
|
||||||
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
.start(&codex, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
||||||
.await
|
.await
|
||||||
.expect("start Codex ok");
|
.expect("start Codex ok");
|
||||||
let content_cx = drain_final(session_cx.as_ref()).await;
|
let content_cx = drain_final(session_cx.as_ref()).await;
|
||||||
@ -434,6 +444,7 @@ mod tests {
|
|||||||
&SessionPlan::Resume {
|
&SessionPlan::Resume {
|
||||||
conversation_id: "repris-42".to_owned(),
|
conversation_id: "repris-42".to_owned(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("start resume ok");
|
.expect("start resume ok");
|
||||||
@ -525,8 +536,9 @@ mod tests {
|
|||||||
cwd: "/".to_owned(),
|
cwd: "/".to_owned(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
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
|
.await
|
||||||
.expect_err("doit expirer");
|
.expect_err("doit expirer");
|
||||||
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
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":"item.completed","item":{"id":"i1","type":"agent_message","text":"fin"}}"#,
|
||||||
r#"{"type":"turn.completed","usage":{}}"#,
|
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 events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||||
let finals = events
|
let finals = events
|
||||||
.iter()
|
.iter()
|
||||||
@ -785,7 +797,7 @@ mod tests {
|
|||||||
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"c"}"#,
|
||||||
r#"{"type":"assistant","message":{"content":[{"type":"text","text":"a"}]}}"#,
|
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 events: Vec<_> = s.send("x").await.expect("send ok").collect();
|
||||||
let finals = events
|
let finals = events
|
||||||
.iter()
|
.iter()
|
||||||
@ -801,7 +813,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_turn_empty_output_is_ok() {
|
async fn run_turn_empty_output_is_ok() {
|
||||||
let fake = FakeCli::printing(&[]);
|
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());
|
assert!(lines.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -811,7 +823,7 @@ mod tests {
|
|||||||
let fake = FakeCli::printing(&["pong"]);
|
let fake = FakeCli::printing(&["pong"]);
|
||||||
let mut spec = fake.spawn_line();
|
let mut spec = fake.spawn_line();
|
||||||
spec.stdin = Some("ping".to_owned());
|
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"]);
|
assert_eq!(lines, vec!["pong"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -872,8 +884,9 @@ mod tests {
|
|||||||
cwd: "/".to_owned(),
|
cwd: "/".to_owned(),
|
||||||
env: Vec::new(),
|
env: Vec::new(),
|
||||||
stdin: None,
|
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
|
.await
|
||||||
.expect_err("doit expirer");
|
.expect_err("doit expirer");
|
||||||
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
||||||
@ -894,6 +907,8 @@ mod tests {
|
|||||||
cmd.clone(),
|
cmd.clone(),
|
||||||
"/",
|
"/",
|
||||||
Some("resume-id".to_owned()),
|
Some("resume-id".to_owned()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
// conversation_id amorcé avant tout tour.
|
// conversation_id amorcé avant tout tour.
|
||||||
assert_eq!(session.conversation_id().as_deref(), Some("resume-id"));
|
assert_eq!(session.conversation_id().as_deref(), Some("resume-id"));
|
||||||
@ -938,6 +953,8 @@ mod tests {
|
|||||||
cmd.clone(),
|
cmd.clone(),
|
||||||
"/",
|
"/",
|
||||||
Some("cx-id".to_owned()),
|
Some("cx-id".to_owned()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let _ = session.send("vas-y").await.expect("send ok");
|
let _ = session.send("vas-y").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
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":"system","subtype":"init","session_id":"captured-1"}"#,
|
||||||
r#"{"type":"result","subtype":"success","result":"r","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);
|
assert_eq!(session.conversation_id(), None);
|
||||||
let _ = session.send("t1").await.expect("t1");
|
let _ = session.send("t1").await.expect("t1");
|
||||||
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
|
assert_eq!(session.conversation_id().as_deref(), Some("captured-1"));
|
||||||
@ -999,6 +1016,7 @@ mod tests {
|
|||||||
&SessionPlan::Resume {
|
&SessionPlan::Resume {
|
||||||
conversation_id: "cx-resume".to_owned(),
|
conversation_id: "cx-resume".to_owned(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("start resume codex");
|
.expect("start resume codex");
|
||||||
@ -1021,6 +1039,7 @@ mod tests {
|
|||||||
&SessionPlan::Assign {
|
&SessionPlan::Assign {
|
||||||
conversation_id: "ignored-by-engine".to_owned(),
|
conversation_id: "ignored-by-engine".to_owned(),
|
||||||
},
|
},
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("start assign");
|
.expect("start assign");
|
||||||
@ -1045,7 +1064,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("profil valide");
|
.expect("profil valide");
|
||||||
match factory
|
match factory
|
||||||
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None)
|
.start(&tui, &prepared_ctx(), &cwd(), &SessionPlan::None, None)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Err(AgentSessionError::Start(_)) => {}
|
Err(AgentSessionError::Start(_)) => {}
|
||||||
@ -1068,6 +1087,8 @@ mod tests {
|
|||||||
fake.command(),
|
fake.command(),
|
||||||
"/",
|
"/",
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
));
|
));
|
||||||
assert_agent_session_contract(session, "cx-0", "direct").await;
|
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":"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}"#,
|
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<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
let events: Vec<ReplyEvent> = session.send("x").await.expect("send ok").collect();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
events,
|
events,
|
||||||
@ -1133,7 +1154,7 @@ mod tests {
|
|||||||
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
|
r#"{"type":"system","subtype":"init","session_id":"new-1"}"#,
|
||||||
r#"{"type":"result","subtype":"success","result":"r","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 _ = session.send("bonjour").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
let args: Vec<&str> = recorded.lines().collect();
|
||||||
@ -1165,7 +1186,7 @@ mod tests {
|
|||||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
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 _ = session.send("salut").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
let args: Vec<&str> = recorded.lines().collect();
|
||||||
@ -1207,7 +1228,7 @@ mod tests {
|
|||||||
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
|
||||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
|
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 _ = session.send("salut").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
let args: Vec<&str> = recorded.lines().collect();
|
let args: Vec<&str> = recorded.lines().collect();
|
||||||
@ -1241,6 +1262,8 @@ mod tests {
|
|||||||
cmd.clone(),
|
cmd.clone(),
|
||||||
"/",
|
"/",
|
||||||
Some("cx-id".to_owned()),
|
Some("cx-id".to_owned()),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let _ = session.send("vas-y").await.expect("send ok");
|
let _ = session.send("vas-y").await.expect("send ok");
|
||||||
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
let recorded = std::fs::read_to_string(&argv).expect("argv");
|
||||||
|
|||||||
@ -28,12 +28,14 @@
|
|||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
use domain::ports::AgentSessionError;
|
use domain::ports::AgentSessionError;
|
||||||
|
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
|
||||||
|
|
||||||
/// Une invocation orientée lignes : binaire + arguments + cwd + env + prompt à
|
/// 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
|
/// 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).
|
/// Contenu à écrire sur stdin du process (`None` ⇒ stdin fermé immédiatement).
|
||||||
/// Sert au mode `--input-format stream-json` / au passage du prompt.
|
/// Sert au mode `--input-format stream-json` / au passage du prompt.
|
||||||
pub stdin: Option<String>,
|
pub stdin: Option<String>,
|
||||||
|
/// 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<SandboxPlan>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lance `spec`, pousse `spec.stdin` sur l'entrée standard, **draine toutes les
|
/// 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
|
/// [`AgentSessionError::Timeout`] est retourné (la machinerie ne suppose jamais que
|
||||||
/// l'appelant veut attendre indéfiniment). `None` ⇒ pas de borne.
|
/// 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
|
/// # 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::Io`] sur échec de lecture/écriture des pipes ;
|
||||||
/// - [`AgentSessionError::Timeout`] si `timeout` expire.
|
/// - [`AgentSessionError::Timeout`] si `timeout` expire.
|
||||||
pub async fn run_turn(
|
pub async fn run_turn(
|
||||||
spec: &SpawnLine,
|
spec: &SpawnLine,
|
||||||
timeout: Option<Duration>,
|
timeout: Option<Duration>,
|
||||||
|
enforcer: Option<&Arc<dyn SandboxEnforcer>>,
|
||||||
) -> Result<Vec<String>, AgentSessionError> {
|
) -> Result<Vec<String>, 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 {
|
match timeout {
|
||||||
Some(dur) => match tokio::time::timeout(dur, drain(spec)).await {
|
Some(dur) => match tokio::time::timeout(dur, drain(spec)).await {
|
||||||
Ok(result) => result,
|
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<Mutex<Child>>`) 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<dyn SandboxEnforcer>,
|
||||||
|
timeout: Option<Duration>,
|
||||||
|
) -> Result<Vec<String>, 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::<Arc<StdMutex<std::process::Child>>>();
|
||||||
|
let (done_tx, done_rx) =
|
||||||
|
tokio::sync::oneshot::channel::<Result<Vec<String>, 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<String>,
|
||||||
|
cwd: String,
|
||||||
|
env: Vec<(String, String)>,
|
||||||
|
stdin_content: Option<String>,
|
||||||
|
enforcer: &Arc<dyn SandboxEnforcer>,
|
||||||
|
plan: &SandboxPlan,
|
||||||
|
killer_tx: tokio::sync::oneshot::Sender<Arc<std::sync::Mutex<std::process::Child>>>,
|
||||||
|
) -> Result<Vec<String>, 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.
|
/// Cœur du drain : spawn → écriture stdin → lecture ligne-à-ligne → wait.
|
||||||
async fn drain(spec: &SpawnLine) -> Result<Vec<String>, AgentSessionError> {
|
async fn drain(spec: &SpawnLine) -> Result<Vec<String>, AgentSessionError> {
|
||||||
let mut cmd = Command::new(&spec.command);
|
let mut cmd = Command::new(&spec.command);
|
||||||
|
|||||||
479
crates/infrastructure/src/session/sandbox_e2e.rs
Normal file
479
crates/infrastructure/src/session/sandbox_e2e.rs
Normal file
@ -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<SandboxPlan>,
|
||||||
|
) -> 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<SandboxStatus, SandboxError> {
|
||||||
|
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<dyn SandboxEnforcer> = 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<ReplyEvent> = 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user