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:
@ -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<dyn PtyPort>;
|
||||
let terminal_sessions = Arc::new(TerminalSessions::new());
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user