//! [`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; // Landlock is an **additive allowlist** — it can only *remove* access, i.e. // it is deny-by-default. A `Posture::Allow` fallback means the opposite, // **allow-by-default**: paths matched by no grant must stay reachable. An // allowlist cannot express that (it would lock everything down to the granted // roots, closing the CLI binary, its libs and `~/.` home — the very // "failed to open terminal" symptom). So under an Allow fallback we enforce // nothing: the policy's only teeth are explicit Denies, which are deny-islands // under an allow-all that an additive sandbox cannot carve out — they remain // advisory via the LP3 projection. This is a property of the allowlist // mechanism, hence it lives in the adapter, not the pure plan. if plan.default_posture == Posture::Allow { return Ok(SandboxStatus::Enforced); } // 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); } /// **Allow fallback ⇒ no OS restriction even with grants posed.** Setting the /// policy to allow-by-default (and, in the UI, every option to Allow) emits RO|RW /// grants scoped to the project root. An allowlist sandbox would then close every /// read/write OUTSIDE that root — locking out the CLI binary, its libs and home, /// the exact "failed to open terminal" bug. Under `Posture::Allow` the adapter must /// instead enforce nothing: from the (would-be) sandboxed thread, a read AND a /// write OUTSIDE the only grant must both still succeed. #[test] fn allow_fallback_does_not_restrict_even_with_grants() { let granted = fresh_dir("allow-granted"); let outside = fresh_dir("allow-outside"); std::fs::write(outside.join("pre.txt"), b"pre").unwrap(); let outside_t = outside.clone(); let plan = SandboxPlan { allowed: vec![PathGrant { abs_root: granted.to_string_lossy().into_owned(), access: PathAccess::RO.union(PathAccess::RW), }], default_posture: Posture::Allow, }; let (status, read_outside, write_outside) = std::thread::spawn(move || { let status = LandlockSandbox::new() .enforce(&plan) .expect("an Allow fallback never fails closed"); let read = std::fs::read(outside_t.join("pre.txt")); let write = std::fs::write(outside_t.join("new.txt"), b"x").map_err(|e| e.kind()); (status, read, write) }) .join() .unwrap(); assert_eq!( status, SandboxStatus::Enforced, "Allow fallback is a no-op enforcement, not an error or a lock-down" ); assert!( read_outside.is_ok(), "Allow fallback must keep reads outside the grant open, got {read_outside:?}" ); assert_eq!( write_outside, Ok(()), "Allow fallback must keep writes outside the grant open" ); let _ = std::fs::remove_dir_all(&granted); let _ = std::fs::remove_dir_all(&outside); } /// **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); } }