Files
IdeaSDK/crates/infrastructure/src/session/factory.rs
Blomios 6236cd727b 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>
2026-06-16 08:47:33 +02:00

122 lines
5.1 KiB
Rust

//! [`StructuredSessionFactory`] — la fabrique [`AgentSessionFactory`] qui **route un
//! profil vers le bon adapter** structuré (ARCHITECTURE §17.2) selon
//! `profile.structured_adapter` (§17.3). Agrège Claude + Codex derrière une seule
//! surface ; aucun type concret ne franchit la frontière domaine (seuls
//! `Arc<dyn AgentSession>` sortent).
//!
//! Open/Closed : ajouter un moteur structuré = ajouter un adapter + une variante
//! [`StructuredAdapter`] + un bras de `match` ici. Le cœur ne bouge pas.
use std::sync::Arc;
use async_trait::async_trait;
use domain::ports::{
AgentSession, AgentSessionError, AgentSessionFactory, PreparedContext, SessionPlan,
};
use domain::profile::{AgentProfile, StructuredAdapter};
use domain::project::ProjectPath;
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId;
use super::claude::ClaudeSdkSession;
use super::codex::CodexExecSession;
/// Fabrique infra des sessions structurées, sélectionnée par le profil.
///
/// 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`). 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<Arc<dyn SandboxEnforcer>>,
}
impl StructuredSessionFactory {
/// Construit la fabrique (sans enforcer : chemin natif).
#[must_use]
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<dyn SandboxEnforcer>) -> Self {
self.sandbox_enforcer = Some(enforcer);
self
}
}
/// Dérive l'[`SessionPlan`] le `seed` de reprise : seul [`SessionPlan::Resume`]
/// amorce l'adapter avec un id de conversation existant ; `Assign`/`None` partent
/// d'une conversation neuve (l'id sera capté au premier tour).
fn seed_conversation_id(session: &SessionPlan) -> Option<String> {
match session {
SessionPlan::Resume { conversation_id } => Some(conversation_id.clone()),
SessionPlan::None | SessionPlan::Assign { .. } => None,
}
}
#[async_trait]
impl AgentSessionFactory for StructuredSessionFactory {
fn supports(&self, profile: &AgentProfile) -> bool {
// Source unique de vérité (§17.3, D7) : sélectionnabilité = pilotable en
// structuré. `is_selectable` et `supports` partagent ainsi le **même**
// prédicat de domaine, ils ne peuvent pas diverger.
profile.is_selectable()
}
async fn start(
&self,
profile: &AgentProfile,
_ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
sandbox: Option<&SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
let adapter = profile.structured_adapter.ok_or_else(|| {
AgentSessionError::Start(format!(
"le profil « {} » n'a pas d'adapter structuré",
profile.name
))
})?;
let id = SessionId::new_random();
let command = profile.command.clone();
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<dyn AgentSession> = match adapter {
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)
}
}