From 17ca65ed0f1a82748613468fdb3bc3a5d21ef1a0 Mon Sep 17 00:00:00 2001 From: Blomios Date: Tue, 16 Jun 2026 08:00:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(permissions):=20LP4-1/LP4-2=20=E2=80=94=20?= =?UTF-8?q?enforcer=20Landlock=20OS=20+=20consommation=20PTY=20du=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LP4-1 : adaptateurs SandboxEnforcer concrets (LandlockSandbox Linux via le LSM Landlock, NoopSandbox ailleurs) + default_enforcer() cfg-sélectionné, prêts à être injectés au composition root (LP4-3). Aucun changement de comportement runtime tant que rien n'est câblé : SpawnSpec.sandbox reste None. LP4-2 : PortablePtyAdapter consomme SpawnSpec.sandbox via spawn_command_sandboxed (thread jetable restreint par enforce() puis fork → le domaine Landlock est hérité par l'enfant à travers execve ; les autres threads d'IdeA ne sont jamais sandboxés). Builder additif with_sandbox_enforcer ; fail-closed si enforce() échoue. Tests : domain + infrastructure verts (dont les tests Landlock réels de fencing read-only/write-only et la confine-au-thread). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 32 ++ crates/infrastructure/Cargo.toml | 7 + crates/infrastructure/src/lib.rs | 4 + crates/infrastructure/src/pty/mod.rs | 91 +++- crates/infrastructure/src/sandbox/landlock.rs | 412 ++++++++++++++++++ crates/infrastructure/src/sandbox/mod.rs | 82 ++++ crates/infrastructure/src/sandbox/noop.rs | 33 ++ 7 files changed, 653 insertions(+), 8 deletions(-) create mode 100644 crates/infrastructure/src/sandbox/landlock.rs create mode 100644 crates/infrastructure/src/sandbox/mod.rs create mode 100644 crates/infrastructure/src/sandbox/noop.rs diff --git a/Cargo.lock b/Cargo.lock index 59f9326..c0985bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -979,6 +979,26 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1923,6 +1943,7 @@ dependencies = [ "domain", "fastembed", "git2", + "landlock", "notify", "portable-pty", "reqwest 0.12.28", @@ -2131,6 +2152,17 @@ dependencies = [ "libc", ] +[[package]] +name = "landlock" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "635839550ae8b90d9fd2571460a6645dc0aec070225956ca7a2831ed31d2795d" +dependencies = [ + "enumflags2", + "libc", + "thiserror 2.0.18", +] + [[package]] name = "lazy_static" version = "1.5.0" diff --git a/crates/infrastructure/Cargo.toml b/crates/infrastructure/Cargo.toml index 52e05bc..bbfa886 100644 --- a/crates/infrastructure/Cargo.toml +++ b/crates/infrastructure/Cargo.toml @@ -22,6 +22,10 @@ serde = { workspace = true } serde_json = { workspace = true } portable-pty = "0.9" git2 = { workspace = true } + +# OS sandbox (lot LP4-1) — Linux Landlock LSM, best-effort/ABI-compat. Linux-only +# target dep so the Windows/macOS builds (and the Noop fallback) compile nothing +# extra. `landlock` is a thin, dependency-light wrapper over the kernel ABI. # Filesystem change notifications used to *wake* the orchestrator poll loop early # (the poll loop remains the robust cross-platform correctness guarantee). notify = "6" @@ -36,6 +40,9 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus # `vector-onnx` feature; the zero-dependency default compiles nothing extra. fastembed = { version = "5", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"], optional = true } +[target.'cfg(target_os = "linux")'.dependencies] +landlock = "0.4.5" + [features] # Real HTTP-backed embedders (`localServer` Ollama/llama.cpp, `api` OpenAI/Voyage…). # OFF by default: the founding posture is `none` ⇒ naïve recall, zero dependency. diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 1ed8376..df9e914 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -29,6 +29,7 @@ pub mod process; pub mod pty; pub mod remote; pub mod runtime; +pub mod sandbox; pub mod session; pub mod store; @@ -55,6 +56,9 @@ pub use process::LocalProcessSpawner; pub use pty::PortablePtyAdapter; pub use remote::{remote_host, LocalHost}; pub use runtime::CliAgentRuntime; +#[cfg(target_os = "linux")] +pub use sandbox::LandlockSandbox; +pub use sandbox::{default_enforcer, NoopSandbox}; pub use session::{ClaudeSdkSession, CodexExecSession, FakeCli, StructuredSessionFactory}; #[cfg(feature = "vector-onnx")] pub use store::OnnxEmbedder; diff --git a/crates/infrastructure/src/pty/mod.rs b/crates/infrastructure/src/pty/mod.rs index 9322cbe..2aec7c6 100644 --- a/crates/infrastructure/src/pty/mod.rs +++ b/crates/infrastructure/src/pty/mod.rs @@ -42,9 +42,10 @@ use std::sync::{Arc, Mutex}; use std::thread::JoinHandle; use async_trait::async_trait; -use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem}; +use portable_pty::{Child, CommandBuilder, MasterPty, NativePtySystem, PtySystem, SlavePty}; use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle, PtyPort, SpawnSpec}; +use domain::sandbox::{SandboxEnforcer, SandboxPlan}; use domain::terminal::PtySize; use domain::SessionId; @@ -143,16 +144,32 @@ struct LivePty { #[derive(Default)] pub struct PortablePtyAdapter { sessions: Mutex>, + /// Optional OS-sandbox enforcer (lot LP4-1). `None` ⇒ no sandboxing at all + /// (the default, zero behaviour change). When set **and** a [`SpawnSpec`] + /// carries a [`SandboxPlan`], the plan is enforced on the child at spawn + /// (see [`spawn_command_sandboxed`]). + sandbox_enforcer: Option>, } impl PortablePtyAdapter { - /// Creates an empty adapter. + /// Creates an empty adapter (no OS sandbox). #[must_use] pub fn new() -> Self { Self { sessions: Mutex::new(HashMap::new()), + sandbox_enforcer: None, } } + + /// Additive builder: wires an OS-sandbox [`SandboxEnforcer`] (lot LP4-1). With + /// it set, any [`SpawnSpec`] whose `sandbox` is `Some` has that plan enforced on + /// the spawned child. Without it (the default), no spawn is ever sandboxed — + /// `new()`'s behaviour is unchanged. + #[must_use] + pub fn with_sandbox_enforcer(mut self, enforcer: Arc) -> Self { + self.sandbox_enforcer = Some(enforcer); + self + } } /// Maps the domain [`PtySize`] to the `portable-pty` one. @@ -165,6 +182,54 @@ fn to_pty_size(size: PtySize) -> portable_pty::PtySize { } } +/// Spawns the child **under an OS sandbox** (lot LP4-1). +/// +/// ## Why a dedicated thread instead of a `pre_exec` hook +/// +/// `portable-pty` 0.9 exposes **no** user-injectable `pre_exec` closure: its +/// `CommandBuilder` has no such method, and `SlavePty::spawn_command` installs its +/// *own* internal `pre_exec` (for `setsid` / controlling-tty) before exec, with no +/// extension point. So we cannot run `enforcer.enforce(plan)` in the child's +/// `pre_exec` directly. +/// +/// Instead we lean on a guaranteed kernel property: **a Landlock domain is +/// inherited across `fork` and preserved across `execve`**. We therefore restrict a +/// **dedicated throwaway thread** with `enforce`, then call `spawn_command` *from +/// that thread*. `std::process::Command` (which `spawn_command` drives) forks from +/// the calling thread — and because `portable-pty` always sets a `pre_exec`, std is +/// forced down the `fork`+`exec` path (never `posix_spawn`) — so the child inherits +/// the restricted thread's domain. The throwaway thread then dies, taking its +/// (irreversible) restriction with it, leaving IdeA's other threads untouched. +/// +/// On `enforce` returning `Err` (fail-closed, e.g. a `Deny` posture on a kernel +/// without Landlock) the spawn is failed and **no child runs**. `Ok(Unsupported / +/// Degraded)` proceeds (best-effort). +/// +/// Everything is **moved** into the thread (no borrow), so no `Sync` bound is +/// required of the slave; the resulting `Child` is `Send` and returned to the +/// caller. +fn spawn_command_sandboxed( + slave: Box, + cmd: CommandBuilder, + enforcer: Arc, + plan: SandboxPlan, +) -> Result, PtyError> { + let join = std::thread::spawn(move || -> Result, PtyError> { + // Restrict THIS thread; the forked child inherits the domain. + if let Err(e) = enforcer.enforce(&plan) { + return Err(PtyError::Spawn(format!("sandbox enforcement failed: {e}"))); + } + let child = slave + .spawn_command(cmd) + .map_err(|e| PtyError::Spawn(e.to_string()))?; + // The slave is held by the child; drop our copy so EOF propagates on exit. + drop(slave); + Ok(child) + }); + join.join() + .map_err(|_| PtyError::Spawn("sandbox spawn thread panicked".to_owned()))? +} + /// Builds the `portable-pty` command from a domain [`SpawnSpec`]. fn to_command(spec: &SpawnSpec) -> CommandBuilder { let mut cmd = CommandBuilder::new(&spec.command); @@ -185,12 +250,22 @@ impl PtyPort for PortablePtyAdapter { .map_err(|e| PtyError::Spawn(e.to_string()))?; let cmd = to_command(&spec); - let child = pair - .slave - .spawn_command(cmd) - .map_err(|e| PtyError::Spawn(e.to_string()))?; - // The slave is held by the child; drop our copy so EOF propagates on exit. - drop(pair.slave); + // Move the slave out of the pair (the master stays usable) so it can be + // either spawned directly or handed to the sandboxed-spawn thread. + let slave = pair.slave; + let child = match (spec.sandbox.clone(), self.sandbox_enforcer.clone()) { + // Sandboxed spawn: a plan AND an enforcer are present. + (Some(plan), Some(enforcer)) => spawn_command_sandboxed(slave, cmd, enforcer, plan)?, + // No plan or no enforcer ⇒ plain spawn (the default path, unchanged). + _ => { + let child = slave + .spawn_command(cmd) + .map_err(|e| PtyError::Spawn(e.to_string()))?; + // The slave is held by the child; drop our copy so EOF propagates. + drop(slave); + child + } + }; let writer = pair .master diff --git a/crates/infrastructure/src/sandbox/landlock.rs b/crates/infrastructure/src/sandbox/landlock.rs new file mode 100644 index 0000000..05c4b39 --- /dev/null +++ b/crates/infrastructure/src/sandbox/landlock.rs @@ -0,0 +1,412 @@ +//! [`LandlockSandbox`] — the Linux [`SandboxEnforcer`] backed by the kernel +//! Landlock LSM (lot LP4-1), via the `landlock` crate, **best-effort / ABI-compat**. +//! +//! # What it does +//! +//! [`SandboxEnforcer::enforce`] translates a domain [`SandboxPlan`] into a Landlock +//! ruleset and restricts the **current thread** (the contract says `enforce` runs +//! post-fork / pre-exec in the child, so the restriction is inherited by the exec'd +//! program — Landlock domains survive `execve`). +//! +//! # Per-access-class handling (mirrors the LP4-0 compile invariant) +//! +//! Landlock works by *handling* a set of access rights — everything **not** granted +//! is then denied **for those handled rights only**. We therefore handle a right +//! class **only if the plan actually posed a grant of that class**: +//! +//! - some `RO` grant present ⇒ handle the **read** class (`from_read`): reads become +//! restricted to the granted roots; +//! - some `RW` grant present ⇒ handle the **write** class (`from_write`): writes / +//! create / delete become restricted to the granted roots. +//! +//! A write-only policy thus leaves **reads globally unrestricted** (so the agent can +//! still load `libc`, read `/etc`, …) and only fences writes — exactly the autonomy +//! the per-access-class compile guarantees. The flip side: a policy that restricts +//! reads governs *all* reads, so the compiled plan must include the system read +//! paths the program needs; that enrichment is the launch-path's concern (LP4-2), +//! not this adapter's — here we translate the plan faithfully. +//! +//! # Fallback policy +//! +//! On a kernel/ABI without (sufficient) Landlock, `restrict_self` reports +//! [`RulesetStatus::NotEnforced`]. We then apply the product fallback: **fail-open** +//! ([`SandboxStatus::Unsupported`]) for any posture **except** [`Posture::Deny`], +//! where we **fail-closed** with [`SandboxError::KernelTooOld`] (a `Deny` posture +//! cannot be silently dropped). Partial enforcement (older ABI missing some rights) +//! maps to [`SandboxStatus::Degraded`]. + +use domain::sandbox::{ + PathAccess, SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus, +}; +use domain::Posture; + +// Leading `::` selects the extern `landlock` crate (this module is also named +// `landlock`, so the disambiguation matters). +use ::landlock::{ + path_beneath_rules, AccessFs, BitFlags, CompatLevel, Compatible, Ruleset, RulesetAttr, + RulesetCreatedAttr, RulesetStatus, ABI, +}; + +/// The Landlock ABI we author rules against. `ABI::V1` (Linux ≥ 5.13) is the broad +/// floor; `CompatLevel::BestEffort` lets newer kernels run it unchanged and older / +/// absent support degrade rather than hard-error. +const TARGET_ABI: ABI = ABI::V1; + +/// The Linux Landlock [`SandboxEnforcer`]. +#[derive(Debug, Default, Clone, Copy)] +pub struct LandlockSandbox; + +impl LandlockSandbox { + /// Builds the Landlock enforcer. + #[must_use] + pub fn new() -> Self { + Self + } +} + +/// The Landlock access rights a single grant opens, intersected later with the +/// handled set. An `RW` grant also opens the read rights (writing a tree usually +/// implies reading it) — harmless when reads are ungoverned, correct when they are. +fn grant_access(access: PathAccess, abi: ABI) -> BitFlags { + let mut acc = BitFlags::::empty(); + if access.contains(PathAccess::RO) { + acc |= AccessFs::from_read(abi); + } + if access.contains(PathAccess::RW) { + acc |= AccessFs::from_read(abi) | AccessFs::from_write(abi); + } + if access.contains(PathAccess::EXEC) { + // The domain never emits EXEC today; honoured for completeness. + acc |= AccessFs::Execute; + } + acc +} + +impl SandboxEnforcer for LandlockSandbox { + fn kind(&self) -> SandboxKind { + SandboxKind::Landlock + } + + fn enforce(&self, plan: &SandboxPlan) -> Result { + let abi = TARGET_ABI; + + // Handle a right class only if the plan posed a grant of that class, so an + // unposed class stays globally unrestricted (per-access-class autonomy). + let mut handled = BitFlags::::empty(); + for grant in &plan.allowed { + if grant.access.contains(PathAccess::RO) { + handled |= AccessFs::from_read(abi); + } + if grant.access.contains(PathAccess::RW) { + handled |= AccessFs::from_write(abi); + } + if grant.access.contains(PathAccess::EXEC) { + handled |= AccessFs::Execute; + } + } + + if handled.is_empty() { + // No file restriction to apply (empty / bash-only plan): nothing for + // Landlock to enforce. A `Deny` posture's teeth, if any, then live only + // in the advisory LP3 projection — not this adapter's concern. + return Ok(SandboxStatus::Enforced); + } + + let mut ruleset = Ruleset::default() + .set_compatibility(CompatLevel::BestEffort) + .handle_access(handled) + .and_then(Ruleset::create) + .map_err(|e| SandboxError::Ruleset(e.to_string()))?; + + for grant in &plan.allowed { + let access = grant_access(grant.access, abi) & handled; + if access.is_empty() { + continue; + } + // `path_beneath_rules` silently skips a path it cannot open (a grant on + // a not-yet-existing root just adds no rule — best-effort, never fatal). + ruleset = ruleset + .add_rules(path_beneath_rules([grant.abs_root.as_str()], access)) + .map_err(|e: ::landlock::RulesetError| SandboxError::Ruleset(e.to_string()))?; + } + + let status = ruleset + .restrict_self() + .map_err(|e| SandboxError::Ruleset(e.to_string()))?; + + status_from_ruleset(status.ruleset, plan.default_posture) + } +} + +/// Maps the kernel's [`RulesetStatus`] (after `restrict_self`) plus the plan's +/// fallback [`Posture`] to the domain outcome. **Pure** (no I/O, no kernel): the +/// security-critical decision — including the *fail-closed only under `Deny`* +/// branch — lives here so it is unit-testable without a Landlock-incapable kernel. +/// +/// - [`RulesetStatus::FullyEnforced`] ⇒ `Ok(`[`SandboxStatus::Enforced`]`)` +/// - [`RulesetStatus::PartiallyEnforced`] ⇒ `Ok(`[`SandboxStatus::Degraded`]`)` +/// - [`RulesetStatus::NotEnforced`] + [`Posture::Deny`] ⇒ +/// `Err(`[`SandboxError::KernelTooOld`]`)` (fail-closed) +/// - [`RulesetStatus::NotEnforced`] + any other posture ⇒ +/// `Ok(`[`SandboxStatus::Unsupported`]`)` (fail-open + warning) +fn status_from_ruleset( + status: RulesetStatus, + posture: Posture, +) -> Result { + match status { + RulesetStatus::FullyEnforced => Ok(SandboxStatus::Enforced), + RulesetStatus::PartiallyEnforced => Ok(SandboxStatus::Degraded( + "landlock ABI compatibility reduced the enforced access rights (best-effort)" + .to_owned(), + )), + RulesetStatus::NotEnforced => { + if posture == Posture::Deny { + Err(SandboxError::KernelTooOld( + "landlock unavailable on this kernel but the policy posture is Deny \ + (fail-closed)" + .to_owned(), + )) + } else { + Ok(SandboxStatus::Unsupported) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::sandbox::{PathGrant, SandboxPlan}; + use std::path::PathBuf; + + /// A unique temp dir for one test (no external tempfile dep). + 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-landlock-{tag}-{nanos}")); + std::fs::create_dir_all(&p).unwrap(); + p + } + + /// Real-kernel integration test: a write-only plan that grants RW on one dir + /// must let writes there succeed and block writes elsewhere with EACCES, while + /// leaving reads unrestricted. Runs the enforcement on a dedicated thread (the + /// restriction is irreversible for that thread) and **skips** if the running + /// kernel has no Landlock (CI / old kernels), like the SSH/WSL gated tests. + #[test] + fn landlock_write_only_plan_fences_writes_to_the_grant() { + let allowed = fresh_dir("allowed"); + let denied = fresh_dir("denied"); + let allowed_for_thread = allowed.clone(); + let denied_for_thread = denied.clone(); + + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: allowed.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Ask, + }; + + let outcome = std::thread::spawn(move || { + let status = LandlockSandbox::new() + .enforce(&plan) + .expect("enforce must not error under an Ask posture"); + // The thread is now sandboxed: try a write inside and outside the grant. + let ok_write = std::fs::write(allowed_for_thread.join("inside.txt"), b"hi"); + let ko_write = std::fs::write(denied_for_thread.join("outside.txt"), b"nope"); + (status, ok_write, ko_write.map_err(|e| e.kind())) + }) + .join() + .unwrap(); + + let (status, ok_write, ko_write) = outcome; + + if status == SandboxStatus::Unsupported { + eprintln!("skipping: Landlock not available on this kernel"); + // Clean up best-effort and bail (no enforcement happened). + let _ = std::fs::remove_dir_all(&allowed); + let _ = std::fs::remove_dir_all(&denied); + return; + } + + assert!( + matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)), + "expected an enforced/degraded sandbox, got {status:?}" + ); + assert!( + ok_write.is_ok(), + "write inside the granted root must succeed, got {ok_write:?}" + ); + assert_eq!( + ko_write, + Err(std::io::ErrorKind::PermissionDenied), + "write outside the granted root must be blocked (EACCES)" + ); + + let _ = std::fs::remove_dir_all(&allowed); + let _ = std::fs::remove_dir_all(&denied); + } + + /// An empty plan (no file grant) has nothing to enforce ⇒ `Enforced`, no error. + #[test] + fn empty_plan_is_a_noop_enforced() { + let plan = SandboxPlan { + allowed: vec![], + default_posture: Posture::Ask, + }; + // Run on a throwaway thread to avoid restricting the test runner thread. + let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan)) + .join() + .unwrap() + .unwrap(); + assert_eq!(status, SandboxStatus::Enforced); + } + + /// **Bash-only / empty plan under a `Deny` posture.** A bash-only policy compiles + /// (LP4-0) to an empty `allowed`; even combined with `Posture::Deny` the adapter + /// has no file right to *handle*, so it must early-return `Enforced` WITHOUT + /// restricting anything — and crucially WITHOUT `KernelTooOld`: the fail-closed + /// branch is only ever reached once a class was actually handled. + #[test] + fn empty_plan_under_deny_posture_is_enforced_without_restriction() { + let plan = SandboxPlan { + allowed: vec![], + default_posture: Posture::Deny, + }; + let status = std::thread::spawn(move || LandlockSandbox::new().enforce(&plan)) + .join() + .unwrap() + .expect("an empty plan never fails closed (nothing handled)"); + assert_eq!(status, SandboxStatus::Enforced); + } + + /// **CRITICAL SAFETY PROPERTY — IdeA is never sandboxed.** The adapter restricts + /// the *current thread*; the launch path runs it on a throwaway thread. This test + /// proves the Landlock domain is confined to that throwaway thread and does **not** + /// leak onto the thread that spawned it (which simulates the IdeA process): after + /// the child has sandboxed itself, the parent thread must still read/write OUTSIDE + /// the plan's roots. A leak here would be catastrophic (IdeA itself locked down). + #[test] + fn enforcement_is_confined_to_the_enforcing_thread_idea_is_never_sandboxed() { + let granted = fresh_dir("safety-granted"); + let outside = fresh_dir("safety-outside"); + let granted_t = granted.clone(); + let outside_t = outside.clone(); + + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: granted.to_string_lossy().into_owned(), + access: PathAccess::RW, + }], + default_posture: Posture::Ask, + }; + + // Enforce on a dedicated thread, exactly as the launch path does, and report + // whether the sandbox was actually live there (write outside must be blocked). + let (status, child_outside_write) = std::thread::spawn(move || { + let status = LandlockSandbox::new() + .enforce(&plan) + .expect("enforce must not error under an Ask posture"); + let _ = granted_t; // (writable in the child; not asserted here) + let child_outside = + std::fs::write(outside_t.join("from-child.txt"), b"x").map_err(|e| e.kind()); + (status, child_outside) + }) + .join() + .unwrap(); + + if status == SandboxStatus::Unsupported { + eprintln!("skipping: Landlock not available on this kernel"); + let _ = std::fs::remove_dir_all(&granted); + let _ = std::fs::remove_dir_all(&outside); + return; + } + + // Sanity: the sandbox really was active on the throwaway thread. + assert_eq!( + child_outside_write, + Err(std::io::ErrorKind::PermissionDenied), + "the throwaway thread must be sandboxed (write outside the grant blocked)" + ); + + // THE GUARANTEE: the parent thread (≈ the IdeA process) is not a descendant of + // the throwaway thread, so the domain must not have leaked onto it. + let idea_write = std::fs::write(outside.join("from-idea.txt"), b"idea"); + assert!( + idea_write.is_ok(), + "SAFETY VIOLATION: IdeA's own thread was sandboxed — write outside the plan \ + failed ({idea_write:?}); the Landlock domain leaked off the throwaway thread" + ); + let idea_read = std::fs::read(outside.join("from-idea.txt")); + assert!( + idea_read.is_ok(), + "IdeA must still read outside the plan roots, got {idea_read:?}" + ); + + let _ = std::fs::remove_dir_all(&granted); + let _ = std::fs::remove_dir_all(&outside); + } + + /// **READ (RO) dimension.** A plan with a single RO grant: from the sandboxed + /// child, reading a file UNDER the granted root succeeds, while reading a file + /// OUTSIDE is denied (EACCES). This also documents the behaviour DevBackend + /// flagged: as soon as *any* RO grant is posed the read class is handled, so + /// **every** read outside the granted roots is closed — a realistic read-restricted + /// plan must therefore include the needed system paths (an LP4-2 concern). + #[test] + fn read_only_plan_fences_reads_to_the_grant() { + let granted = fresh_dir("ro-granted"); + let outside = fresh_dir("ro-outside"); + // Files must exist before enforcement (creation is a write). + std::fs::write(granted.join("in.txt"), b"inside").unwrap(); + std::fs::write(outside.join("out.txt"), b"outside").unwrap(); + let granted_t = granted.clone(); + let outside_t = outside.clone(); + + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: granted.to_string_lossy().into_owned(), + access: PathAccess::RO, + }], + default_posture: Posture::Ask, + }; + + let (status, ok_read, ko_read) = std::thread::spawn(move || { + let status = LandlockSandbox::new() + .enforce(&plan) + .expect("enforce must not error under an Ask posture"); + let ok = std::fs::read(granted_t.join("in.txt")); + let ko = std::fs::read(outside_t.join("out.txt")).map_err(|e| e.kind()); + (status, ok, ko) + }) + .join() + .unwrap(); + + if status == SandboxStatus::Unsupported { + eprintln!("skipping: Landlock not available on this kernel"); + let _ = std::fs::remove_dir_all(&granted); + let _ = std::fs::remove_dir_all(&outside); + return; + } + + assert!( + matches!(status, SandboxStatus::Enforced | SandboxStatus::Degraded(_)), + "expected an enforced/degraded sandbox, got {status:?}" + ); + assert!( + ok_read.is_ok(), + "read inside the granted RO root must succeed, got {ok_read:?}" + ); + assert_eq!( + ko_read, + Err(std::io::ErrorKind::PermissionDenied), + "once a RO grant is posed, reads outside the granted roots are closed (EACCES)" + ); + + let _ = std::fs::remove_dir_all(&granted); + let _ = std::fs::remove_dir_all(&outside); + } +} diff --git a/crates/infrastructure/src/sandbox/mod.rs b/crates/infrastructure/src/sandbox/mod.rs new file mode 100644 index 0000000..d489b70 --- /dev/null +++ b/crates/infrastructure/src/sandbox/mod.rs @@ -0,0 +1,82 @@ +//! OS-sandbox adapters (lot LP4-1) — concrete [`domain::sandbox::SandboxEnforcer`] +//! implementations and the cfg-selected default constructor. +//! +//! - [`LandlockSandbox`] (Linux only) enforces a [`domain::sandbox::SandboxPlan`] +//! via the kernel Landlock LSM. +//! - [`NoopSandbox`] (everywhere) enforces nothing — the non-Linux / fallback path. +//! +//! [`default_enforcer`] returns the right one for the build target as a +//! `Arc`, ready for the composition root (LP4-3) to inject. +//! Nothing here is wired into the launch path yet (that is LP4-2): until then the +//! enforcer stays unused and `SpawnSpec.sandbox` stays `None`, so there is zero +//! runtime behaviour change. + +use std::sync::Arc; + +use domain::sandbox::SandboxEnforcer; + +mod noop; +pub use noop::NoopSandbox; + +#[cfg(target_os = "linux")] +mod landlock; +#[cfg(target_os = "linux")] +pub use landlock::LandlockSandbox; + +/// The default OS-sandbox enforcer for the current build target: [`LandlockSandbox`] +/// on Linux, [`NoopSandbox`] everywhere else. +#[cfg(target_os = "linux")] +#[must_use] +pub fn default_enforcer() -> Arc { + Arc::new(LandlockSandbox::new()) +} + +/// The default OS-sandbox enforcer for the current build target: [`NoopSandbox`] +/// on non-Linux platforms (no OS sandbox wired yet). +#[cfg(not(target_os = "linux"))] +#[must_use] +pub fn default_enforcer() -> Arc { + Arc::new(NoopSandbox::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + use domain::sandbox::{SandboxKind, SandboxPlan, SandboxStatus}; + use domain::Posture; + + #[test] + fn noop_enforcer_is_unsupported_and_never_errors() { + use domain::sandbox::PathGrant; + + let noop = NoopSandbox::new(); + assert_eq!(noop.kind(), SandboxKind::Unsupported); + + // Across every posture — and even with grants present — the no-op enforces + // nothing and never fails closed: there is no OS sandbox here to fail closed + // against (the Deny posture's teeth live only in the advisory LP3 projection). + for posture in [Posture::Allow, Posture::Ask, Posture::Deny] { + let plan = SandboxPlan { + allowed: vec![PathGrant { + abs_root: "/whatever".to_owned(), + access: domain::sandbox::PathAccess::RW, + }], + default_posture: posture, + }; + assert_eq!( + noop.enforce(&plan).unwrap(), + SandboxStatus::Unsupported, + "no-op must return Unsupported (never Err) under posture {posture:?}" + ); + } + } + + #[test] + fn default_enforcer_matches_the_build_target() { + let enforcer = default_enforcer(); + #[cfg(target_os = "linux")] + assert_eq!(enforcer.kind(), SandboxKind::Landlock); + #[cfg(not(target_os = "linux"))] + assert_eq!(enforcer.kind(), SandboxKind::Unsupported); + } +} diff --git a/crates/infrastructure/src/sandbox/noop.rs b/crates/infrastructure/src/sandbox/noop.rs new file mode 100644 index 0000000..5d87dba --- /dev/null +++ b/crates/infrastructure/src/sandbox/noop.rs @@ -0,0 +1,33 @@ +//! [`NoopSandbox`] — the no-op [`SandboxEnforcer`] for platforms without an OS +//! sandbox (Windows, macOS) and the universal fallback (lot LP4-1). +//! +//! It compiles **everywhere** and never restricts anything: `enforce` always +//! returns [`SandboxStatus::Unsupported`] and never errors. The caller keeps the +//! advisory LP3 projection as its only guard. + +use domain::sandbox::{SandboxEnforcer, SandboxError, SandboxKind, SandboxPlan, SandboxStatus}; + +/// A [`SandboxEnforcer`] that enforces nothing (non-Linux / fallback). +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopSandbox; + +impl NoopSandbox { + /// Builds the no-op enforcer. + #[must_use] + pub fn new() -> Self { + Self + } +} + +impl SandboxEnforcer for NoopSandbox { + fn kind(&self) -> SandboxKind { + SandboxKind::Unsupported + } + + fn enforce(&self, _plan: &SandboxPlan) -> Result { + // No OS sandbox here: nothing enforced, never an error (the fail-closed + // posture handling is a Landlock-only concern; on an unsupported platform + // there is nothing to fail closed *against*). + Ok(SandboxStatus::Unsupported) + } +}