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

@ -13,12 +13,13 @@
//! (2026-06-09) ; **seule [`parse_event`] (et la composition de la commande) porte
//! le format**, pas la machinerie ni le reste de l'adapter.
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use serde_json::Value;
use domain::ports::{AgentSession, AgentSessionError, ReplyEvent, ReplyStream};
use domain::sandbox::{SandboxEnforcer, SandboxPlan};
use domain::SessionId;
use super::process::{run_turn, SpawnLine};
@ -154,24 +155,36 @@ pub struct ClaudeSdkSession {
cwd: String,
/// Id de conversation **du moteur** Claude, capté au premier tour, `None` avant.
conversation_id: Mutex<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 {
/// Construit l'adapter. `command` est le binaire à lancer (injecté ⇒ testable
/// avec un fake CLI) ; `seed_conversation_id` amorce la reprise (`SessionPlan::
/// Resume` côté factory) ou reste `None` pour une conversation neuve.
/// Resume` côté factory) ou reste `None` pour une conversation neuve. `sandbox` /
/// `sandbox_enforcer` (lot LP4-4) pilotent le sandboxing OS du tour ; `None`/`None`
/// ⇒ chemin natif inchangé.
#[must_use]
pub fn new(
id: SessionId,
command: impl Into<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,
}
}
@ -201,6 +214,7 @@ impl ClaudeSdkSession {
cwd: self.cwd.clone(),
env: Vec::new(),
stdin: None,
sandbox: self.sandbox.clone(),
}
}
}
@ -217,7 +231,7 @@ impl AgentSession for ClaudeSdkSession {
async fn send(&self, prompt: &str) -> Result<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;