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:
2026-06-16 08:47:33 +02:00
parent 7e01ac60cb
commit 6236cd727b
13 changed files with 821 additions and 39 deletions

View File

@ -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<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 {
/// 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<String>,
cwd: impl Into<String>,
seed_conversation_id: Option<String>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> 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<ReplyStream, AgentSessionError> {
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;