diff --git a/crates/tinybox-jail/Cargo.toml b/crates/tinybox-jail/Cargo.toml index db6a4b8..b4f32ce 100644 --- a/crates/tinybox-jail/Cargo.toml +++ b/crates/tinybox-jail/Cargo.toml @@ -13,8 +13,10 @@ categories.workspace = true publish = false [features] -# Enables the Linux Landlock backend. Without it `LandlockBackend` reports -# itself unavailable and default spawning returns an unsupported error. +# On by default so a plain dependency is actually confined on Linux. Without +# the feature `LandlockBackend` reports itself unavailable and `spawn` returns +# `Unsupported` (it never runs the command unconfined). +default = ["landlock"] landlock = ["dep:landlock"] [dependencies] diff --git a/crates/tinybox-jail/README.md b/crates/tinybox-jail/README.md index d67d2a3..ea80fce 100644 --- a/crates/tinybox-jail/README.md +++ b/crates/tinybox-jail/README.md @@ -1,11 +1,12 @@ # cwd_jail Directory-jail facade. Given a declarative description of a workspace -(`Jail`), it spawns a child through an available sandbox backend. Platform -backends are currently disabled until they can satisfy the workspace safety -policy and enforce the declared jail contract. The default backend therefore -returns `Unsupported`; callers can explicitly select `NoopBackend` when -unrestricted execution is intended. +(`Jail`), it spawns a child through an available sandbox backend. Linux +(Landlock) and macOS (Seatbelt) backends are compiled and selected by +`pick_backend()`. The Windows AppContainer backend is still not compiled (see +below). When no backend is usable the default backend is `unsupported` +(`is_available() == false`, `spawn` fails with `Unsupported`); callers can +explicitly select `NoopBackend` when unrestricted execution is intended. It is a per-process complement to the box-level isolation in `tinybox-linux`: the autonomy gate decides whether a command may run, and `cwd_jail` decides what filesystem the approved child process sees. It jails the child it @@ -19,8 +20,8 @@ spawns, never the core process itself. root the same access as the root (Landlock rule, Seatbelt `file-write*` subpath, `AppContainer` ACL), for host-owned scratch such as a per-call output-capture directory that must not land inside the root. -- Cache the default backend; currently this is an unsupported backend on every - platform while OS implementations are being brought into compliance. +- Cache the default backend: Landlock on Linux kernels that support it, + Seatbelt on macOS, otherwise the `unsupported` backend. - Spawn a `std::process::Command` inside the jail, canonicalizing `root` (and the read-only and read/write paths) first so backends never see `..` or symlink trickery. @@ -36,11 +37,11 @@ spawns, never the core process itself. | --- | --- | | `crates/tinybox-jail/src/lib.rs` | Module docstring plus the thin facade: `spawn` / `spawn_with` / `default_backend` (cached via `OnceLock`). Re-exports the public surface. | | `crates/tinybox-jail/src/jail.rs` | Core types: the `Jail` description struct (builder plus `canonicalize`/`canonicalize_or_log`) and the `JailBackend` trait (`name`/`is_available`/`spawn`). | -| `crates/tinybox-jail/src/detect.rs` | `pick_backend()`: returns an unsupported backend until a compliant platform backend is available. | +| `crates/tinybox-jail/src/detect.rs` | `pick_backend()`: first available OS backend, else an unsupported backend that fails closed. | | `crates/tinybox-jail/src/noop.rs` | `NoopBackend`: no enforcement, plain `Command::spawn`. Always available. | -| `crates/tinybox-jail/src/linux.rs` | Proposed Landlock implementation; currently not compiled or selected. | -| `crates/tinybox-jail/src/macos.rs` | Proposed Seatbelt implementation; currently not compiled or selected. | -| `crates/tinybox-jail/src/windows.rs` | Proposed AppContainer implementation; currently not compiled or selected. | +| `crates/tinybox-jail/src/linux.rs` | Landlock backend (Linux only, `landlock` feature, on by default). Applies the ruleset to a dedicated spawn thread so no `unsafe` `pre_exec` is needed. | +| `crates/tinybox-jail/src/macos.rs` | Seatbelt backend via `sandbox-exec`. Compiled on every host so the profile renderer is unit-tested everywhere; selected only on macOS. | +| `crates/tinybox-jail/src/windows.rs` | AppContainer implementation; **not compiled**: it needs `unsafe` FFI the workspace forbids and cannot return a waitable `std::process::Child` yet. | | `crates/tinybox-jail/src/registry.rs` | `JailRegistry` and `JailRecord`: multi-jail manager persisted to `index.json`, with atomic-rename writes and containment checks. | | `crates/tinybox-jail/src/{lib,jail,noop,macos,windows,registry}_tests.rs` | Sibling test suites, each `#[path]`-included from its source file. | @@ -123,6 +124,11 @@ not import that module. returns `io::ErrorKind::Unsupported`. See the TODO in `windows.rs`. The Windows path is compile-checked but flagged as needing real-hardware testing. +- macOS forwards only variables explicitly supplied with `Command::env` or + `Command::envs`. The launcher clears the inherited environment because Rust + exposes no getter for `Command::env_clear`; this prevents restoring parent + credentials a caller deliberately removed. Supply required variables such + as `PATH` explicitly. Linux preserves the original command environment. - macOS stdio is inherited: the Seatbelt wrapper cannot re-apply the original command's `Stdio` config, so it uses `sandbox-exec` defaults (inherit). The profile re-allows writes under the canonicalized `root` and @@ -130,9 +136,20 @@ not import that module. it does not grant `/dev` generally. Callers must canonicalize the root first (the `spawn` facade does this automatically) or writes inside it may be denied (for example `/tmp` resolving to `/private/tmp`). -- Linux Landlock runs in `pre_exec` (child-side, after fork), so the parent - keeps its privileges; read-only paths also get `Execute` so the child can - run binaries found there (for example `/usr/bin/sh`). +- Linux Landlock is applied to a short-lived dedicated thread that then + spawns the command; the child inherits the thread's domain and + `no_new_privs`, the caller's thread keeps its privileges. Read-only paths + also get `Execute` so the child can run binaries found there. +- Linux baseline grants (see `SYSTEM_READ_PATHS`, `DEVICE_PATHS` in + `linux.rs`): `/usr /bin /sbin /lib* /etc` read+execute and a few harmless + `/dev` nodes read+write. Everything else is denied unless the `Jail` grants + it: the rest of `$HOME`, `/proc`, `/sys` and `/tmp` included. Grant scratch + space and toolchain caches with `add_read_write` / `add_read_only`. +- On a kernel without Landlock (or a build without the `landlock` feature) + the backend reports unavailable and `spawn` returns `Unsupported`; it never + runs the command unconfined. The availability probe checks basic support; + spawning also rejects partially enforced rulesets with `Unsupported` before + running the child, including older ABIs that cannot restrict truncation. - Registry containment guard: both `delete` and `jail_for` (used by `spawn_in`/`spawn_in_with`) refuse to operate on a record whose canonicalized `dir` is not under the canonicalized `base`, defending diff --git a/crates/tinybox-jail/src/detect.rs b/crates/tinybox-jail/src/detect.rs index 77e011c..ca5e1d9 100644 --- a/crates/tinybox-jail/src/detect.rs +++ b/crates/tinybox-jail/src/detect.rs @@ -5,12 +5,15 @@ use std::sync::Arc; use super::jail::{Jail, JailBackend}; use std::process::{Child, Command}; +/// Name reported by the backend returned when no OS sandbox is usable. +pub const UNSUPPORTED_BACKEND_NAME: &str = "unsupported"; + #[derive(Debug)] struct UnsupportedBackend; impl JailBackend for UnsupportedBackend { fn name(&self) -> &'static str { - "unsupported" + UNSUPPORTED_BACKEND_NAME } fn is_available(&self) -> bool { @@ -25,11 +28,47 @@ impl JailBackend for UnsupportedBackend { } } -/// Picks the strongest available backend, returning an unsupported backend -/// when no OS sandbox works. +/// The OS backends this build knows about, strongest first. +/// +/// Windows `AppContainer` is intentionally absent: it cannot hand back a +/// waitable `std::process::Child` yet (see `windows.rs`). +#[cfg(target_os = "linux")] +fn candidates() -> Vec> { + vec![Arc::new(crate::linux::LandlockBackend::new())] +} + +/// The OS backends this build knows about, strongest first. +#[cfg(target_os = "macos")] +fn candidates() -> Vec> { + vec![Arc::new(crate::macos::SeatbeltBackend::new())] +} + +/// The OS backends this build knows about, strongest first. None here. +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn candidates() -> Vec> { + Vec::new() +} + +/// Picks the first available OS backend (Landlock on Linux, Seatbelt on +/// macOS). When none works it logs a warning and returns an unsupported +/// backend whose `is_available` is `false` and whose `spawn` fails with +/// `ErrorKind::Unsupported`. It never silently returns an unconfined backend: +/// a caller that wants to run unconfined must choose `NoopBackend` itself. #[must_use] pub fn pick_backend() -> Arc { - log::warn!("[cwd_jail] no OS sandbox available"); + pick_from(candidates()) +} + +/// Select an available backend from the ordered platform candidates. +fn pick_from(backends: Vec>) -> Arc { + for backend in backends { + if backend.is_available() { + log::debug!("[cwd_jail] selected OS sandbox backend {}", backend.name()); + return backend; + } + log::debug!("[cwd_jail] backend {} is not available", backend.name()); + } + log::warn!("[cwd_jail] no OS sandbox available; jailed spawns are unsupported"); Arc::new(UnsupportedBackend) } diff --git a/crates/tinybox-jail/src/detect_tests.rs b/crates/tinybox-jail/src/detect_tests.rs index d817cc2..b0568dc 100644 --- a/crates/tinybox-jail/src/detect_tests.rs +++ b/crates/tinybox-jail/src/detect_tests.rs @@ -5,7 +5,7 @@ use super::*; #[test] fn unavailable_backend_rejects_spawning() { let backend = UnsupportedBackend; - assert_eq!(backend.name(), "unsupported"); + assert_eq!(backend.name(), UNSUPPORTED_BACKEND_NAME); assert!(!backend.is_available()); let error = backend .spawn(&Jail::new(".", "unsupported"), Command::new("true")) @@ -18,3 +18,56 @@ fn unavailable_backend_rejects_spawning() { fn backend_detection_returns_a_backend() { assert_ne!(pick_backend().name().len(), 0); } + +#[test] +fn detection_prefers_the_platform_backend_when_it_works() { + let picked = pick_backend(); + let expected = candidates() + .into_iter() + .find(|backend| backend.is_available()); + if let Some(backend) = expected { + assert_eq!(picked.name(), backend.name()); + assert!(picked.is_available()); + } else { + assert_eq!(picked.name(), UNSUPPORTED_BACKEND_NAME); + assert!(!picked.is_available()); + } +} + +#[cfg(all(target_os = "linux", feature = "landlock"))] +#[test] +fn linux_with_landlock_selects_landlock_on_a_supporting_kernel() { + if crate::linux::LandlockBackend::new().is_available() { + assert_eq!(pick_backend().name(), "landlock"); + } +} + +#[test] +fn windows_appcontainer_is_never_a_candidate() { + assert!( + candidates() + .iter() + .all(|backend| backend.name() != "appcontainer") + ); +} + +#[test] +fn unavailable_candidates_are_skipped_and_empty_candidates_fail_closed() { + for candidates in [ + vec![], + vec![Arc::new(UnsupportedBackend) as Arc], + ] { + let picked = pick_from(candidates); + assert_eq!(picked.name(), UNSUPPORTED_BACKEND_NAME); + assert!(!picked.is_available()); + } +} + +#[test] +fn selection_uses_the_first_available_candidate() { + let picked = pick_from(vec![ + Arc::new(UnsupportedBackend), + Arc::new(crate::noop::NoopBackend), + ]); + assert_eq!(picked.name(), crate::noop::NOOP_BACKEND_NAME); +} diff --git a/crates/tinybox-jail/src/lib.rs b/crates/tinybox-jail/src/lib.rs index 943e6ce..94101b9 100644 --- a/crates/tinybox-jail/src/lib.rs +++ b/crates/tinybox-jail/src/lib.rs @@ -12,11 +12,14 @@ //! //! | OS | Backend | Mechanism | //! |---------|---------------|--------------------------------------------| -//! | Linux | landlock | Kernel 5.13+ LSM, applied in `pre_exec` | +//! | Linux | landlock | Kernel 5.13+ LSM, applied on a spawn thread | //! | macOS | seatbelt | `sandbox-exec -p '' …` | -//! | Windows | appcontainer | `CreateAppContainerProfile` + `STARTUPINFOEX` | +//! | Windows | (not compiled)| `AppContainer`, pending a `Child` bridge | //! | other | unsupported | Spawning is rejected | //! +//! The Windows backend is not compiled yet (see `windows.rs`); on Windows the +//! default backend is `unsupported` and a host must opt into `NoopBackend`. +//! //! ## Quick start //! //! ```ignore @@ -50,10 +53,20 @@ pub mod jail; pub mod noop; pub mod registry; -// Platform backends are intentionally not compiled until they can preserve -// the workspace unsafe-code policy and enforce the public jail contract. +// Platform backends. Linux (Landlock) is compiled on Linux only. The Seatbelt +// module is plain `std` (it shells out to `sandbox-exec`), so it compiles +// everywhere and its profile renderer is unit-tested on every host; it is only +// *selected* on macOS. The Windows AppContainer module (`windows.rs`) is +// deliberately not compiled: it needs `unsafe` FFI the workspace forbids and +// cannot yet return a waitable `std::process::Child`. +#[cfg(target_os = "linux")] +pub mod linux; +pub mod macos; pub use jail::{Jail, JailBackend}; +#[cfg(target_os = "linux")] +pub use linux::LandlockBackend; +pub use macos::SeatbeltBackend; pub use noop::{NOOP_BACKEND_NAME, NoopBackend}; pub use registry::{JailRecord, JailRegistry}; diff --git a/crates/tinybox-jail/src/linux.rs b/crates/tinybox-jail/src/linux.rs index 592cbf9..5aa28f1 100644 --- a/crates/tinybox-jail/src/linux.rs +++ b/crates/tinybox-jail/src/linux.rs @@ -1,18 +1,78 @@ //! Linux backend: Landlock LSM (kernel 5.13+). //! -//! Mirrors the host-side Landlock implementation -//! but wraps it behind the [`JailBackend`] trait so callers don't have to -//! plumb `SecurityConfig`. Landlock is applied via `pre_exec`, which runs -//! in the *child* process after `fork()` and before `exec()` — the parent -//! retains its broader privileges, the child gets the ruleset before any -//! user code runs. Same model used by Chromium's Linux sandbox. - -#![cfg(target_os = "linux")] +//! Landlock restricts the *calling thread* and everything that thread later +//! forks. The usual way to confine a child is `CommandExt::pre_exec`, but that +//! is `unsafe` and this workspace forbids `unsafe_code`. Instead the ruleset is +//! applied to a short-lived dedicated thread and the command is spawned from +//! that thread: the child inherits the thread's Landlock domain (and +//! `no_new_privs`), the thread is discarded after the spawn, and the calling +//! thread and the rest of the process keep their full privileges. No unsafe +//! code is needed in this crate (the `landlock` crate owns the syscalls). +//! +//! # What the jail grants +//! +//! - `jail.root` and every `jail.read_write` path: read, write and execute. +//! - every `jail.read_only` path: read and execute. +//! - a fixed baseline so an ordinary shell can start at all: the system +//! directories in [`SYSTEM_READ_PATHS`] (read and execute) and the harmless +//! character devices in [`DEVICE_PATHS`] (read and write). Missing baseline +//! paths are skipped. +//! +//! Everything else on the filesystem is denied, including the rest of the home +//! directory (`~/.ssh`, `~/.aws`, ...), `/proc` and `/sys`, and `/tmp`. A host +//! that wants a scratch directory grants it with `add_read_write`. +//! +//! Landlock does not gate the network or process creation, so `allow_net` and +//! `allow_subprocess` are not enforced by this backend. +//! +//! # Failing closed on old kernels +//! +//! [`LandlockBackend::is_available`] probes the kernel. When Landlock is not +//! supported (kernel older than 5.13, or the LSM is not enabled) the backend +//! reports unavailable, [`crate::detect::pick_backend`] moves on, and `spawn` +//! returns `ErrorKind::Unsupported` without ever running the command +//! unconfined. The availability probe checks basic Landlock support; spawning +//! additionally requires the complete filesystem policy to be enforced. A +//! partially enforced ruleset (for example, on an older ABI without truncate +//! restrictions) returns `ErrorKind::Unsupported` before spawning the child. +use std::io; use std::process::{Child, Command}; use super::jail::{Jail, JailBackend}; +/// Backend name reported by [`LandlockBackend`]. +pub const LANDLOCK_BACKEND_NAME: &str = "landlock"; + +/// System directories every jailed child may read and execute from, so that a +/// shell, the dynamic loader and the C library can start. Missing entries are +/// skipped. +pub const SYSTEM_READ_PATHS: &[&str] = &[ + "/usr", + "/bin", + "/sbin", + "/lib", + "/lib32", + "/lib64", + "/libx32", + "/etc", + // `/etc/resolv.conf` is a symlink into here on systemd-resolved hosts; + // without it no name resolves. + "/run/systemd/resolve", +]; + +/// Character devices every jailed child may read and write: shell +/// redirections to `/dev/null`, entropy, and the controlling terminal. Missing +/// entries are skipped. +pub const DEVICE_PATHS: &[&str] = &[ + "/dev/null", + "/dev/zero", + "/dev/full", + "/dev/random", + "/dev/urandom", + "/dev/tty", +]; + /// Landlock LSM backend (kernel 5.13+). #[derive(Debug)] pub struct LandlockBackend; @@ -25,6 +85,7 @@ impl Default for LandlockBackend { impl LandlockBackend { /// Creates the backend; availability is checked by `is_available`. + #[must_use] pub fn new() -> Self { Self } @@ -32,91 +93,226 @@ impl LandlockBackend { impl JailBackend for LandlockBackend { fn name(&self) -> &'static str { - "landlock" + LANDLOCK_BACKEND_NAME } fn is_available(&self) -> bool { - #[cfg(feature = "landlock")] - { - use landlock::{AccessFs, Ruleset, RulesetAttr}; - Ruleset::default() - .handle_access(AccessFs::ReadFile) - .and_then(|r| r.create()) - .is_ok() + imp::kernel_supports_landlock() + } + + fn spawn(&self, jail: &Jail, cmd: Command) -> io::Result { + imp::spawn(jail, cmd) + } +} + +#[cfg(feature = "landlock")] +mod imp { + use std::fs::File; + use std::io; + use std::os::fd::AsFd; + use std::path::Path; + use std::process::{Child, Command}; + + use landlock::{ + ABI, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr, + RulesetCreated, RulesetCreatedAttr, RulesetStatus, + }; + + use super::{DEVICE_PATHS, SYSTEM_READ_PATHS}; + use crate::jail::Jail; + + fn writes() -> landlock::BitFlags { + AccessFs::WriteFile + | AccessFs::RemoveDir + | AccessFs::RemoveFile + | AccessFs::MakeChar + | AccessFs::MakeDir + | AccessFs::MakeReg + | AccessFs::MakeSock + | AccessFs::MakeFifo + | AccessFs::MakeBlock + | AccessFs::MakeSym + | AccessFs::Refer + | AccessFs::Truncate + } + + fn reads() -> landlock::BitFlags { + AccessFs::Execute | AccessFs::ReadFile | AccessFs::ReadDir + } + + fn other(error: impl std::fmt::Display) -> io::Error { + io::Error::other(error.to_string()) + } + + /// Whether the running kernel enforces Landlock. A hard-requirement probe + /// is needed: the default best-effort mode "succeeds" on kernels without + /// Landlock by producing a ruleset that enforces nothing. + pub(super) fn kernel_supports_landlock() -> bool { + Ruleset::default() + .set_compatibility(CompatLevel::HardRequirement) + .handle_access(AccessFs::ReadFile) + .and_then(Ruleset::create) + .is_ok() + } + + fn add_path( + ruleset: RulesetCreated, + path: &Path, + access: landlock::BitFlags, + required: bool, + ) -> io::Result { + let fd = match PathFd::new(path) { + Ok(fd) => fd, + Err(error) if !required => { + log::debug!( + "[cwd_jail:landlock] skipping unavailable path {}: {error}", + path.display() + ); + return Ok(ruleset); + } + Err(error) => { + return Err(io::Error::new( + io::ErrorKind::NotFound, + format!("jail path {} cannot be opened: {error}", path.display()), + )); + } + }; + // Directory-only grants are meaningless on a file and otherwise mark + // the policy partially enforced. Inspect the opened descriptor so a + // path replacement cannot change which object's rights are filtered. + let metadata = File::from(fd.as_fd().try_clone_to_owned()?).metadata()?; + let access = if metadata.is_dir() { + access + } else { + access & AccessFs::from_file(ABI::V3) + }; + ruleset + .add_rule(PathBeneath::new(fd, access)) + .map_err(other) + } + + fn build_ruleset(jail: &Jail) -> io::Result { + let (writes, reads) = (writes(), reads()); + let mut ruleset = Ruleset::default() + .handle_access(writes | reads) + .and_then(Ruleset::create) + .map_err(other)?; + // Baseline first: it is the least privileged and skipped when absent. + for path in SYSTEM_READ_PATHS { + ruleset = add_path(ruleset, Path::new(path), reads, false)?; } - #[cfg(not(feature = "landlock"))] - { - false + for path in DEVICE_PATHS { + ruleset = add_path(ruleset, Path::new(path), writes | reads, false)?; } + for path in &jail.read_only { + ruleset = add_path(ruleset, path, reads, false)?; + } + // The root and read/write grants must exist: silently dropping them + // would hand the child a jail it cannot write to, or worse a wrong one. + ruleset = add_path(ruleset, &jail.root, writes | reads, true)?; + for path in &jail.read_write { + ruleset = add_path(ruleset, path, writes | reads, true)?; + } + Ok(ruleset) } - fn spawn(&self, jail: &Jail, mut cmd: Command) -> std::io::Result { - #[cfg(feature = "landlock")] - { - use landlock::{ - AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, - }; - use std::os::unix::process::CommandExt; - - let writes = AccessFs::WriteFile - | AccessFs::RemoveDir - | AccessFs::RemoveFile - | AccessFs::MakeChar - | AccessFs::MakeDir - | AccessFs::MakeReg - | AccessFs::MakeSock - | AccessFs::MakeFifo - | AccessFs::MakeBlock - | AccessFs::MakeSym - | AccessFs::Refer - | AccessFs::Truncate; - let reads = AccessFs::Execute | AccessFs::ReadFile | AccessFs::ReadDir; - let mut ruleset = Ruleset::default() - .handle_access(writes | reads) - .and_then(|ruleset| ruleset.create()) - .map_err(|error| std::io::Error::other(error.to_string()))?; - let root_fd = PathFd::new(&jail.root) - .map_err(|error| std::io::Error::other(error.to_string()))?; - ruleset = ruleset - .add_rule(PathBeneath::new(root_fd, writes | reads)) - .map_err(|error| std::io::Error::other(error.to_string()))?; - for path in &jail.read_write { - let fd = - PathFd::new(path).map_err(|error| std::io::Error::other(error.to_string()))?; - ruleset = ruleset - .add_rule(PathBeneath::new(fd, writes | reads)) - .map_err(|error| std::io::Error::other(error.to_string()))?; + /// Reject a ruleset unless every requested restriction is enforced. + pub(super) fn check_enforcement(status: &RulesetStatus, label: &str) -> io::Result<()> { + match status { + RulesetStatus::NotEnforced => { + log::warn!( + "[cwd_jail:landlock] ruleset not enforced; refusing to spawn \ + unconfined (label={label})" + ); + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Landlock ruleset was not enforced", + )); } - for path in &jail.read_only { - let fd = - PathFd::new(path).map_err(|error| std::io::Error::other(error.to_string()))?; - ruleset = ruleset - .add_rule(PathBeneath::new(fd, reads)) - .map_err(|error| std::io::Error::other(error.to_string()))?; + RulesetStatus::PartiallyEnforced => { + log::warn!( + "[cwd_jail:landlock] ruleset partially enforced; refusing to spawn \ + (label={label})" + ); + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Landlock ruleset was only partially enforced", + )); } - let mut ruleset = Some(ruleset); - - // SAFETY: the child callback only applies this prebuilt ruleset. - unsafe { - cmd.pre_exec(move || match ruleset.take() { - Some(ruleset) => match ruleset.restrict_self() { - Ok(_) => Ok(()), - Err(_) => Err(std::io::Error::from_raw_os_error(5)), - }, - None => Err(std::io::Error::from_raw_os_error(22)), - }); + RulesetStatus::FullyEnforced => { + log::trace!("[cwd_jail:landlock] ruleset fully enforced label={label}"); } - - cmd.spawn() } - #[cfg(not(feature = "landlock"))] - { - let _ = jail; - cmd.spawn() + Ok(()) + } + + pub(super) fn spawn(jail: &Jail, cmd: Command) -> io::Result { + spawn_with_support(jail, cmd, kernel_supports_landlock()) + } + + /// Keep the availability decision injectable without changing kernel state. + pub(super) fn spawn_with_support( + jail: &Jail, + cmd: Command, + supported: bool, + ) -> io::Result { + if !supported { + log::warn!( + "[cwd_jail:landlock] kernel does not support Landlock; refusing to spawn \ + unconfined (label={})", + jail.label + ); + return Err(io::Error::new( + io::ErrorKind::Unsupported, + "Landlock is not supported by this kernel", + )); } + let ruleset = build_ruleset(jail)?; + let label = jail.label.clone(); + let worker = std::thread::Builder::new() + .name("tinybox-jail-spawn".into()) + .spawn(move || -> io::Result { + let mut cmd = cmd; + let status = ruleset.restrict_self().map_err(other)?; + check_enforcement(&status.ruleset, &label)?; + cmd.spawn() + })?; + worker + .join() + .map_err(|_| io::Error::other("Landlock spawn thread panicked"))? } } -#[cfg(all(test, feature = "landlock"))] +#[cfg(not(feature = "landlock"))] +mod imp { + use std::fs::File; + use std::io; + use std::os::fd::AsFd; + use std::process::{Child, Command}; + + use crate::jail::Jail; + + pub(super) fn kernel_supports_landlock() -> bool { + false + } + + pub(super) fn spawn(jail: &Jail, _cmd: Command) -> io::Result { + log::warn!( + "[cwd_jail:landlock] built without the `landlock` feature; refusing to spawn \ + unconfined (label={})", + jail.label + ); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "tinybox-jail was built without the `landlock` feature", + )) + } +} + +#[cfg(test)] #[path = "linux_tests.rs"] mod tests; + +#[cfg(all(test, feature = "landlock"))] +#[path = "linux_imp_tests.rs"] +mod imp_tests; diff --git a/crates/tinybox-jail/src/linux_imp_tests.rs b/crates/tinybox-jail/src/linux_imp_tests.rs new file mode 100644 index 0000000..fb35aaf --- /dev/null +++ b/crates/tinybox-jail/src/linux_imp_tests.rs @@ -0,0 +1,44 @@ +//! Tests for unavailable kernels and enforcement status handling. + +use super::imp::{check_enforcement, spawn_with_support}; +use super::*; +use landlock::RulesetStatus; + +#[test] +fn unsupported_kernel_never_runs_the_command() -> io::Result<()> { + let root = tempfile::tempdir()?; + let marker = root.path().join("ran"); + let mut cmd = Command::new("/bin/touch"); + cmd.arg(&marker); + let result = spawn_with_support(&Jail::new(root.path(), "unsupported"), cmd, false); + assert_eq!( + result.err().map(|e| e.kind()), + Some(io::ErrorKind::Unsupported) + ); + assert!(!marker.exists()); + Ok(()) +} + +#[test] +fn only_fully_enforced_rulesets_are_accepted() -> io::Result<()> { + assert_eq!( + check_enforcement(&RulesetStatus::NotEnforced, "test") + .err() + .map(|e| e.kind()), + Some(io::ErrorKind::Unsupported) + ); + assert_eq!( + check_enforcement(&RulesetStatus::PartiallyEnforced, "test") + .err() + .map(|e| e.kind()), + Some(io::ErrorKind::Unsupported) + ); + check_enforcement(&RulesetStatus::FullyEnforced, "test")?; + assert_default_name::("landlock"); + Ok(()) +} + +/// Check the default-construction contract through the backend trait. +fn assert_default_name(name: &str) { + assert_eq!(B::default().name(), name); +} diff --git a/crates/tinybox-jail/src/linux_tests.rs b/crates/tinybox-jail/src/linux_tests.rs index 6b6c90b..8730130 100644 --- a/crates/tinybox-jail/src/linux_tests.rs +++ b/crates/tinybox-jail/src/linux_tests.rs @@ -1,59 +1,271 @@ -//! Tests for the Linux Landlock backend. +//! Tests for the Linux Landlock backend. They enforce for real, so each one +//! returns early (with a note) on a kernel without Landlock. use super::*; use std::path::Path; +use std::process::Stdio; + +fn available() -> bool { + let ok = LandlockBackend::new().is_available(); + if !ok { + eprintln!("skipped: Landlock is not supported by this kernel"); + } + ok +} + +/// Runs `script` under `sh -c` inside `jail`, returning whether it exited 0. +fn sh(jail: &Jail, script: &str) -> io::Result { + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c").arg(script); + Ok(LandlockBackend::new().spawn(jail, cmd)?.wait()?.success()) +} + +fn jail_for(root: &Path) -> Jail { + let mut jail = Jail::new(root, "landlock-test"); + jail.canonicalize().unwrap(); + jail +} + +#[test] +fn reports_name_and_probes_the_kernel() { + let backend = LandlockBackend::new(); + assert_eq!(backend.name(), "landlock"); + // The probe must agree with a hard-requirement ruleset creation. + assert_eq!(backend.is_available(), imp::kernel_supports_landlock()); +} #[test] -fn landlock_spawns_with_configured_system_read_paths() -> std::io::Result<()> { +fn shell_starts_with_only_the_baseline_system_paths() -> io::Result<()> { + if !available() { + return Ok(()); + } let root = tempfile::tempdir()?; - let mut jail = Jail::new(root.path(), "landlock-test"); - for path in ["/usr", "/bin", "/lib", "/lib64"] { - if Path::new(path).exists() { - jail = jail.add_read_only(path); - } + assert!(sh( + &jail_for(root.path()), + "ls /usr/bin >/dev/null && echo hi >/dev/null" + )?); + Ok(()) +} + +#[test] +fn writes_inside_the_root_succeed_and_outside_fail() -> io::Result<()> { + if !available() { + return Ok(()); } + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + let jail = jail_for(root.path()); - let backend = LandlockBackend::new(); - if !backend.is_available() { - let error = backend - .spawn(&jail, Command::new("/usr/bin/true")) - .err() - .map(|error| error.kind()); - assert_eq!(error, Some(std::io::ErrorKind::PermissionDenied)); + assert!(sh( + &jail, + &format!("echo ok > '{}'", root.path().join("in").display()) + )?); + assert_eq!(std::fs::read_to_string(root.path().join("in"))?, "ok\n"); + + let target = outside.path().join("out"); + assert!(!sh(&jail, &format!("echo no > '{}'", target.display()))?); + assert!(!target.exists(), "write outside the root must be denied"); + Ok(()) +} + +#[test] +fn reads_outside_granted_paths_are_denied() -> io::Result<()> { + if !available() { return Ok(()); } + let root = tempfile::tempdir()?; + let secret_dir = tempfile::tempdir()?; + let secret = secret_dir.path().join("secret"); + std::fs::write(&secret, "token")?; + let jail = jail_for(root.path()); + assert!(!sh( + &jail, + &format!("cat '{}' >/dev/null", secret.display()) + )?); + Ok(()) +} - let mut child = backend.spawn(&jail, Command::new("/usr/bin/true"))?; - assert!(child.wait()?.success()); +#[test] +fn read_only_paths_are_readable_but_not_writable() -> io::Result<()> { + if !available() { + return Ok(()); + } + let root = tempfile::tempdir()?; + let shared = tempfile::tempdir()?; + std::fs::write(shared.path().join("data"), "v")?; + let jail = jail_for(root.path()).add_read_only(shared.path()); + let mut jail = jail; + jail.canonicalize()?; + + assert!(sh( + &jail, + &format!("cat '{}' >/dev/null", shared.path().join("data").display()) + )?); + assert!(!sh( + &jail, + &format!("echo x > '{}'", shared.path().join("new").display()) + )?); + assert!(!shared.path().join("new").exists()); Ok(()) } #[test] -fn landlock_grants_writes_to_read_write_paths_outside_the_root() -> std::io::Result<()> { - let backend = LandlockBackend::new(); - if !backend.is_available() { +fn read_write_paths_outside_the_root_are_writable() -> io::Result<()> { + if !available() { return Ok(()); } let root = tempfile::tempdir()?; let scratch = tempfile::tempdir()?; let denied = tempfile::tempdir()?; let mut jail = Jail::new(root.path(), "landlock-rw").add_read_write(scratch.path()); - for path in ["/usr", "/bin", "/lib", "/lib64"] { - if Path::new(path).exists() { - jail = jail.add_read_only(path); - } - } - - let write = |dir: &Path| -> std::io::Result { - let mut cmd = Command::new("/bin/sh"); - cmd.arg("-c") - .arg(format!("echo ok > '{}'", dir.join("out").display())); - Ok(backend.spawn(&jail, cmd)?.wait()?.success()) - }; + jail.canonicalize()?; + let write = |dir: &Path| sh(&jail, &format!("echo ok > '{}'", dir.join("out").display())); assert!(write(scratch.path())?, "read_write path must be writable"); assert_eq!(std::fs::read_to_string(scratch.path().join("out"))?, "ok\n"); - assert!(!write(denied.path())?, "paths not granted must stay unwritable"); + assert!( + !write(denied.path())?, + "paths not granted must stay unwritable" + ); assert!(!denied.path().join("out").exists()); Ok(()) } + +#[test] +fn missing_read_write_path_fails_the_spawn_but_missing_read_only_is_skipped() -> io::Result<()> { + if !available() { + return Ok(()); + } + let root = tempfile::tempdir()?; + let missing = root.path().join("does-not-exist"); + + let jail = jail_for(root.path()).add_read_write(&missing); + let error = LandlockBackend::new() + .spawn(&jail, Command::new("/bin/true")) + .unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::NotFound); + + let jail = jail_for(root.path()).add_read_only(&missing); + assert!(sh(&jail, "true")?); + Ok(()) +} + +#[test] +fn confinement_does_not_leak_into_the_parent_process() -> io::Result<()> { + if !available() { + return Ok(()); + } + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + assert!(sh(&jail_for(root.path()), "true")?); + // The calling thread (and the process) must still be able to write anywhere. + std::fs::write(outside.path().join("parent"), "still free")?; + Ok(()) +} + +#[test] +fn null_stdio_and_environment_pass_through() -> io::Result<()> { + if !available() { + return Ok(()); + } + let root = tempfile::tempdir()?; + let jail = jail_for(root.path()); + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c") + .arg("echo $JAIL_TEST_VAR > out") + .env("JAIL_TEST_VAR", "from-host") + .current_dir(root.path()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + assert!(LandlockBackend::new().spawn(&jail, cmd)?.wait()?.success()); + assert_eq!( + std::fs::read_to_string(root.path().join("out"))?, + "from-host\n" + ); + Ok(()) +} + +#[test] +fn child_cannot_regain_privileges_through_no_new_privs() -> io::Result<()> { + if !available() { + return Ok(()); + } + let root = tempfile::tempdir()?; + // NoNewPrivs is reported in /proc/self/status, which is outside the + // baseline, so grant /proc read access just for this probe. + let jail = jail_for(root.path()).add_read_only("/proc"); + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c") + .arg("grep -q '^NoNewPrivs:[[:space:]]*1' /proc/self/status"); + assert!(LandlockBackend::new().spawn(&jail, cmd)?.wait()?.success()); + Ok(()) +} + +#[cfg(not(feature = "landlock"))] +#[test] +fn without_the_feature_spawn_is_unsupported_and_never_runs_unconfined() { + let root = tempfile::tempdir().unwrap(); + let marker = root.path().join("ran"); + let mut cmd = Command::new("/bin/sh"); + cmd.arg("-c").arg(format!("touch '{}'", marker.display())); + let backend = LandlockBackend::new(); + assert!(!backend.is_available()); + let error = backend.spawn(&jail_for(root.path()), cmd).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::Unsupported); + assert!(!marker.exists()); +} + +#[test] +fn baseline_lets_the_resolver_config_be_read_through_its_symlink() -> io::Result<()> { + if !available() { + return Ok(()); + } + let root = tempfile::tempdir()?; + // Resolves symlinks like a DNS lookup does: /etc/resolv.conf may point + // into /run/systemd/resolve. + assert!(sh( + &jail_for(root.path()), + "cat /etc/resolv.conf >/dev/null" + )?); + Ok(()) +} + +/// File grants must enforce fully without granting their parent directory. +#[test] +fn individual_file_grants_preserve_read_only_and_read_write_access() -> io::Result<()> { + if !available() { + return Ok(()); + } + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + let readable = outside.path().join("readable"); + let writable = outside.path().join("writable"); + let denied = outside.path().join("denied"); + std::fs::write(&readable, "read only")?; + std::fs::write(&writable, "writable")?; + std::fs::write(&denied, "unchanged")?; + let jail = jail_for(root.path()) + .add_read_only(&readable) + .add_read_write(&writable); + assert!(sh( + &jail, + &format!("cat '{}' >/dev/null", readable.display()) + )?); + assert!(!sh( + &jail, + &format!("truncate -s 0 '{}'", readable.display()) + )?); + assert!(sh( + &jail, + &format!("truncate -s 0 '{}'", writable.display()) + )?); + assert!(!sh( + &jail, + &format!("truncate -s 0 '{}'", denied.display()) + )?); + assert_eq!(std::fs::read_to_string(&readable)?, "read only"); + assert_eq!(std::fs::read_to_string(&writable)?, ""); + assert_eq!(std::fs::read_to_string(&denied)?, "unchanged"); + Ok(()) +} diff --git a/crates/tinybox-jail/src/macos.rs b/crates/tinybox-jail/src/macos.rs index 86c8d29..f226b9e 100644 --- a/crates/tinybox-jail/src/macos.rs +++ b/crates/tinybox-jail/src/macos.rs @@ -1,19 +1,24 @@ //! macOS backend: Seatbelt via `sandbox-exec`. //! //! `sandbox-exec` is a built-in macOS binary that takes a Scheme-style -//! profile (the "Seatbelt" / TrustedBSD policy language) and execs the +//! profile (the "Seatbelt" / `TrustedBSD` policy language) and execs the //! requested command under it. Chromium, iOS simulators and Apple's own //! tools use the same SPI under the hood. The CLI is technically //! deprecated but has stayed shipping for a decade and is the only //! supported way to apply Seatbelt without private framework bindings. -#![cfg(target_os = "macos")] - +use std::fmt::Write as _; use std::process::{Child, Command}; use super::jail::{Jail, JailBackend}; /// macOS Seatbelt backend that launches commands with `sandbox-exec`. +/// +/// Only environment variables explicitly set on the command are forwarded. +/// The wrapper clears inherited variables because `Command` does not expose +/// whether the caller used `env_clear`; inheriting could restore credentials +/// the caller deliberately removed. Configure required variables with `env`. +/// Stdio uses the launcher defaults (inherit). #[derive(Debug)] pub struct SeatbeltBackend; @@ -25,6 +30,7 @@ impl Default for SeatbeltBackend { impl SeatbeltBackend { /// Creates the Seatbelt backend. + #[must_use] pub fn new() -> Self { Self } @@ -40,41 +46,49 @@ impl JailBackend for SeatbeltBackend { } fn spawn(&self, jail: &Jail, cmd: Command) -> std::io::Result { - let profile = render_profile(jail); - - // sandbox-exec only accepts profiles from disk or from `-p`. Inline - // (`-p`) is simpler and avoids a tempfile lifecycle problem (the - // child may outlive our parent scope). - let program = cmd.get_program().to_os_string(); - let args: Vec<_> = cmd.get_args().map(|a| a.to_os_string()).collect(); - let envs: Vec<_> = cmd - .get_envs() - .map(|(k, v)| (k.to_os_string(), v.map(|s| s.to_os_string()))) - .collect(); - let cwd = cmd.get_current_dir().map(|p| p.to_path_buf()); - - let mut wrapper = Command::new("/usr/bin/sandbox-exec"); - wrapper.arg("-p").arg(profile).arg(program).args(args); - for (k, v) in envs { - match v { - Some(val) => { - wrapper.env(k, val); - } - None => { - wrapper.env_remove(k); - } + prepare_command(jail, &cmd, std::ffi::OsStr::new("/usr/bin/sandbox-exec")).spawn() + } +} + +/// Build the launcher separately so forwarding can be checked on any host. +fn prepare_command(jail: &Jail, cmd: &Command, launcher: &std::ffi::OsStr) -> Command { + let profile = render_profile(jail); + + // sandbox-exec only accepts profiles from disk or from `-p`. Inline + // (`-p`) is simpler and avoids a tempfile lifecycle problem (the + // child may outlive our parent scope). + let program = cmd.get_program().to_os_string(); + let args: Vec<_> = cmd.get_args().map(std::ffi::OsStr::to_os_string).collect(); + let envs: Vec<_> = cmd + .get_envs() + .map(|(k, v)| (k.to_os_string(), v.map(std::ffi::OsStr::to_os_string))) + .collect(); + let cwd = cmd.get_current_dir().map(std::path::Path::to_path_buf); + + let mut wrapper = Command::new(launcher); + // Fail closed for environment authority: never restore values that an + // opaque Command may have deliberately cleared. + wrapper.env_clear(); + wrapper.arg("-p").arg(profile).arg(program).args(args); + for (k, v) in envs { + match v { + Some(val) => { + wrapper.env(k, val); + } + None => { + wrapper.env_remove(k); } } - if let Some(d) = cwd { - wrapper.current_dir(d); - } - // Inherit stdio from the original command intent. `std::process` - // doesn't expose the original `Stdio`, so we leave the inherited - // defaults — callers can re-wire by spawning into a pre-set stdio - // via the returned `Child` is not possible; for now we match the - // sandbox-exec defaults (inherit). Document this in mod.rs. - wrapper.spawn() } + if let Some(d) = cwd { + wrapper.current_dir(d); + } + // Inherit stdio from the original command intent. `std::process` + // doesn't expose the original `Stdio`, so we leave the inherited + // defaults — callers can re-wire by spawning into a pre-set stdio + // via the returned `Child` is not possible; for now we match the + // sandbox-exec defaults (inherit). Document this in mod.rs. + wrapper } /// Render a Seatbelt profile. @@ -114,10 +128,7 @@ fn render_profile(jail: &Jail) -> String { out.push_str("(deny file-write*)\n"); out.push_str("(allow file-write*\n"); for path in std::iter::once(&jail.root).chain(&jail.read_write) { - out.push_str(&format!( - " (subpath \"{}\")\n", - escape(&path.to_string_lossy()) - )); + let _ = writeln!(out, " (subpath \"{}\")", escape(&path.to_string_lossy())); } out.push_str(" (subpath \"/private/tmp\")\n (literal \"/dev/null\")\n)\n"); diff --git a/crates/tinybox-jail/src/macos_tests.rs b/crates/tinybox-jail/src/macos_tests.rs index 3011f6c..3109a13 100644 --- a/crates/tinybox-jail/src/macos_tests.rs +++ b/crates/tinybox-jail/src/macos_tests.rs @@ -1,3 +1,5 @@ +//! Tests for Seatbelt profiles and launcher command forwarding. + use super::*; use std::fs; use std::process::Stdio; @@ -223,3 +225,96 @@ fn profile_without_read_write_paths_only_allows_root_and_tmp() { let p = render_profile(&Jail::new("/work/root", "x")); assert_eq!(p.matches("(subpath ").count(), 2); } + +#[test] +fn launcher_preserves_arguments_environment_overrides_and_working_directory() { + let jail = Jail::new("/work", "forwarding"); + let mut cmd = Command::new("/bin/tool"); + cmd.arg("a b") + .arg("$(literal)") + .env("SET", "value") + .env_remove("REMOVE") + .current_dir("/work"); + let wrapper = prepare_command(&jail, &cmd, std::ffi::OsStr::new("launcher")); + assert_eq!(wrapper.get_program(), "launcher"); + let args: Vec<_> = wrapper.get_args().collect(); + assert_eq!( + args, + vec![ + "-p", + &render_profile(&jail), + "/bin/tool", + "a b", + "$(literal)" + ] + ); + assert_eq!( + wrapper.get_current_dir(), + Some(std::path::Path::new("/work")) + ); + let env: Vec<_> = wrapper.get_envs().collect(); + assert!(env.contains(&( + std::ffi::OsStr::new("SET"), + Some(std::ffi::OsStr::new("value")) + ))); + assert!( + env.iter() + .all(|(key, _)| *key != std::ffi::OsStr::new("REMOVE")) + ); + let defaults = prepare_command( + &jail, + &Command::new("true"), + std::ffi::OsStr::new("launcher"), + ); + assert!(defaults.get_current_dir().is_none()); + assert_default_name::("seatbelt"); +} + +#[test] +fn missing_launcher_returns_an_error() { + let result = SeatbeltBackend::new().spawn( + &Jail::new("/work", "missing"), + Command::new("/nonexistent/tinybox-command"), + ); + if !SeatbeltBackend::new().is_available() { + assert_eq!( + result.err().map(|e| e.kind()), + Some(std::io::ErrorKind::NotFound) + ); + } +} + +/// Check the default-construction contract through the backend trait. +fn assert_default_name(name: &str) { + assert_eq!(B::default().name(), name); +} + +/// Exercise the real wrapper environment using a fake launcher on Unix hosts. +#[cfg(unix)] +#[test] +fn launcher_does_not_restore_inherited_environment_after_env_clear() -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir()?; + let launcher = root.path().join("launcher"); + fs::write(&launcher, "#!/bin/sh\nshift 2\nexec \"$@\"\n")?; + fs::set_permissions(&launcher, fs::Permissions::from_mode(0o700))?; + let mut cmd = Command::new("/usr/bin/env"); + cmd.env_clear().env("JAIL_EXPLICIT", "allowed"); + let output = prepare_command( + &Jail::new(root.path(), "environment"), + &cmd, + launcher.as_os_str(), + ) + .output()?; + assert!(output.status.success()); + // Some shells add PWD while executing a script. No inherited parent keys + // should survive, and the explicitly supplied value must still be there. + let env = String::from_utf8_lossy(&output.stdout); + assert!(env.lines().any(|line| line == "JAIL_EXPLICIT=allowed")); + assert!( + env.lines() + .all(|line| line.starts_with("JAIL_EXPLICIT=") || line.starts_with("PWD=")) + ); + Ok(()) +} diff --git a/crates/tinybox-jail/src/mod_tests.rs b/crates/tinybox-jail/src/mod_tests.rs index 41e89b1..105c110 100644 --- a/crates/tinybox-jail/src/mod_tests.rs +++ b/crates/tinybox-jail/src/mod_tests.rs @@ -51,13 +51,12 @@ fn default_backend_is_cached() { #[test] fn spawn_uses_default_backend() { let dir = std::env::temp_dir(); - let mut jail = Jail::new(&dir, "default-spawn"); - #[cfg(target_os = "linux")] - for path in ["/usr", "/bin", "/lib", "/lib64"] { - if std::path::Path::new(path).exists() { - jail = jail.add_read_only(path); - } - } + // Landlock denies everything it is not told about; give it the system + // directories so `true` can run. Other backends ignore the extra grants. + let jail = ["/usr", "/bin", "/lib", "/lib64"] + .into_iter() + .filter(|path| std::path::Path::new(path).exists()) + .fold(Jail::new(&dir, "default-spawn"), Jail::add_read_only); let cmd = if cfg!(windows) { let mut c = Command::new("cmd"); c.args(["/C", "exit"]);