From 7e01ac60cb357c98ae8d51dd5388e5aa32f931f2 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 08:08:40 +0200 Subject: [PATCH] =?UTF-8?q?feat(permissions):=20LP4-3=20=E2=80=94=20activa?= =?UTF-8?q?tion=20bout-en-bout=20de=20la=20sandbox=20OS=20au=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composition root : PortablePtyAdapter::new() injecte désormais default_enforcer() (Landlock sur Linux / Noop ailleurs) via with_sandbox_enforcer, dans state.rs et dans LocalHost (remote/mod.rs). Launch path : LaunchAgent::execute peuple spec.sandbox via le plan pur compile_sandbox_plan(effective_permissions, SandboxContext{project_root, run_dir}), en réutilisant l'EffectivePermissions déjà résolu pour la projection advisory LP3. Invariant produit conservé : eff == None ⇒ plan None ⇒ comportement natif, aucune projection OS. Portée : seul le chemin PTY brut est OS-enforcé ; le chemin structuré (Claude/Codex mode JSON) porte le plan mais ne l'enforce pas encore (lot ultérieur). Tests : ajout d'un test d'intégration bout-en-bout (pty/mod.rs, sandbox_e2e_tests) prouvant qu'un enfant spawné sous un plan RW restreint ne peut PAS écrire hors-grant (écriture bloquée par le kernel) alors que l'écriture dans le grant passe, plus un test compagnon anti faux-positif (sans plan ⇒ pas de sandbox). Skip propre si le kernel n'a pas Landlock. domain+infrastructure+application+app-tauri verts. Co-Authored-By: Claude Opus 4.8 --- crates/app-tauri/src/state.rs | 4 +- crates/application/src/agent/lifecycle.rs | 17 +++ crates/infrastructure/src/pty/mod.rs | 175 ++++++++++++++++++++++ crates/infrastructure/src/remote/mod.rs | 4 +- 4 files changed, 198 insertions(+), 2 deletions(-) diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index b383983..b4fe994 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -393,7 +393,9 @@ impl AppState { let update_project_context = Arc::new(UpdateProjectContext::new(Arc::clone(&fs_port))); // --- PTY adapter + terminal use cases (L3) --- - let pty = Arc::new(PortablePtyAdapter::new()); + let pty = Arc::new( + PortablePtyAdapter::new().with_sandbox_enforcer(infrastructure::default_enforcer()), + ); let pty_port = Arc::clone(&pty) as Arc; let terminal_sessions = Arc::new(TerminalSessions::new()); diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index bbb581f..025f2de 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -28,6 +28,7 @@ use domain::{ ProjectedFile, ProjectionContext, ProjectorKey, Project, ProjectPath, ProviderSessionStore, PtySize, SessionId, SessionKind, SessionStatus, Skill, TerminalSession, }; +use domain::sandbox::{compile_sandbox_plan, SandboxContext}; use crate::error::AppError; use crate::layout::{persist_doc, resolve_doc}; @@ -1510,6 +1511,22 @@ impl LaunchAgent { ) .await?; + // 5d. ── PLAN DE SANDBOX OS (lot LP4-3) ── + // Strictement APRÈS la résolution des permissions (3) et la projection + // advisory (5c), AVANT le split structuré/PTY : `compile_sandbox_plan` + // est **pur** (domaine) — le launch path ne fait qu'orchestrer + // `EffectivePermissions` (déjà lue du store en 3) → `SandboxPlan` et + // remplir `spec.sandbox`. `eff == None` (rien posé) ⇒ `None` : aucun plan, + // comportement natif conservé (invariant produit). L'enforcer concret + // (Landlock/Noop) est injecté côté PTY au composition root. + spec.sandbox = compile_sandbox_plan( + effective_permissions.as_ref(), + &SandboxContext { + project_root: input.project.root.as_str(), + run_dir: run_dir.as_str(), + }, + ); + // 5b. ── POINT DE ROUTAGE §17.4 : IA structuré vs terminal brut ── // Le convention file (CLAUDE.md / AGENTS.md) vient d'être écrit dans le // run dir (étape 5) ; la CLI structurée le lira à chaque tour diff --git a/crates/infrastructure/src/pty/mod.rs b/crates/infrastructure/src/pty/mod.rs index 2aec7c6..fd7af8d 100644 --- a/crates/infrastructure/src/pty/mod.rs +++ b/crates/infrastructure/src/pty/mod.rs @@ -446,3 +446,178 @@ mod tests { assert_eq!(hub.snapshot(), b"abc".to_vec()); } } + +/// **End-to-end PTY × Landlock enforcement (lot LP4-3).** +/// +/// These tests prove the OS sandbox is *really live on the raw PTY path*: a real +/// [`PortablePtyAdapter`] wired with the Landlock enforcer, a [`SpawnSpec`] whose +/// `sandbox = Some(plan)`, and a harmless `sh -c` that tries to write **inside** the +/// granted root (must succeed) and **outside** it (must be blocked by the kernel). +/// No AI CLI is launched — zero tokens. The whole chain enforcer → spec.sandbox → +/// `spawn` → `spawn_command_sandboxed` → restricted thread → fork/exec is exercised. +#[cfg(all(test, target_os = "linux"))] +mod sandbox_e2e_tests { + use super::*; + use crate::sandbox::LandlockSandbox; + use domain::sandbox::{PathAccess, PathGrant, SandboxPlan, SandboxStatus}; + use domain::terminal::PtySize; + use domain::Posture; + use std::path::{Path, PathBuf}; + use std::time::{Duration, Instant}; + + /// A unique temp dir for one test (no external tempfile dep), mirroring the + /// helper used by the Landlock adapter tests. + fn fresh_dir(tag: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let p = std::env::temp_dir().join(format!("idea-pty-sandbox-{tag}-{nanos}")); + std::fs::create_dir_all(&p).unwrap(); + p + } + + /// Probes whether the running kernel actually enforces Landlock, exactly the way + /// the launch path would observe it: enforce a tiny RW plan on a throwaway thread + /// (the restriction is irreversible, so it must not run on the test thread) and + /// report `false` when the adapter returns [`SandboxStatus::Unsupported`]. Lets us + /// skip cleanly on CI / old kernels without a Landlock LSM, like the adapter tests. + fn landlock_is_enforced() -> bool { + let probe = fresh_dir("probe"); + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: probe.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Ask, + }; + let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan)) + .join() + .unwrap() + .expect("enforce must not error under an Ask posture"); + let _ = std::fs::remove_dir_all(&probe); + !matches!(status, SandboxStatus::Unsupported) + } + + /// Waits (bounded) for `path` to appear, returning whether it showed up in time. + fn wait_for(path: &Path, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + while Instant::now() < deadline { + if path.exists() { + return true; + } + std::thread::sleep(Duration::from_millis(25)); + } + path.exists() + } + + /// THE high-fidelity end-to-end proof. With the enforcer wired and a plan that + /// grants RW on `allowed` only, a child `sh` writing to **both** an out-of-grant + /// path and the granted path must have its out-of-grant write blocked while the + /// in-grant write lands on disk. The script writes the denied target *first*, so + /// once the allowed marker exists we know the denied write was already attempted + /// (and fenced) — making the assertion deterministic without racing the child. + #[tokio::test] + async fn pty_spawn_enforces_sandbox_plan_end_to_end() { + if !landlock_is_enforced() { + eprintln!("skipping pty_spawn_enforces_sandbox_plan_end_to_end: \ + Landlock not available/enforced on this kernel"); + return; + } + + let allowed = fresh_dir("allowed"); + let denied = fresh_dir("denied"); + let allowed_marker = allowed.join("in.txt"); + let denied_marker = denied.join("out.txt"); + + // RW on `allowed` only ⇒ the write class is handled and fenced to that root; + // reads stay globally unrestricted so `sh`/libc still load normally. + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: allowed.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Ask, + }; + + // Denied write FIRST, then the allowed write: the allowed marker appearing is + // a happens-after signal that the denied attempt already ran. + let script = format!( + "echo outside > '{}'; echo inside > '{}'", + denied_marker.display(), + allowed_marker.display() + ); + let spec = SpawnSpec { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script], + cwd: domain::project::ProjectPath::new(allowed.to_string_lossy().into_owned()) + .expect("temp dir is absolute"), + env: vec![], + context_plan: None, + sandbox: Some(plan), + }; + + let adapter = + PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()); + let handle = adapter + .spawn(spec, PtySize { rows: 24, cols: 80 }) + .await + .expect("sandboxed spawn must succeed (Ask posture never fails closed)"); + + // Wait for the in-grant write to land (the child's last action). + assert!( + wait_for(&allowed_marker, Duration::from_secs(5)), + "in-grant write must succeed: {allowed_marker:?} never appeared — \ + the sandbox may have wrongly fenced the granted root" + ); + + // THE GUARANTEE: the out-of-grant write was attempted before the marker and + // must have been blocked by Landlock, so the file must not exist. + assert!( + !denied_marker.exists(), + "SANDBOX BREACH: out-of-grant write succeeded ({denied_marker:?} exists) — \ + the Landlock plan was NOT enforced on the PTY child" + ); + + let _ = adapter.kill(&handle).await; + let _ = std::fs::remove_dir_all(&allowed); + let _ = std::fs::remove_dir_all(&denied); + } + + /// Companion sanity check: with the **same** adapter+enforcer but a [`SpawnSpec`] + /// carrying `sandbox = None`, the out-of-grant write must succeed — proving the + /// previous test's block comes from the *enforced plan*, not from some ambient PTY + /// restriction. (Guards against a false-positive where writes fail for unrelated + /// reasons.) `denied` here is just an ordinary writable temp dir. + #[tokio::test] + async fn pty_spawn_without_plan_does_not_sandbox() { + let denied = fresh_dir("noplan"); + let marker = denied.join("out.txt"); + let script = format!("echo outside > '{}'", marker.display()); + + let spec = SpawnSpec { + command: "sh".to_owned(), + args: vec!["-c".to_owned(), script], + cwd: domain::project::ProjectPath::new(denied.to_string_lossy().into_owned()) + .expect("temp dir is absolute"), + env: vec![], + context_plan: None, + sandbox: None, + }; + + let adapter = + PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()); + let handle = adapter + .spawn(spec, PtySize { rows: 24, cols: 80 }) + .await + .expect("plain spawn must succeed"); + + assert!( + wait_for(&marker, Duration::from_secs(5)), + "without a sandbox plan the write must succeed: {marker:?} never appeared" + ); + + let _ = adapter.kill(&handle).await; + let _ = std::fs::remove_dir_all(&denied); + } +} diff --git a/crates/infrastructure/src/remote/mod.rs b/crates/infrastructure/src/remote/mod.rs index 86ff8af..589e19a 100644 --- a/crates/infrastructure/src/remote/mod.rs +++ b/crates/infrastructure/src/remote/mod.rs @@ -38,7 +38,9 @@ impl LocalHost { Self { fs: Arc::new(LocalFileSystem::new()), spawner: Arc::new(LocalProcessSpawner::new()), - pty: Arc::new(PortablePtyAdapter::new()), + pty: Arc::new( + PortablePtyAdapter::new().with_sandbox_enforcer(crate::sandbox::default_enforcer()), + ), } } }