feat(permissions): LP4-3 — activation bout-en-bout de la sandbox OS au runtime

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-16 08:08:40 +02:00
parent 17ca65ed0f
commit 7e01ac60cb
4 changed files with 198 additions and 2 deletions

View File

@ -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);
}
}

View File

@ -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()),
),
}
}
}