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:
@ -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<Arc<dyn SandboxEnforcer>>,
|
||||
}
|
||||
|
||||
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<dyn SandboxEnforcer>) -> 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<Arc<dyn AgentSession>, 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<dyn AgentSession> = 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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user