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

@ -531,6 +531,13 @@ pub trait AgentSessionFactory: Send + Sync {
/// §14.1), avec le contexte déjà préparé ([`PreparedContext`]) et l'intention de
/// 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
/// [`AgentSessionError::Start`] si la CLI/SDK est indisponible ou le mode
/// structuré ne peut s'initialiser.
@ -540,6 +547,7 @@ pub trait AgentSessionFactory: Send + Sync {
ctx: &PreparedContext,
cwd: &ProjectPath,
session: &SessionPlan,
sandbox: Option<&crate::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError>;
}

View File

@ -340,6 +340,7 @@ impl AgentSessionFactory for FakeFactory {
_ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
_sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
Ok(Arc::new(FakeSession {
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 session = factory
.start(&structured, &ctx, &cwd, &SessionPlan::None)
.start(&structured, &ctx, &cwd, &SessionPlan::None, None)
.await
.expect("factory starts a session");
assert_eq!(session.id(), SessionId::from_uuid(Uuid::from_u128(7)));