diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 803236a..4e736dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,17 +40,37 @@ jobs: - name: Test Seatbelt enforcement run: make test-live - linux-facade: - name: Linux facade compile - runs-on: ubuntu-latest + linux-substrate: + name: Linux enforcement substrate (${{ matrix.runner }}) + strategy: + fail-fast: false + matrix: + runner: [ubuntu-24.04, ubuntu-24.04-arm] + runs-on: ${{ matrix.runner }} steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: persist-credentials: false - name: Install Rust toolchain - run: rustup toolchain install 1.91.1 --profile minimal - - name: Check facade and run external consumer + run: rustup toolchain install 1.91.1 --profile minimal --component clippy + - name: Lint Linux backend + run: cargo clippy -p sandy-linux --all-targets --all-features --locked -- -D warnings + - name: Verify restricted user namespaces fail closed + env: + SANDY_LINUX_EXPECT_UNSUPPORTED: "1" + run: | + if [ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "1" ]; then + cargo test -p sandy-linux --test live_linux --features live-tests --locked + fi + - name: Enable user namespaces for positive enforcement tests + run: | + if [ -f /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + - name: Test Linux backend + run: make test-live-linux + - name: Preserve unsupported facade contract run: | cargo check -p sandy-sandbox --all-targets --all-features --locked cargo run --manifest-path tests/package-consumer/Cargo.toml --locked diff --git a/AGENTS.md b/AGENTS.md index d94b490..3086f69 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,8 +6,8 @@ This file governs the entire repository. Sandy is a macOS-native process sandbox for AI coding agents. -- Cargo workspace: `sandy-core`, `sandy-seatbelt`, `sandy-sandbox`, and - `sandy-cli` +- Cargo workspace: `sandy-core`, `sandy-seatbelt`, `sandy-linux`, + `sandy-sandbox`, and `sandy-cli` - Installed executable: `sandy` - Default mode: standalone sandboxing - Optional integrations: preserve verified existing Kontext hooks or @@ -45,7 +45,7 @@ There is no implicit discovery, include, URL, fallback, or user-to-user inheritance. File I/O and ambient resolution remain in `sandy-cli`; lexical validation and deterministic additive composition remain in `sandy-core`. -Do not add Linux, detached sessions, a PTY proxy, domain filtering, credential +Do not add detached sessions, a PTY proxy, domain filtering, credential brokering, dynamic grants, rollback, resource limits, raw Seatbelt input, or organization-managed Kontext support or outside-sandbox synchronous hook decision services without an explicit scope decision. @@ -55,12 +55,13 @@ changes. ## Architecture -Use a virtual workspace with four packages representing validation, +Use a virtual workspace with five packages representing validation, native-code, embedding, and product boundaries. ```text crates/core/ package sandy-core; validated security contract crates/seatbelt/ package sandy-seatbelt; macOS compiler and FFI +crates/linux/ package sandy-linux; Linux preparation and enforcement crates/sandy/ package sandy-sandbox; supported Rust facade crates/cli/ package sandy-cli; sandy binary and product UX ``` @@ -75,10 +76,12 @@ Keep dependencies flowing in one direction: CLI -> sandy-core -> sandy-seatbelt -> sandy-core + -> sandy-linux -> sandy-core sandy facade -> sandy-core -> sandy-seatbelt -> sandy-core + -> sandy-linux -> sandy-core optional integrations -> typed capabilities @@ -86,7 +89,7 @@ optional integrations ``` `sandy-core` performs deterministic validation but no ambient filesystem -discovery. `sandy-seatbelt` receives only validated policy and does not see +discovery. Native backends receive only validated policy and do not see argv, environment, agent preset names, Clap, or service configuration. The CLI does not render policy. `SandboxPolicy` is the shared, side-effect-free intent; callers may construct it with the Rust builder or its strict, bounded, @@ -204,8 +207,25 @@ cleanup. The FFI boundary exposes only owned safe Rust types and functions. Raw pointers, unsafe functions, native error buffers, and raw sandbox flags must not escape it. -Adding a native symbol requires documenting its SDK declaration, availability, -deprecation status, cleanup contract, and live macOS coverage. +`sandy-linux` follows the same rule. Its only permitted unsafe and native-call +module is: + +```text +crates/linux/src/ffi.rs +``` + +The Linux backend separates deterministic `LinuxPolicyPlan`, ambient +`PreparedLinuxSandbox`, and irreversible `apply()` states. It requires a +single-threaded caller, private user, mount, and IPC namespaces, fixed Landlock +ABI 6 semantics with signal scoping, complete capability removal, and final +seccomp enforcement. It replaces the inherited session keyring with an +anonymous ring and denies key-management syscalls before untrusted work. It +never clears an inherited host `noexec` restriction. Unsupported policy +combinations fail before enforcement; there is no weaker fallback. + +Adding a native operation requires documenting its declaration, availability, +deprecation status, cleanup contract, and live coverage on every supported +host and architecture. ## Data and CLI contracts diff --git a/Cargo.lock b/Cargo.lock index b54b1ee..949a2bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,7 +145,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -195,6 +195,26 @@ dependencies = [ "crypto-common", ] +[[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.119", +] + [[package]] name = "errno" version = "0.3.14" @@ -259,6 +279,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "landlock" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cca98e95f35b29d469dade6724c6f96cec9236640f745a0e99b0334ec320ab1" +dependencies = [ + "enumflags2", + "libc", + "thiserror", +] + [[package]] name = "libc" version = "0.2.189" @@ -425,6 +456,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "sandy-linux" +version = "0.1.9" +dependencies = [ + "landlock", + "libc", + "sandy-core", + "seccompiler", + "tempfile", +] + [[package]] name = "sandy-sandbox" version = "0.1.9" @@ -443,6 +485,15 @@ dependencies = [ "thiserror", ] +[[package]] +name = "seccompiler" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae55de56877481d112a559bbc12667635fdaf5e005712fd4e2b2fa50ffc884" +dependencies = [ + "libc", +] + [[package]] name = "serde" version = "1.0.229" @@ -470,7 +521,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -503,6 +554,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.3" @@ -550,7 +612,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7a51cb7..23ff87f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,11 @@ [workspace] -members = ["crates/core", "crates/seatbelt", "crates/cli", "crates/sandy"] +members = [ + "crates/core", + "crates/seatbelt", + "crates/linux", + "crates/cli", + "crates/sandy", +] default-members = ["crates/cli"] resolver = "3" @@ -16,15 +22,18 @@ authors = ["Kontext Security"] assert_cmd = "2.0.17" clap = { version = "4.5.51", features = ["derive"] } libc = "0.2.177" +landlock = "0.4.7" predicates = "3.1.3" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" sha2 = "0.10.9" tempfile = "3.23.0" thiserror = "2.0.17" +seccompiler = "0.5.0" sandy-core = { path = "crates/core", version = "=0.1.9" } # x-release-please-version sandy-seatbelt = { path = "crates/seatbelt", version = "=0.1.9" } # x-release-please-version +sandy-linux = { path = "crates/linux", version = "=0.1.9" } # x-release-please-version [workspace.lints.rust] rust_2018_idioms = { level = "deny", priority = -1 } diff --git a/Makefile b/Makefile index 775082d..9c5ba7d 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := help -.PHONY: help build test test-live fmt fmt-check clippy deny package-check formula-check release-version-check check run +.PHONY: help build test test-live test-live-linux fmt fmt-check clippy deny package-check formula-check release-version-check check run help: ## Show available targets. @awk 'BEGIN {FS = ":.*##"; printf "Usage:\n make \n\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) @@ -15,6 +15,9 @@ test-live: ## Run sacrificial macOS Seatbelt enforcement tests. cargo test -p sandy-cli --test live_macos --locked -- --ignored --test-threads=1 cargo test -p sandy-sandbox --test live_macos --features live-tests --locked +test-live-linux: ## Run sacrificial Linux enforcement tests. + cargo test -p sandy-linux --test live_linux --features live-tests --locked -- --test-threads=1 + fmt: ## Format Rust sources. cargo fmt --all diff --git a/crates/linux/Cargo.toml b/crates/linux/Cargo.toml new file mode 100644 index 0000000..27d1d01 --- /dev/null +++ b/crates/linux/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "sandy-linux" +description = "Linux enforcement backend for Sandy" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +publish = false + +[features] +live-tests = [] + +[dependencies] +sandy-core.workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +landlock.workspace = true +libc.workspace = true +seccompiler.workspace = true + +[dev-dependencies] +tempfile.workspace = true + +[[test]] +name = "live_linux" +path = "tests/live_linux.rs" +harness = false +required-features = ["live-tests"] + +[lints] +workspace = true diff --git a/crates/linux/README.md b/crates/linux/README.md new file mode 100644 index 0000000..36817c1 --- /dev/null +++ b/crates/linux/README.md @@ -0,0 +1,8 @@ +# sandy-linux + +Private Linux enforcement backend for Sandy. It lowers a validated, +platform-neutral policy into a fixed native plan, pins every ambient resource, +and applies the irreversible boundary to the calling process. + +This package is an implementation detail. Use the supported `sandy-sandbox` +facade instead of depending on it directly. diff --git a/crates/linux/src/apply.rs b/crates/linux/src/apply.rs new file mode 100644 index 0000000..9f219e5 --- /dev/null +++ b/crates/linux/src/apply.rs @@ -0,0 +1,35 @@ +use crate::{ + LinuxError, LinuxErrorKind, PreparedLinuxSandbox, capabilities, landlock, mount, namespace, + seccomp, +}; + +/// Irreversibly applies a fully prepared Linux sandbox to the current process. +/// +/// Any error from this function may leave the process partially restricted. +/// The caller must terminate immediately and must not run untrusted code. +pub fn apply(mut prepared: PreparedLinuxSandbox) -> Result<(), LinuxError> { + namespace::enter(&prepared.namespace, prepared.block_network)?; + // Descriptors opened before CLONE_NEWNS still refer to mounts in the old + // namespace and cannot be cloned with open_tree. Reopen every canonical + // source in the new namespace and prove it is the same object before the + // old filesystem view is hidden. + crate::prepare::repin_after_namespace(&mut prepared.mount)?; + mount::construct_and_enter(&prepared.mount)?; + landlock::apply(prepared.landlock)?; + capabilities::drop_all(prepared.namespace.last_capability)?; + seccomp::apply(&prepared.seccomp)?; + verify_postconditions() +} + +fn verify_postconditions() -> Result<(), LinuxError> { + // After pivot_root the former host root and procfs must be absent. This is + // an invariant check, not a source of policy authority. + if std::path::Path::new("/.old_root").exists() { + Err(LinuxError::new( + LinuxErrorKind::EnforcementFailed, + "postcondition verification", + )) + } else { + Ok(()) + } +} diff --git a/crates/linux/src/capabilities.rs b/crates/linux/src/capabilities.rs new file mode 100644 index 0000000..dfc44b8 --- /dev/null +++ b/crates/linux/src/capabilities.rs @@ -0,0 +1,7 @@ +use crate::{LinuxError, LinuxErrorKind, ffi}; + +pub(crate) fn drop_all(last_capability: u32) -> Result<(), LinuxError> { + ffi::drop_all_capabilities(last_capability) + .and_then(|()| ffi::verify_no_capabilities()) + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "capability removal")) +} diff --git a/crates/linux/src/error.rs b/crates/linux/src/error.rs new file mode 100644 index 0000000..26dd6f4 --- /dev/null +++ b/crates/linux/src/error.rs @@ -0,0 +1,68 @@ +use std::{error::Error, fmt}; + +/// Stable high-level classification for a Linux backend failure. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum LinuxErrorKind { + /// The host or requested policy cannot provide the required semantics. + Unsupported, + /// Ambient resources could not be pinned before enforcement. + PreparationFailed, + /// An irreversible native enforcement step failed. + EnforcementFailed, +} + +/// Redacted Linux backend failure. +/// +/// Paths and native error strings are intentionally not retained because this +/// error crosses the supported facade and CLI boundaries. +pub struct LinuxError { + kind: LinuxErrorKind, + phase: &'static str, +} + +impl LinuxError { + pub(crate) const fn new(kind: LinuxErrorKind, phase: &'static str) -> Self { + Self { kind, phase } + } + + /// Returns the stable failure class. + #[must_use] + pub const fn kind(&self) -> LinuxErrorKind { + self.kind + } +} + +impl fmt::Debug for LinuxError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("LinuxError") + .field("kind", &self.kind) + .field("phase", &self.phase) + .finish() + } +} + +impl fmt::Display for LinuxError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "Linux sandbox {} failed", self.phase) + } +} + +impl Error for LinuxError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_error_format_is_redacted() { + let error = LinuxError::new(LinuxErrorKind::PreparationFailed, "path pinning"); + let display = error.to_string(); + let debug = format!("{error:?}"); + for rendered in [display, debug] { + assert!(!rendered.contains("/home/")); + assert!(!rendered.contains("native error")); + } + assert!(error.source().is_none()); + } +} diff --git a/crates/linux/src/ffi.rs b/crates/linux/src/ffi.rs new file mode 100644 index 0000000..37b4165 --- /dev/null +++ b/crates/linux/src/ffi.rs @@ -0,0 +1,776 @@ +//! Sole Linux unsafe/native boundary. + +use std::{ + ffi::{CStr, CString}, + fs::File, + io, + mem::size_of, + os::fd::{AsRawFd, BorrowedFd, FromRawFd, OwnedFd}, +}; + +const EMPTY: &CStr = c""; +const BASE_NAMESPACE_FLAGS: libc::c_int = + libc::CLONE_NEWUSER | libc::CLONE_NEWNS | libc::CLONE_NEWIPC; + +#[repr(C)] +struct CapHeader { + version: u32, + pid: i32, +} + +#[repr(C)] +struct OpenHow { + flags: u64, + mode: u64, + resolve: u64, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct CapData { + effective: u32, + permitted: u32, + inheritable: u32, +} + +pub(crate) struct MountRestrictions { + pub(crate) recursive: bool, + pub(crate) read_only: bool, + pub(crate) allow_device: bool, + pub(crate) allow_execute: bool, +} + +pub(crate) fn effective_ids() -> (u32, u32) { + // SAFETY: `geteuid` and `getegid` take no pointers, own no resources, and + // return process credentials directly. + unsafe { (libc::geteuid(), libc::getegid()) } +} + +pub(crate) fn unshare_namespaces(block_network: bool) -> io::Result<()> { + let mut flags = BASE_NAMESPACE_FLAGS; + if block_network { + flags |= libc::CLONE_NEWNET; + } + // SAFETY: `unshare` receives only a validated fixed flag set. It borrows no + // pointers and transfers no ownership. Callers enforce the single-threaded + // process precondition before invoking this irreversible operation. + cvt(unsafe { libc::unshare(flags) }).map(|_| ()) +} + +/// Exercises the irreversible namespace setup in a sacrificial child. +/// +/// Every allocation is completed before `fork`, and the child executes only +/// async-signal-safe libc/syscall operations before `_exit`. +pub(crate) fn probe_namespace_setup( + effective_uid: u32, + effective_gid: u32, + block_network: bool, +) -> io::Result<()> { + let uid_map = format!("{effective_uid} {effective_uid} 1\n").into_bytes(); + let gid_map = format!("{effective_gid} {effective_gid} 1\n").into_bytes(); + + // SAFETY: callers have verified that the process is single-threaded. The + // child uses only the fixed native operations in `namespace_probe_child` + // and terminates through `_exit`; the parent waits synchronously. + let child = unsafe { libc::fork() }; + if child < 0 { + return Err(io::Error::last_os_error()); + } + if child == 0 { + // SAFETY: this is the sacrificial post-fork child described above. + unsafe { namespace_probe_child(&uid_map, &gid_map, block_network) } + } + + let mut status = 0; + loop { + // SAFETY: `child` is the positive PID returned by fork, `status` is a + // valid writable integer, and no ownership is transferred. + let result = unsafe { libc::waitpid(child, &mut status, 0) }; + if result == child { + break; + } + if result < 0 && io::Error::last_os_error().kind() == io::ErrorKind::Interrupted { + continue; + } + return Err(io::Error::last_os_error()); + } + if libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(libc::EPERM)) + } +} + +unsafe fn namespace_probe_child(uid_map: &[u8], gid_map: &[u8], block_network: bool) -> ! { + let mut flags = BASE_NAMESPACE_FLAGS; + if block_network { + flags |= libc::CLONE_NEWNET; + } + // SAFETY: this child is single-threaded and disposable; all pointers below + // refer to fixed C strings or buffers allocated before fork. + let succeeded = unsafe { libc::unshare(flags) } == 0 + && unsafe { write_probe_file(c"/proc/self/uid_map", uid_map) } + && unsafe { write_probe_file(c"/proc/self/setgroups", b"deny") } + && unsafe { write_probe_file(c"/proc/self/gid_map", gid_map) } + && probe_isolate_session_keyring() + && unsafe { + libc::mount( + std::ptr::null(), + c"/".as_ptr(), + std::ptr::null(), + (libc::MS_REC | libc::MS_PRIVATE) as libc::c_ulong, + std::ptr::null(), + ) + } == 0 + && probe_mount_sequence(); + // SAFETY: `_exit` performs no Rust cleanup and cannot return. + unsafe { libc::_exit(if succeeded { 0 } else { 1 }) } +} + +fn probe_isolate_session_keyring() -> bool { + // SAFETY: the fixed keyctl operation accepts a null name to create an + // anonymous session keyring, retains no pointer, and affects only this + // sacrificial child. + unsafe { + libc::syscall( + libc::SYS_keyctl, + libc::KEYCTL_JOIN_SESSION_KEYRING, + std::ptr::null::(), + ) >= 0 + } +} + +fn probe_mount_sequence() -> bool { + // Exercise the same modern mount transaction as the real backend with a + // source descriptor opened after CLONE_NEWNS. All paths and structures + // are fixed before use, and the sacrificial child exits immediately. + if probe_mount_tmpfs(c"/tmp") != 0 + || probe_mkdir(c"/tmp/.sandy-probe-root") != 0 + || probe_mount_tmpfs(c"/tmp/.sandy-probe-root") != 0 + || probe_mkdir(c"/tmp/.sandy-probe-root/usr") != 0 + || probe_mkdir(c"/tmp/.sandy-probe-root/.old_root") != 0 + { + return false; + } + + let source = probe_open(c"/usr"); + if source < 0 { + return false; + } + let flags = libc::OPEN_TREE_CLONE + | libc::OPEN_TREE_CLOEXEC + | libc::AT_EMPTY_PATH as u32 + | libc::AT_RECURSIVE as u32; + let mount_fd = probe_open_tree(source, flags); + if mount_fd < 0 { + return false; + } + let attributes = libc::mount_attr { + attr_set: libc::MOUNT_ATTR_NOSUID + | libc::MOUNT_ATTR_NODEV + | libc::MOUNT_ATTR_NOEXEC + | libc::MOUNT_ATTR_RDONLY, + attr_clr: 0, + propagation: 0, + userns_fd: 0, + }; + if probe_mount_setattr(mount_fd, &attributes) < 0 { + return false; + } + let target = probe_open(c"/tmp/.sandy-probe-root/usr"); + if target < 0 { + return false; + } + if probe_move_mount(mount_fd, target) < 0 + || probe_chdir(c"/tmp/.sandy-probe-root") != 0 + || probe_pivot_root() < 0 + || probe_chdir(c"/") != 0 + || probe_detach_old_root() != 0 + { + return false; + } + true +} + +fn probe_mount_tmpfs(target: &CStr) -> libc::c_int { + // SAFETY: every pointer is a live C string for the call, mount copies the + // values, and the operation is confined to the sacrificial mount namespace. + unsafe { + libc::mount( + c"tmpfs".as_ptr(), + target.as_ptr(), + c"tmpfs".as_ptr(), + (libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC) as libc::c_ulong, + c"mode=0700,size=1m".as_ptr().cast(), + ) + } +} + +fn probe_mkdir(path: &CStr) -> libc::c_int { + // SAFETY: `path` is a live C string and mkdir retains no pointer. + unsafe { libc::mkdir(path.as_ptr(), 0o700) } +} + +fn probe_open(path: &CStr) -> libc::c_int { + // SAFETY: `path` is a live C string. The sacrificial child exclusively + // owns any descriptor returned by the kernel until it exits. + unsafe { libc::open(path.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) } +} + +fn probe_open_tree(source: libc::c_int, flags: u32) -> libc::c_long { + // SAFETY: `source` is checked by the kernel and the empty path is a static + // C string selected by AT_EMPTY_PATH. No pointer is retained. + unsafe { libc::syscall(libc::SYS_open_tree, source, EMPTY.as_ptr(), flags) } +} + +fn probe_mount_setattr(mount_fd: libc::c_long, attributes: &libc::mount_attr) -> libc::c_long { + // SAFETY: `mount_fd` is checked by the kernel and `attributes` remains live + // for the fixed-size call. The kernel copies it and retains no pointer. + unsafe { + libc::syscall( + libc::SYS_mount_setattr, + mount_fd, + EMPTY.as_ptr(), + libc::AT_EMPTY_PATH | libc::AT_RECURSIVE, + attributes, + size_of::(), + ) + } +} + +fn probe_move_mount(mount_fd: libc::c_long, target: libc::c_int) -> libc::c_long { + // SAFETY: both descriptors are checked by the kernel; the static empty + // paths select them and no pointer or Rust ownership is retained. + unsafe { + libc::syscall( + libc::SYS_move_mount, + mount_fd, + EMPTY.as_ptr(), + target, + EMPTY.as_ptr(), + libc::MOVE_MOUNT_F_EMPTY_PATH | libc::MOVE_MOUNT_T_EMPTY_PATH, + ) + } +} + +fn probe_chdir(path: &CStr) -> libc::c_int { + // SAFETY: `path` is a live C string and chdir retains no pointer. + unsafe { libc::chdir(path.as_ptr()) } +} + +fn probe_pivot_root() -> libc::c_long { + // SAFETY: both static paths refer to directories created above, remain + // live for the syscall, and are used only in the sacrificial namespace. + unsafe { libc::syscall(libc::SYS_pivot_root, c".".as_ptr(), c".old_root".as_ptr()) } +} + +fn probe_detach_old_root() -> libc::c_int { + // SAFETY: the static path remains live and umount2 retains no pointer. + unsafe { libc::umount2(c"/.old_root".as_ptr(), libc::MNT_DETACH) } +} + +unsafe fn write_probe_file(path: &CStr, contents: &[u8]) -> bool { + // SAFETY: `path` is NUL-terminated and remains valid for the call. + let fd = unsafe { libc::open(path.as_ptr(), libc::O_WRONLY | libc::O_CLOEXEC) }; + if fd < 0 { + return path == c"/proc/self/setgroups" + && io::Error::last_os_error().kind() == io::ErrorKind::NotFound; + } + let mut offset = 0; + while offset < contents.len() { + // SAFETY: the byte slice remains valid, and its remaining length is + // passed exactly. The descriptor is owned by this child. + let written = unsafe { + libc::write( + fd, + contents[offset..].as_ptr().cast(), + contents.len() - offset, + ) + }; + if written <= 0 { + // SAFETY: `fd` is a live descriptor opened above. + unsafe { libc::close(fd) }; + return false; + } + offset += written as usize; + } + // SAFETY: `fd` is a live descriptor opened above and is closed once. + (unsafe { libc::close(fd) }) == 0 +} + +pub(crate) fn make_mounts_private() -> io::Result<()> { + // SAFETY: all pointers are either null as required by mount(2), or the + // process-lifetime static C string `/`. No pointer is retained. The flags + // change propagation only in the already-private mount namespace. + cvt(unsafe { + libc::mount( + std::ptr::null(), + c"/".as_ptr(), + std::ptr::null(), + (libc::MS_REC | libc::MS_PRIVATE) as libc::c_ulong, + std::ptr::null(), + ) + }) + .map(|_| ()) +} + +pub(crate) fn mount_tmpfs(target: &CStr) -> io::Result<()> { + // SAFETY: `target` and the static source/type/data strings are valid, + // NUL-terminated for the duration of the call. mount(2) copies them and + // retains no Rust pointer. The mount is confined to the private namespace. + cvt(unsafe { + libc::mount( + c"tmpfs".as_ptr(), + target.as_ptr(), + c"tmpfs".as_ptr(), + (libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC) as libc::c_ulong, + c"mode=0700,size=64m".as_ptr().cast(), + ) + }) + .map(|_| ()) +} + +pub(crate) fn open_path_at(root: BorrowedFd<'_>, relative: &CStr) -> io::Result { + let how = OpenHow { + flags: (libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW) as u64, + mode: 0, + resolve: libc::RESOLVE_IN_ROOT | libc::RESOLVE_NO_MAGICLINKS | libc::RESOLVE_NO_SYMLINKS, + }; + // SAFETY: `root` is a borrowed open directory descriptor, `relative` and + // `how` remain valid for the syscall, and the exact structure size is + // supplied. On success the returned descriptor is uniquely owned here. + let fd = unsafe { + libc::syscall( + libc::SYS_openat2, + root.as_raw_fd(), + relative.as_ptr(), + &how, + size_of::(), + ) + }; + if fd < 0 { + Err(io::Error::last_os_error()) + } else { + let raw = i32::try_from(fd).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; + // SAFETY: the successful openat2 call returned a fresh descriptor and + // ownership is transferred exactly once into `OwnedFd`. + Ok(unsafe { OwnedFd::from_raw_fd(raw) }) + } +} + +pub(crate) fn clone_mount(source: BorrowedFd<'_>, recursive: bool) -> io::Result { + let mut flags = libc::OPEN_TREE_CLONE | libc::OPEN_TREE_CLOEXEC | libc::AT_EMPTY_PATH as u32; + if recursive { + flags |= libc::AT_RECURSIVE as u32; + } + // SAFETY: `source` is a borrowed O_PATH descriptor. With AT_EMPTY_PATH the + // empty pathname selects that descriptor; no pointer is retained. A + // successful open_tree returns a new mount descriptor owned by this call. + let fd = unsafe { + libc::syscall( + libc::SYS_open_tree, + source.as_raw_fd(), + EMPTY.as_ptr(), + flags, + ) + }; + if fd < 0 { + return Err(io::Error::last_os_error()); + } + let raw = i32::try_from(fd).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; + // SAFETY: the successful syscall returned a fresh descriptor and ownership + // is transferred exactly once. + Ok(unsafe { OwnedFd::from_raw_fd(raw) }) +} + +pub(crate) fn restrict_mount( + mount_fd: BorrowedFd<'_>, + restrictions: MountRestrictions, +) -> io::Result<()> { + let attributes = libc::mount_attr { + attr_set: libc::MOUNT_ATTR_NOSUID + | if restrictions.allow_device { + 0 + } else { + libc::MOUNT_ATTR_NODEV + } + | if restrictions.read_only { + libc::MOUNT_ATTR_RDONLY + } else { + 0 + } + | if restrictions.allow_execute { + 0 + } else { + libc::MOUNT_ATTR_NOEXEC + }, + // A caller grant must never clear a host or administrator restriction. + attr_clr: 0, + propagation: 0, + userns_fd: 0, + }; + let attribute_flags = libc::AT_EMPTY_PATH + | if restrictions.recursive { + libc::AT_RECURSIVE + } else { + 0 + }; + // SAFETY: the mount descriptor, empty path, and mount_attr pointer remain + // valid for the syscall; the kernel copies the fixed-size structure and + // retains no pointer. The detached mount is not externally visible yet. + let result = unsafe { + libc::syscall( + libc::SYS_mount_setattr, + mount_fd.as_raw_fd(), + EMPTY.as_ptr(), + attribute_flags, + &attributes, + size_of::(), + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +pub(crate) fn mount_is_noexec(fd: BorrowedFd<'_>) -> io::Result { + let mut status = std::mem::MaybeUninit::::uninit(); + // SAFETY: `fd` is a borrowed live descriptor. `status` points to writable, + // correctly aligned storage for exactly one `statvfs` value. The kernel + // initializes it on success and retains neither pointer nor descriptor. + cvt(unsafe { libc::fstatvfs(fd.as_raw_fd(), status.as_mut_ptr()) })?; + // SAFETY: the successful call above initialized the complete structure. + let status = unsafe { status.assume_init() }; + Ok(status.f_flag & libc::ST_NOEXEC != 0) +} + +pub(crate) fn verify_clone3_blocked() -> io::Result<()> { + // SAFETY: the installed filter must reject clone3 before inspecting the + // deliberately null argument pointer. No child or ownership transfer can + // occur on the required ENOSYS path. + let result = + unsafe { libc::syscall(libc::SYS_clone3, std::ptr::null::(), 0_usize) }; + expect_errno(result, libc::ENOSYS) +} + +pub(crate) fn verify_namespace_change_blocked() -> io::Result<()> { + // SAFETY: unshare receives only the fixed mount-namespace flag and borrows + // no pointers. The installed filter must reject it before any state change. + let result = unsafe { libc::unshare(libc::CLONE_NEWNS) }; + expect_errno(result.into(), libc::EPERM) +} + +pub(crate) fn verify_keyring_operations_blocked() -> io::Result<()> { + // SAFETY: this keyctl form takes only integer arguments and the installed + // filter must reject it before any keyring lookup can occur. + let result = unsafe { + libc::syscall( + libc::SYS_keyctl, + libc::KEYCTL_GET_KEYRING_ID, + libc::KEY_SPEC_SESSION_KEYRING, + 0, + ) + }; + expect_errno(result, libc::EPERM) +} + +pub(crate) fn verify_fork_blocked() -> io::Result<()> { + // SAFETY: the installed filter must reject fork before a child is created. + // If a broken filter unexpectedly creates one, the child immediately exits + // without running Rust cleanup and the parent reaps it before returning an + // error. No borrowed memory or descriptor ownership crosses this boundary. + let result = unsafe { libc::fork() }; + match result { + -1 => { + let error = io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::EPERM) { + Ok(()) + } else { + Err(error) + } + } + 0 => { + // SAFETY: this is the unexpected child path described above. + unsafe { libc::_exit(127) } + } + child => { + let mut status = 0; + // SAFETY: `child` was returned by fork in this process and is + // reaped exactly once; the status pointer remains live for the call. + unsafe { libc::waitpid(child, &mut status, 0) }; + Err(io::Error::from_raw_os_error(libc::EPERM)) + } + } +} + +fn expect_errno(result: libc::c_long, expected: libc::c_int) -> io::Result<()> { + let error = io::Error::last_os_error(); + if result == -1 && error.raw_os_error() == Some(expected) { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(libc::EPERM)) + } +} + +pub(crate) fn attach_mount(mount_fd: BorrowedFd<'_>, target_fd: BorrowedFd<'_>) -> io::Result<()> { + let flags = libc::MOVE_MOUNT_F_EMPTY_PATH | libc::MOVE_MOUNT_T_EMPTY_PATH; + // SAFETY: both descriptors are borrowed and valid for the call. Empty + // pathnames select the descriptors under the matching flags. move_mount + // consumes the detached mount in the kernel but not the descriptor itself. + let result = unsafe { + libc::syscall( + libc::SYS_move_mount, + mount_fd.as_raw_fd(), + EMPTY.as_ptr(), + target_fd.as_raw_fd(), + EMPTY.as_ptr(), + flags, + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +pub(crate) fn isolate_session_keyring() -> io::Result<()> { + // SAFETY: a null name asks the kernel for a fresh anonymous session + // keyring. The call retains no pointer and returns no userspace-owned + // resource; it only replaces the calling process's inherited association. + let result = unsafe { + libc::syscall( + libc::SYS_keyctl, + libc::KEYCTL_JOIN_SESSION_KEYRING, + std::ptr::null::(), + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(feature = "live-tests")] +pub(crate) fn create_private_message_queue() -> io::Result { + // SAFETY: msgget receives no pointers. IPC_PRIVATE creates a new queue in + // the caller's current IPC namespace with owner-only permissions. + let id = unsafe { libc::msgget(libc::IPC_PRIVATE, libc::IPC_CREAT | 0o600) }; + if id < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(id) + } +} + +#[cfg(feature = "live-tests")] +pub(crate) fn message_queue_is_visible(id: libc::c_int) -> io::Result { + let mut state = std::mem::MaybeUninit::::uninit(); + // SAFETY: `state` is correctly aligned writable storage for one msqid_ds; + // IPC_STAT initializes it on success and retains no pointer. + let result = unsafe { libc::msgctl(id, libc::IPC_STAT, state.as_mut_ptr()) }; + if result == 0 { + Ok(true) + } else { + let error = io::Error::last_os_error(); + if matches!( + error.raw_os_error(), + Some(libc::EINVAL) | Some(libc::EACCES) + ) { + Ok(false) + } else { + Err(error) + } + } +} + +#[cfg(feature = "live-tests")] +pub(crate) fn remove_message_queue(id: libc::c_int) -> io::Result<()> { + // SAFETY: IPC_RMID consumes no userspace pointer and removes only the + // explicitly identified test queue from the caller's IPC namespace. + cvt(unsafe { libc::msgctl(id, libc::IPC_RMID, std::ptr::null_mut()) }).map(|_| ()) +} + +#[cfg(feature = "live-tests")] +pub(crate) fn create_session_test_key(payload: &[u8]) -> io::Result { + isolate_session_keyring()?; + // SAFETY: the type and description are static C strings, `payload` remains + // live for its exact length, and add_key copies all supplied bytes before + // returning. The key is linked only into the anonymous test session ring. + let result = unsafe { + libc::syscall( + libc::SYS_add_key, + c"user".as_ptr(), + c"sandy-live-key".as_ptr(), + payload.as_ptr(), + payload.len(), + libc::KEY_SPEC_SESSION_KEYRING, + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + i32::try_from(result).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW)) + } +} + +#[cfg(feature = "live-tests")] +pub(crate) fn read_test_key(id: libc::c_int) -> io::Result> { + let mut payload = vec![0_u8; 64]; + // SAFETY: `payload` is writable for the exact supplied length. KEYCTL_READ + // copies at most that many bytes and retains no pointer. + let result = unsafe { + libc::syscall( + libc::SYS_keyctl, + libc::KEYCTL_READ, + id, + payload.as_mut_ptr(), + payload.len(), + ) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + let length = + usize::try_from(result).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; + if length > payload.len() { + return Err(io::Error::from_raw_os_error(libc::EOVERFLOW)); + } + payload.truncate(length); + Ok(payload) +} + +pub(crate) fn pivot_root(new_root: &CStr, old_root: &CStr) -> io::Result<()> { + // SAFETY: both paths are valid NUL-terminated strings for the syscall and + // are copied immediately. The caller has made `new_root` a mount point and + // created `old_root` beneath it. + cvt_long(unsafe { libc::syscall(libc::SYS_pivot_root, new_root.as_ptr(), old_root.as_ptr()) }) + .map(|_| ()) +} + +pub(crate) fn detach_mount(path: &CStr) -> io::Result<()> { + // SAFETY: `path` is a valid NUL-terminated string for the duration of the + // call and umount2 retains no pointer. + cvt(unsafe { libc::umount2(path.as_ptr(), libc::MNT_DETACH) }).map(|_| ()) +} + +pub(crate) fn drop_all_capabilities(last_capability: u32) -> io::Result<()> { + const SECURE_NO_SETUID_FIXUP: libc::c_ulong = 0x04; + const SECURE_NO_SETUID_FIXUP_LOCKED: libc::c_ulong = 0x08; + const SECURE_NOROOT: libc::c_ulong = 0x01; + const SECURE_NOROOT_LOCKED: libc::c_ulong = 0x02; + const SECURE_NO_CAP_AMBIENT_RAISE: libc::c_ulong = 0x40; + const SECURE_NO_CAP_AMBIENT_RAISE_LOCKED: libc::c_ulong = 0x80; + const SECURE_BITS: libc::c_ulong = SECURE_NOROOT + | SECURE_NOROOT_LOCKED + | SECURE_NO_SETUID_FIXUP + | SECURE_NO_SETUID_FIXUP_LOCKED + | SECURE_NO_CAP_AMBIENT_RAISE + | SECURE_NO_CAP_AMBIENT_RAISE_LOCKED; + const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; + + // SAFETY: prctl receives only integer operations and values, borrows no + // pointers, and transfers no ownership. CAP_SETPCAP is still effective + // while the bounding set is reduced. + cvt(unsafe { libc::prctl(libc::PR_SET_SECUREBITS, SECURE_BITS, 0, 0, 0) })?; + // SAFETY: this prctl variant has no pointer arguments. It clears the + // calling thread's ambient set synchronously. + cvt(unsafe { + libc::prctl( + libc::PR_CAP_AMBIENT, + libc::PR_CAP_AMBIENT_CLEAR_ALL, + 0, + 0, + 0, + ) + })?; + for capability in 0..=last_capability { + // SAFETY: this prctl variant has no pointer arguments. Each numeric + // capability is bounded by the kernel-reported cap_last_cap value. + cvt(unsafe { libc::prctl(libc::PR_CAPBSET_DROP, capability, 0, 0, 0) })?; + } + + let header = CapHeader { + version: LINUX_CAPABILITY_VERSION_3, + pid: 0, + }; + let data = [CapData::default(), CapData::default()]; + // SAFETY: the header and two-element data array match Linux's + // _LINUX_CAPABILITY_VERSION_3 ABI, remain valid for the call, contain no + // outgoing pointers, and are not retained by the kernel. + cvt_long(unsafe { libc::syscall(libc::SYS_capset, &header, data.as_ptr()) }).map(|_| ()) +} + +pub(crate) fn verify_no_capabilities() -> io::Result<()> { + const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; + let mut header = CapHeader { + version: LINUX_CAPABILITY_VERSION_3, + pid: 0, + }; + let mut data = [CapData::default(), CapData::default()]; + // SAFETY: the writable header and data array match Linux's capability ABI, + // remain uniquely borrowed for the syscall, and are fully initialized. + cvt_long(unsafe { libc::syscall(libc::SYS_capget, &mut header, data.as_mut_ptr()) })?; + if data + .iter() + .any(|entry| entry.effective != 0 || entry.permitted != 0 || entry.inheritable != 0) + { + Err(io::Error::from_raw_os_error(libc::EPERM)) + } else { + Ok(()) + } +} + +pub(crate) fn verify_io_uring_blocked() -> io::Result<()> { + // SAFETY: the seccomp filter must reject this syscall before the kernel + // dereferences the deliberately null parameter pointer. No descriptor is + // created on the required EPERM path and no ownership is transferred. + let result = unsafe { + libc::syscall( + libc::SYS_io_uring_setup, + 1_u32, + std::ptr::null::(), + ) + }; + let error = io::Error::last_os_error(); + if result == -1 && error.raw_os_error() == Some(libc::EPERM) { + Ok(()) + } else { + if result >= 0 { + let descriptor = + i32::try_from(result).map_err(|_| io::Error::from_raw_os_error(libc::EOVERFLOW))?; + // SAFETY: an unexpected successful setup returned a fresh + // descriptor, which is closed exactly once before reporting the + // failed postcondition. + unsafe { libc::close(descriptor) }; + } + Err(io::Error::from_raw_os_error(libc::EPERM)) + } +} + +pub(crate) fn c_string(value: &str) -> io::Result { + CString::new(value).map_err(|_| io::Error::from_raw_os_error(libc::EINVAL)) +} + +pub(crate) fn file_from_owned(fd: OwnedFd) -> File { + File::from(fd) +} + +fn cvt(value: libc::c_int) -> io::Result { + if value == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(value) + } +} + +fn cvt_long(value: libc::c_long) -> io::Result { + if value == -1 { + Err(io::Error::last_os_error()) + } else { + Ok(value) + } +} diff --git a/crates/linux/src/landlock.rs b/crates/linux/src/landlock.rs new file mode 100644 index 0000000..27f9420 --- /dev/null +++ b/crates/linux/src/landlock.rs @@ -0,0 +1,111 @@ +use std::{collections::BTreeMap, fs::File}; + +use landlock::{ + ABI, Access, AccessFs, BitFlags, CompatLevel, Compatible, PathBeneath, Ruleset, RulesetAttr, + RulesetCreated, RulesetCreatedAttr, RulesetStatus, Scope, +}; +use sandy_core::{AccessMode, PolicySpec}; + +use crate::{ + LinuxError, LinuxErrorKind, + prepare::{PinnedKind, PinnedPath, denied}, +}; + +pub(crate) struct PreparedLandlock { + ruleset: RulesetCreated, +} + +pub(crate) fn prepare( + policy: &PolicySpec, + pinned: &BTreeMap, +) -> Result { + let builder = Ruleset::default() + .set_compatibility(CompatLevel::HardRequirement) + .handle_access(AccessFs::from_all(ABI::V8)) + .and_then(|builder| builder.scope(Scope::Signal)) + .map_err(|_| unsupported("Landlock ABI or signal scope"))?; + let mut ruleset = builder + .create() + .map_err(|_| unsupported("Landlock ruleset creation"))?; + + for grant in &policy.files { + if denied(&grant.path, &policy.protected_paths) { + continue; + } + let pinned_path = pinned + .get(&grant.path) + .ok_or_else(|| preparation("Landlock path lookup"))?; + let mut rights = read_rights(pinned_path.kind); + if grant.access == AccessMode::ReadWrite { + rights |= write_rights(pinned_path.kind); + } + ruleset = add_rule(ruleset, &pinned_path.file, rights)?; + } + for grant in &policy.executables { + if denied(&grant.path, &policy.protected_paths) { + continue; + } + let file = &pinned + .get(&grant.path) + .ok_or_else(|| preparation("Landlock executable lookup"))? + .file; + ruleset = add_rule(ruleset, file, AccessFs::Execute.into())?; + } + // The shape checks in preparation ensure all directory rules are subtree + // rules. PathBeneath is therefore exact for files and recursive only for + // directory grants already validated as subtrees. + Ok(PreparedLandlock { ruleset }) +} + +pub(crate) fn apply(prepared: PreparedLandlock) -> Result<(), LinuxError> { + let status = prepared + .ruleset + .set_compatibility(CompatLevel::HardRequirement) + .restrict_self() + .map_err(|_| enforcement("Landlock application"))?; + if status.ruleset != RulesetStatus::FullyEnforced || !status.no_new_privs { + return Err(enforcement("Landlock completeness")); + } + Ok(()) +} + +fn add_rule( + ruleset: RulesetCreated, + file: &File, + rights: BitFlags, +) -> Result { + ruleset + .add_rule(PathBeneath::new(file, rights)) + .map_err(|_| preparation("Landlock rule addition")) +} + +fn read_rights(kind: PinnedKind) -> BitFlags { + if kind == PinnedKind::Directory { + AccessFs::ReadFile | AccessFs::ReadDir + } else { + AccessFs::ReadFile.into() + } +} + +fn write_rights(kind: PinnedKind) -> BitFlags { + // Keep the fixed filesystem mutation set independent from future rights. + let rights = AccessFs::from_write(ABI::V8); + if kind == PinnedKind::Directory { + rights + } else { + // Never attach directory-mutation rights to an exact file-like rule. + rights & AccessFs::from_file(ABI::V8) + } +} + +fn preparation(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::PreparationFailed, phase) +} + +fn enforcement(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::EnforcementFailed, phase) +} + +fn unsupported(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::Unsupported, phase) +} diff --git a/crates/linux/src/lib.rs b/crates/linux/src/lib.rs new file mode 100644 index 0000000..9ceb68a --- /dev/null +++ b/crates/linux/src/lib.rs @@ -0,0 +1,68 @@ +//! Linux-native enforcement for a validated Sandy policy. +//! +//! This is an implementation crate used by the supported Rust facade and the +//! CLI bootstrap. It is deliberately split into a deterministic plan, ambient +//! preparation, and an irreversible application transaction. + +#![deny(unsafe_code)] +#![deny(unsafe_op_in_unsafe_fn)] +#![warn(missing_docs)] + +#[cfg(target_os = "linux")] +mod apply; +#[cfg(target_os = "linux")] +mod capabilities; +mod error; +#[cfg(target_os = "linux")] +#[allow(unsafe_code)] +mod ffi; +#[cfg(target_os = "linux")] +mod landlock; +#[cfg(target_os = "linux")] +mod mount; +#[cfg(target_os = "linux")] +mod namespace; +mod plan; +#[cfg(target_os = "linux")] +mod prepare; +#[cfg(target_os = "linux")] +mod seccomp; + +#[cfg(target_os = "linux")] +pub use apply::apply; +pub use error::{LinuxError, LinuxErrorKind}; +pub use plan::{LinuxPolicyPlan, plan}; +#[cfg(target_os = "linux")] +pub use prepare::{PreparedLinuxSandbox, prepare, prepare_foreground_launch}; + +/// Native helpers used only by Sandy's sacrificial Linux enforcement tests. +#[cfg(all(target_os = "linux", feature = "live-tests"))] +#[doc(hidden)] +pub mod live_test_support { + use std::io; + + /// Creates an owner-only System V message queue in the current IPC namespace. + pub fn create_private_message_queue() -> io::Result { + crate::ffi::create_private_message_queue() + } + + /// Reports whether a System V message queue is visible in the current namespace. + pub fn message_queue_is_visible(id: i32) -> io::Result { + crate::ffi::message_queue_is_visible(id) + } + + /// Removes a System V message queue created by a live test. + pub fn remove_message_queue(id: i32) -> io::Result<()> { + crate::ffi::remove_message_queue(id) + } + + /// Creates a test key in a fresh anonymous session keyring. + pub fn create_session_test_key(payload: &[u8]) -> io::Result { + crate::ffi::create_session_test_key(payload) + } + + /// Reads a test key through the native keyctl interface. + pub fn read_test_key(id: i32) -> io::Result> { + crate::ffi::read_test_key(id) + } +} diff --git a/crates/linux/src/mount.rs b/crates/linux/src/mount.rs new file mode 100644 index 0000000..07ea7dd --- /dev/null +++ b/crates/linux/src/mount.rs @@ -0,0 +1,146 @@ +use std::{ + fs::{self, OpenOptions}, + os::{ + fd::AsFd, + unix::fs::{OpenOptionsExt, PermissionsExt}, + }, + path::{Path, PathBuf}, +}; + +use sandy_core::AbsolutePath; + +use crate::{ + LinuxError, LinuxErrorKind, ffi, + prepare::{MountPreparation, PinnedKind}, +}; + +const STAGING_ROOT: &str = "/tmp/.sandy-root"; + +pub(crate) fn construct_and_enter(preparation: &MountPreparation) -> Result<(), LinuxError> { + let tmp = ffi::c_string("/tmp").map_err(|_| enforcement("root construction"))?; + ffi::mount_tmpfs(&tmp).map_err(|_| enforcement("temporary root isolation"))?; + fs::create_dir(STAGING_ROOT).map_err(|_| enforcement("root construction"))?; + fs::set_permissions(STAGING_ROOT, fs::Permissions::from_mode(0o700)) + .map_err(|_| enforcement("root permissions"))?; + let staging = ffi::c_string(STAGING_ROOT).map_err(|_| enforcement("root construction"))?; + ffi::mount_tmpfs(&staging).map_err(|_| enforcement("private root mount"))?; + fs::create_dir(format!("{STAGING_ROOT}/.old_root")) + .map_err(|_| enforcement("root construction"))?; + + for requirement in &preparation.mounts { + create_target(&requirement.path, preparation)?; + } + for protection in &preparation.protections { + create_target(&protection.path, preparation)?; + } + + let root = OpenOptions::new() + .read(true) + .custom_flags(libc::O_PATH | libc::O_CLOEXEC) + .open(STAGING_ROOT) + .map_err(|_| enforcement("private root pinning"))?; + + for requirement in &preparation.mounts { + let source = preparation + .pinned + .get(&requirement.path) + .ok_or_else(|| enforcement("mount source lookup"))?; + attach( + &root, + &requirement.path, + &source.file, + ffi::MountRestrictions { + recursive: requirement.recursive, + read_only: !requirement.writable, + allow_device: source.kind == PinnedKind::Device, + allow_execute: requirement.executable, + }, + )?; + } + for protection in &preparation.protections { + let source = preparation + .pinned + .get(&protection.path) + .ok_or_else(|| enforcement("write-protection lookup"))?; + attach( + &root, + &protection.path, + &source.file, + ffi::MountRestrictions { + recursive: protection.recursive, + read_only: true, + allow_device: source.kind == PinnedKind::Device, + allow_execute: protection.executable, + }, + )?; + } + for alias in &preparation.aliases { + let path = target_path(&alias.path); + let parent = path + .parent() + .ok_or_else(|| enforcement("runtime alias creation"))?; + fs::create_dir_all(parent).map_err(|_| enforcement("runtime alias creation"))?; + std::os::unix::fs::symlink(&alias.target, &path) + .map_err(|_| enforcement("runtime alias creation"))?; + } + + std::env::set_current_dir(STAGING_ROOT).map_err(|_| enforcement("root entry"))?; + ffi::pivot_root(c".", c".old_root").map_err(|_| enforcement("root entry"))?; + std::env::set_current_dir("/").map_err(|_| enforcement("root entry"))?; + ffi::detach_mount(c"/.old_root").map_err(|_| enforcement("old root detachment"))?; + fs::remove_dir("/.old_root").map_err(|_| enforcement("old root cleanup"))?; + + std::env::set_current_dir(preparation.working_directory.as_path()) + .map_err(|_| enforcement("working directory restoration"))?; + Ok(()) +} + +fn create_target(path: &AbsolutePath, preparation: &MountPreparation) -> Result<(), LinuxError> { + let source = preparation + .pinned + .get(path) + .ok_or_else(|| enforcement("mount target lookup"))?; + let target = target_path(path); + if source.kind == PinnedKind::Directory { + fs::create_dir_all(&target).map_err(|_| enforcement("mount target creation"))?; + } else { + let parent = target + .parent() + .ok_or_else(|| enforcement("mount target creation"))?; + fs::create_dir_all(parent).map_err(|_| enforcement("mount target creation"))?; + if !target.exists() { + OpenOptions::new() + .write(true) + .create_new(true) + .open(&target) + .map_err(|_| enforcement("mount target creation"))?; + } + } + Ok(()) +} + +fn attach( + root: &std::fs::File, + path: &AbsolutePath, + source: &std::fs::File, + restrictions: ffi::MountRestrictions, +) -> Result<(), LinuxError> { + let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); + let relative = ffi::c_string(relative).map_err(|_| enforcement("mount target pinning"))?; + let target = ffi::open_path_at(root.as_fd(), &relative) + .map_err(|_| enforcement("mount target pinning"))?; + let detached = ffi::clone_mount(source.as_fd(), restrictions.recursive) + .map_err(|_| enforcement("detached mount creation"))?; + ffi::restrict_mount(detached.as_fd(), restrictions) + .map_err(|_| enforcement("detached mount restriction"))?; + ffi::attach_mount(detached.as_fd(), target.as_fd()).map_err(|_| enforcement("mount attachment")) +} + +fn target_path(path: &AbsolutePath) -> PathBuf { + let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); + Path::new(STAGING_ROOT).join(relative) +} + +fn enforcement(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::EnforcementFailed, phase) +} diff --git a/crates/linux/src/namespace.rs b/crates/linux/src/namespace.rs new file mode 100644 index 0000000..4b35175 --- /dev/null +++ b/crates/linux/src/namespace.rs @@ -0,0 +1,105 @@ +use std::{fs, io}; + +use crate::{LinuxError, LinuxErrorKind, ffi}; + +pub(crate) struct NamespacePreparation { + effective_uid: u32, + effective_gid: u32, + pub(crate) last_capability: u32, +} + +pub(crate) fn prepare(block_network: bool) -> Result { + ensure_single_threaded()?; + let (effective_uid, effective_gid) = ffi::effective_ids(); + ffi::probe_namespace_setup(effective_uid, effective_gid, block_network) + .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "namespace support"))?; + let raw_last_capability = fs::read_to_string("/proc/sys/kernel/cap_last_cap") + .map_err(|_| preparation("capability discovery"))?; + if raw_last_capability.len() > 16 { + return Err(preparation("capability discovery")); + } + let last_capability = raw_last_capability + .trim() + .parse::() + .map_err(|_| preparation("capability discovery"))?; + if last_capability >= 64 { + return Err(LinuxError::new( + LinuxErrorKind::Unsupported, + "capability ABI", + )); + } + + Ok(NamespacePreparation { + effective_uid, + effective_gid, + last_capability, + }) +} + +pub(crate) fn enter( + preparation: &NamespacePreparation, + block_network: bool, +) -> Result<(), LinuxError> { + ffi::unshare_namespaces(block_network).map_err(|_| enforcement("namespace creation"))?; + + fs::write( + "/proc/self/uid_map", + format!( + "{} {} 1\n", + preparation.effective_uid, preparation.effective_uid + ), + ) + .map_err(|_| enforcement("user mapping"))?; + match fs::write("/proc/self/setgroups", b"deny") { + Ok(()) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => { + let state = fs::read_to_string("/proc/self/setgroups") + .map_err(|_| enforcement("group mapping"))?; + if state.trim() != "deny" { + return Err(enforcement("group mapping")); + } + } + Err(_) => return Err(enforcement("group mapping")), + } + fs::write( + "/proc/self/gid_map", + format!( + "{} {} 1\n", + preparation.effective_gid, preparation.effective_gid + ), + ) + .map_err(|_| enforcement("group mapping"))?; + + ffi::make_mounts_private().map_err(|_| enforcement("mount propagation"))?; + ffi::isolate_session_keyring().map_err(|_| enforcement("session keyring isolation")) +} + +fn ensure_single_threaded() -> Result<(), LinuxError> { + let mut entries = fs::read_dir("/proc/self/task") + .map_err(|_| LinuxError::new(LinuxErrorKind::Unsupported, "thread inspection"))?; + let first = entries + .next() + .transpose() + .map_err(|_| preparation("thread inspection"))?; + let second = entries + .next() + .transpose() + .map_err(|_| preparation("thread inspection"))?; + if first.is_none() || second.is_some() { + Err(LinuxError::new( + LinuxErrorKind::Unsupported, + "single-threaded precondition", + )) + } else { + Ok(()) + } +} + +fn preparation(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::PreparationFailed, phase) +} + +fn enforcement(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::EnforcementFailed, phase) +} diff --git a/crates/linux/src/plan.rs b/crates/linux/src/plan.rs new file mode 100644 index 0000000..9737207 --- /dev/null +++ b/crates/linux/src/plan.rs @@ -0,0 +1,235 @@ +use sandy_core::{ + FileMetadataPolicy, NetworkPolicy, PathScope, RuntimeCompatibility, ValidatedPolicy, +}; + +use crate::{LinuxError, LinuxErrorKind}; + +const BASELINE_LANDLOCK_ABI: u32 = 6; + +/// Deterministic Linux lowering of a validated platform-neutral policy. +/// +/// This type owns no file descriptors and performs no ambient discovery. It +/// exists so representability decisions can be reviewed and tested separately +/// from path pinning and irreversible enforcement. +pub struct LinuxPolicyPlan { + pub(crate) policy: ValidatedPolicy, +} + +impl LinuxPolicyPlan { + /// Returns whether a private network namespace is required. + #[must_use] + pub fn blocks_network(&self) -> bool { + self.policy.spec().network == NetworkPolicy::BlockAll + } + + /// Returns whether ordinary descendant process creation is authorized. + #[must_use] + pub fn allows_subprocesses(&self) -> bool { + self.policy.spec().allow_subprocesses + } + + /// Returns the minimum Landlock ABI required by this policy. + /// + /// The initial backend uses one fixed semantic floor for every accepted + /// policy, including process-signal isolation. + #[must_use] + pub fn required_landlock_abi(&self) -> u32 { + BASELINE_LANDLOCK_ABI + } +} + +/// Lowers a validated policy into deterministic Linux semantics. +/// +/// Unsupported combinations are rejected here whenever their incompatibility +/// is independent of the ambient filesystem. File-type-dependent checks occur +/// during the target-specific `prepare` phase. +pub fn plan(policy: &ValidatedPolicy) -> Result { + let spec = policy.spec(); + + if spec.file_metadata != FileMetadataPolicy::Deny { + return Err(unsupported("filesystem metadata policy")); + } + if !spec.local_host_tcp.is_empty() { + return Err(unsupported("local-host TCP policy")); + } + if !spec.unix_sockets.is_empty() { + return Err(unsupported("pathname Unix-socket policy")); + } + if spec.runtime_compatibility != RuntimeCompatibility::Minimal + && spec.runtime_compatibility != RuntimeCompatibility::ForegroundCli + { + return Err(unsupported("runtime compatibility policy")); + } + // A read-only recursive mount protects only names reached through that + // mount. A pre-existing hard link outside the protected subtree can still + // mutate the same inode through a writable mount. Reject the shape until + // Linux can prove the complete requested semantics. + if spec + .write_protections + .iter() + .any(|protection| protection.scope == PathScope::Subtree) + { + return Err(unsupported("recursive write protection")); + } + + // A private filesystem view hides non-granted paths completely. A deny + // nested inside a visible subtree, however, would leave at least a mount + // placeholder observable. Reject it until an implementation can prove the + // same metadata-denial semantics as a fully absent path. + for protected in &spec.protected_paths { + let overlaps_visible_subtree = spec.files.iter().any(|grant| { + !denied(&grant.path, &spec.protected_paths) + && grant.scope == PathScope::Subtree + && protected.as_path().starts_with(grant.path.as_path()) + }) || spec.executables.iter().any(|grant| { + !denied(&grant.path, &spec.protected_paths) + && grant.scope == PathScope::Subtree + && protected.as_path().starts_with(grant.path.as_path()) + }); + if overlaps_visible_subtree { + return Err(unsupported("nested confidential path")); + } + } + + Ok(LinuxPolicyPlan { + policy: policy.clone(), + }) +} + +fn denied(path: &sandy_core::AbsolutePath, protected: &[sandy_core::AbsolutePath]) -> bool { + protected + .iter() + .any(|entry| path.as_path().starts_with(entry.as_path())) +} + +fn unsupported(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::Unsupported, phase) +} + +#[cfg(test)] +mod tests { + use sandy_core::{ + AbsolutePath, AccessMode, FileGrant, NetworkPolicy, PathScope, PolicySpec, ValidatedPolicy, + WriteProtection, + }; + + use super::*; + + fn path(value: &str) -> Result> { + Ok(AbsolutePath::new(value.to_owned())?) + } + + #[test] + fn accepts_monotonic_allowlists() -> Result<(), Box> { + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: path("/workspace")?, + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + + let plan = plan(&policy)?; + assert!(plan.blocks_network()); + assert!(!plan.allows_subprocesses()); + assert_eq!(plan.required_landlock_abi(), 6); + Ok(()) + } + + #[test] + fn rejects_confidential_child_of_visible_subtree() -> Result<(), Box> { + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: path("/workspace")?, + access: AccessMode::Read, + scope: PathScope::Subtree, + }], + protected_paths: vec![path("/workspace/.secret")?], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + + let error = plan(&policy).err().ok_or("policy unexpectedly supported")?; + assert_eq!(error.kind(), LinuxErrorKind::Unsupported); + Ok(()) + } + + #[test] + fn accepts_a_deny_that_completely_removes_a_grant() -> Result<(), Box> { + for (granted, protected) in [ + (path("/workspace")?, path("/workspace")?), + (path("/workspace/project")?, path("/workspace")?), + ] { + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: granted, + access: AccessMode::Read, + scope: PathScope::Subtree, + }], + protected_paths: vec![protected], + ..PolicySpec::default() + })?; + assert!(plan(&policy).is_ok()); + } + Ok(()) + } + + #[test] + fn rejects_recursive_write_protection() -> Result<(), Box> { + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: path("/workspace")?, + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + write_protections: vec![WriteProtection { + path: path("/workspace/protected")?, + scope: PathScope::Subtree, + }], + ..PolicySpec::default() + })?; + + let error = plan(&policy).err().ok_or("policy unexpectedly supported")?; + assert_eq!(error.kind(), LinuxErrorKind::Unsupported); + Ok(()) + } + + #[test] + fn rejects_local_host_tcp_without_broadening_it() -> Result<(), Box> { + use sandy_core::{LocalHostTcpGrant, LocalHostTcpOperation, TcpPort}; + + let port = TcpPort::new(443).ok_or("invalid test port")?; + let policy = ValidatedPolicy::try_from(PolicySpec { + local_host_tcp: vec![LocalHostTcpGrant { + port, + operation: LocalHostTcpOperation::Connect, + }], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + + let error = plan(&policy).err().ok_or("policy unexpectedly supported")?; + assert_eq!(error.kind(), LinuxErrorKind::Unsupported); + Ok(()) + } + + #[test] + fn rejects_pathname_socket_authority_until_the_backend_contract_supports_it() + -> Result<(), Box> { + use sandy_core::{UnixSocketGrant, UnixSocketOperation}; + + let policy = ValidatedPolicy::try_from(PolicySpec { + unix_sockets: vec![UnixSocketGrant { + path: path("/service.sock")?, + operation: UnixSocketOperation::Connect, + }], + ..PolicySpec::default() + })?; + + let error = plan(&policy).err().ok_or("policy unexpectedly supported")?; + assert_eq!(error.kind(), LinuxErrorKind::Unsupported); + Ok(()) + } +} diff --git a/crates/linux/src/prepare.rs b/crates/linux/src/prepare.rs new file mode 100644 index 0000000..a547f09 --- /dev/null +++ b/crates/linux/src/prepare.rs @@ -0,0 +1,557 @@ +use std::{ + collections::BTreeMap, + fs::{File, OpenOptions}, + os::{ + fd::AsFd, + unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}, + }, + path::PathBuf, +}; + +use sandy_core::{AbsolutePath, AccessMode, PathScope, RuntimeCompatibility}; + +use crate::{LinuxError, LinuxErrorKind, LinuxPolicyPlan, landlock, namespace, seccomp}; + +#[derive(Clone, Copy, Eq, PartialEq)] +pub(crate) enum PinnedKind { + Directory, + Regular, + Socket, + Device, + Other, +} + +pub(crate) struct PinnedPath { + pub(crate) file: File, + pub(crate) kind: PinnedKind, + device: u64, + inode: u64, + link_count: u64, +} + +pub(crate) struct MountRequirement { + pub(crate) path: AbsolutePath, + pub(crate) recursive: bool, + pub(crate) writable: bool, + pub(crate) executable: bool, +} + +pub(crate) struct ProtectionRequirement { + pub(crate) path: AbsolutePath, + pub(crate) recursive: bool, + pub(crate) executable: bool, +} + +pub(crate) struct MountPreparation { + pub(crate) pinned: BTreeMap, + pub(crate) mounts: Vec, + pub(crate) protections: Vec, + pub(crate) aliases: Vec, + pub(crate) working_directory: AbsolutePath, +} + +pub(crate) struct RuntimeAlias { + pub(crate) path: AbsolutePath, + pub(crate) target: PathBuf, +} + +/// Fully prepared Linux sandbox whose remaining operations are irreversible. +/// +/// This value owns all pinned path descriptors, the already-created Landlock +/// ruleset, compiled seccomp programs, and namespace inputs. Dropping it before +/// [`crate::apply`] leaves the process unrestricted. +#[must_use = "the sandbox is not enforced until this value is passed to sandy_linux::apply"] +pub struct PreparedLinuxSandbox { + pub(crate) mount: MountPreparation, + pub(crate) landlock: landlock::PreparedLandlock, + pub(crate) seccomp: seccomp::SeccompPrograms, + pub(crate) namespace: namespace::NamespacePreparation, + pub(crate) block_network: bool, +} + +/// Pins every ambient resource and prepares native enforcement without +/// restricting the current process. +pub fn prepare( + plan: LinuxPolicyPlan, + working_directory: &AbsolutePath, +) -> Result { + prepare_with_primary_executable(plan, working_directory, None) +} + +/// Prepares the Linux sandbox for a foreground CLI launch. +/// +/// `primary_executable` is already part of the caller's explicit executable +/// policy. Sandy uses it only to recreate the bounded `/proc/self/exe` +/// spelling needed by programs that identify or re-execute their own image; +/// procfs itself remains unmounted. +#[doc(hidden)] +pub fn prepare_foreground_launch( + plan: LinuxPolicyPlan, + working_directory: &AbsolutePath, + primary_executable: &AbsolutePath, +) -> Result { + prepare_with_primary_executable(plan, working_directory, Some(primary_executable)) +} + +fn prepare_with_primary_executable( + plan: LinuxPolicyPlan, + working_directory: &AbsolutePath, + primary_executable: Option<&AbsolutePath>, +) -> Result { + let block_network = plan.blocks_network(); + let namespace = namespace::prepare(block_network)?; + let mount = prepare_mounts(&plan, working_directory, primary_executable)?; + let landlock = landlock::prepare(plan.policy.spec(), &mount.pinned)?; + let seccomp = seccomp::compile(plan.allows_subprocesses(), block_network)?; + Ok(PreparedLinuxSandbox { + mount, + landlock, + seccomp, + namespace, + block_network, + }) +} + +fn prepare_mounts( + plan: &LinuxPolicyPlan, + working_directory: &AbsolutePath, + primary_executable: Option<&AbsolutePath>, +) -> Result { + let spec = plan.policy.spec(); + let mut requested = BTreeMap::::new(); + + for grant in &spec.files { + if denied(&grant.path, &spec.protected_paths) { + continue; + } + let entry = requested + .entry(grant.path.clone()) + .or_insert_with(|| MountRequirement { + path: grant.path.clone(), + recursive: false, + writable: false, + executable: false, + }); + entry.recursive |= grant.scope == PathScope::Subtree; + entry.writable |= grant.access == AccessMode::ReadWrite; + } + for grant in &spec.executables { + if denied(&grant.path, &spec.protected_paths) { + continue; + } + let entry = requested + .entry(grant.path.clone()) + .or_insert_with(|| MountRequirement { + path: grant.path.clone(), + recursive: false, + writable: false, + executable: false, + }); + entry.recursive |= grant.scope == PathScope::Subtree; + entry.executable = true; + } + let mut pinned = BTreeMap::new(); + for path in requested.keys().chain( + spec.write_protections + .iter() + .filter(|protection| visible(&protection.path, requested.values())) + .map(|protection| &protection.path), + ) { + if !pinned.contains_key(path) { + pinned.insert(path.clone(), pin(path)?); + } + } + + for grant in &spec.files { + if denied(&grant.path, &spec.protected_paths) { + continue; + } + let pinned_path = pinned + .get(&grant.path) + .ok_or_else(|| preparation("path pinning"))?; + let exact_directory = + grant.scope == PathScope::Exact && pinned_path.kind == PinnedKind::Directory; + let subtree_non_directory = + grant.scope == PathScope::Subtree && pinned_path.kind != PinnedKind::Directory; + // Landlock can constrain file-like write operations on one exact + // object. Directory mutation rights apply beneath directories, so an + // exact read-write directory would silently imply subtree authority. + // A typed exact device grant does not carry that ambiguity. + let unsupported_exact_read_write = grant.scope == PathScope::Exact + && grant.access == AccessMode::ReadWrite + && pinned_path.kind != PinnedKind::Device; + if exact_directory || subtree_non_directory || unsupported_exact_read_write { + return Err(unsupported("filesystem grant shape")); + } + } + for grant in &spec.executables { + if denied(&grant.path, &spec.protected_paths) { + continue; + } + let kind = pinned + .get(&grant.path) + .ok_or_else(|| preparation("executable pinning"))? + .kind; + if (grant.scope == PathScope::Exact && kind == PinnedKind::Directory) + || (grant.scope == PathScope::Subtree && kind != PinnedKind::Directory) + { + return Err(unsupported("executable grant shape")); + } + } + let mut mounts = requested.into_values().collect::>(); + mounts.sort_by_key(|entry| entry.path.as_path().components().count()); + let mut effective = Vec::::new(); + for mount in mounts { + let covered = effective.iter().any(|parent| { + parent.recursive + && mount.path.as_path().starts_with(parent.path.as_path()) + && (!mount.writable || parent.writable) + && (!mount.executable || parent.executable) + }); + if !covered { + effective.push(mount); + } + } + + let protections = spec + .write_protections + .iter() + .filter(|protection| visible(&protection.path, effective.iter())) + .map(|protection| { + let pinned_path = pinned + .get(&protection.path) + .ok_or_else(|| preparation("write-protection pinning"))?; + let kind = pinned_path.kind; + if kind == PinnedKind::Directory && protection.scope == PathScope::Exact { + return Err(unsupported("exact directory write protection")); + } + if kind == PinnedKind::Regular && pinned_path.link_count != 1 { + return Err(unsupported("write-protected hard-link alias")); + } + Ok(ProtectionRequirement { + path: protection.path.clone(), + recursive: protection.scope == PathScope::Subtree, + executable: executable(&protection.path, &spec.executables), + }) + }) + .collect::, LinuxError>>()?; + + for requirement in effective + .iter() + .filter(|requirement| requirement.executable) + { + reject_host_noexec(&requirement.path, &pinned)?; + } + for requirement in protections + .iter() + .filter(|requirement| requirement.executable) + { + reject_host_noexec(&requirement.path, &pinned)?; + } + + if !visible(working_directory, effective.iter()) { + return Err(unsupported("working directory visibility")); + } + let working_directory = working_directory.clone(); + let aliases = prepare_runtime_aliases( + spec.runtime_compatibility, + &effective, + &spec.protected_paths, + primary_executable, + )?; + + Ok(MountPreparation { + pinned, + mounts: effective, + protections, + aliases, + working_directory, + }) +} + +fn reject_host_noexec( + path: &AbsolutePath, + pinned: &BTreeMap, +) -> Result<(), LinuxError> { + let source = pinned + .get(path) + .ok_or_else(|| preparation("executable mount lookup"))?; + if crate::ffi::mount_is_noexec(source.file.as_fd()) + .map_err(|_| preparation("host mount inspection"))? + { + return Err(unsupported("host noexec restriction")); + } + Ok(()) +} + +fn executable(path: &AbsolutePath, grants: &[sandy_core::ExecutableGrant]) -> bool { + grants.iter().any(|grant| { + path == &grant.path + || (grant.scope == PathScope::Subtree + && path.as_path().starts_with(grant.path.as_path())) + }) +} + +fn prepare_runtime_aliases( + compatibility: RuntimeCompatibility, + mounts: &[MountRequirement], + protected_paths: &[AbsolutePath], + primary_executable: Option<&AbsolutePath>, +) -> Result, LinuxError> { + if compatibility != RuntimeCompatibility::ForegroundCli { + return Ok(Vec::new()); + } + + let mut aliases = Vec::new(); + for requested in [ + "/bin", + "/sbin", + "/lib", + "/lib64", + "/etc/resolv.conf", + "/etc/localtime", + "/etc/ssl/cert.pem", + ] { + let metadata = match std::fs::symlink_metadata(requested) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err(preparation("runtime alias inspection")), + }; + if !metadata.file_type().is_symlink() { + continue; + } + let canonical = std::fs::canonicalize(requested) + .map_err(|_| preparation("runtime alias resolution"))?; + let canonical = AbsolutePath::new( + canonical + .to_str() + .ok_or_else(|| preparation("runtime alias representation"))? + .to_owned(), + ) + .map_err(|_| preparation("runtime alias representation"))?; + let path = AbsolutePath::new(requested.to_owned()) + .map_err(|_| preparation("runtime alias representation"))?; + if !should_materialize_runtime_alias(&path, &canonical, mounts, protected_paths) { + continue; + } + let target = + std::fs::read_link(requested).map_err(|_| preparation("runtime alias resolution"))?; + aliases.push(RuntimeAlias { path, target }); + } + if let Some(alias) = + prepare_primary_executable_alias(primary_executable, mounts, protected_paths)? + { + aliases.push(alias); + } + Ok(aliases) +} + +fn prepare_primary_executable_alias( + primary_executable: Option<&AbsolutePath>, + mounts: &[MountRequirement], + protected_paths: &[AbsolutePath], +) -> Result, LinuxError> { + let Some(primary_executable) = primary_executable else { + return Ok(None); + }; + let path = AbsolutePath::new("/proc/self/exe".to_owned()) + .map_err(|_| preparation("primary executable alias"))?; + if denied(&path, protected_paths) + || visible(&path, mounts.iter()) + || !visible(primary_executable, mounts.iter()) + || !mounts.iter().any(|mount| { + mount.executable + && (primary_executable == &mount.path + || (mount.recursive + && primary_executable + .as_path() + .starts_with(mount.path.as_path()))) + }) + { + return Err(unsupported("primary executable alias")); + } + Ok(Some(RuntimeAlias { + path, + target: primary_executable.as_path().to_path_buf(), + })) +} + +fn should_materialize_runtime_alias( + path: &AbsolutePath, + canonical: &AbsolutePath, + mounts: &[MountRequirement], + protected_paths: &[AbsolutePath], +) -> bool { + !denied(path, protected_paths) + && !visible(path, mounts.iter()) + && visible(canonical, mounts.iter()) +} + +fn pin(path: &AbsolutePath) -> Result { + let root = OpenOptions::new() + .read(true) + .custom_flags(libc::O_PATH | libc::O_CLOEXEC) + .open("/") + .map_err(|_| preparation("root pinning"))?; + let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); + let relative = if relative.is_empty() { "." } else { relative }; + let relative = crate::ffi::c_string(relative).map_err(|_| preparation("path pinning"))?; + let owned = crate::ffi::open_path_at(root.as_fd(), &relative) + .map_err(|_| preparation("path pinning"))?; + let file = crate::ffi::file_from_owned(owned); + let metadata = file + .metadata() + .map_err(|_| preparation("path inspection"))?; + let kind = classify(&metadata); + Ok(PinnedPath { + file, + kind, + device: metadata.dev(), + inode: metadata.ino(), + link_count: metadata.nlink(), + }) +} + +pub(crate) fn repin_after_namespace(preparation: &mut MountPreparation) -> Result<(), LinuxError> { + let root = OpenOptions::new() + .read(true) + .custom_flags(libc::O_PATH | libc::O_CLOEXEC) + .open("/") + .map_err(|_| enforcement("namespace root pinning"))?; + + for (path, pinned) in &mut preparation.pinned { + let relative = path.as_str().strip_prefix('/').unwrap_or(path.as_str()); + let relative = if relative.is_empty() { "." } else { relative }; + let relative = + crate::ffi::c_string(relative).map_err(|_| enforcement("mount source repinning"))?; + let owned = crate::ffi::open_path_at(root.as_fd(), &relative) + .map_err(|_| enforcement("mount source repinning"))?; + let file = crate::ffi::file_from_owned(owned); + let metadata = file + .metadata() + .map_err(|_| enforcement("mount source verification"))?; + if metadata.dev() != pinned.device + || metadata.ino() != pinned.inode + || classify(&metadata) != pinned.kind + || metadata.nlink() != pinned.link_count + { + return Err(enforcement("mount source verification")); + } + pinned.file = file; + } + Ok(()) +} + +fn classify(metadata: &std::fs::Metadata) -> PinnedKind { + let file_type = metadata.file_type(); + if file_type.is_dir() { + PinnedKind::Directory + } else if file_type.is_file() { + PinnedKind::Regular + } else if file_type.is_socket() { + PinnedKind::Socket + } else if file_type.is_char_device() || file_type.is_block_device() { + PinnedKind::Device + } else { + PinnedKind::Other + } +} + +pub(crate) fn denied(path: &AbsolutePath, protected_paths: &[AbsolutePath]) -> bool { + protected_paths + .iter() + .any(|protected| path.as_path().starts_with(protected.as_path())) +} + +fn visible<'a>( + path: &AbsolutePath, + mounts: impl IntoIterator, +) -> bool { + mounts.into_iter().any(|mount| { + path == &mount.path || (mount.recursive && path.as_path().starts_with(mount.path.as_path())) + }) +} + +fn preparation(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::PreparationFailed, phase) +} + +fn enforcement(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::EnforcementFailed, phase) +} + +fn unsupported(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::Unsupported, phase) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path(value: &str) -> Result> { + Ok(AbsolutePath::new(value.to_owned())?) + } + + #[test] + fn protected_runtime_alias_is_not_materialized() -> Result<(), Box> { + let alias = path("/bin")?; + let canonical = path("/usr/bin")?; + let mounts = [MountRequirement { + path: canonical.clone(), + recursive: true, + writable: false, + executable: true, + }]; + + assert!(should_materialize_runtime_alias( + &alias, + &canonical, + &mounts, + &[], + )); + assert!(!should_materialize_runtime_alias( + &alias, + &canonical, + &mounts, + std::slice::from_ref(&alias), + )); + Ok(()) + } + + #[test] + fn primary_executable_alias_requires_existing_execute_authority() + -> Result<(), Box> { + let executable = path("/opt/agent/bin/codex")?; + let executable_mount = MountRequirement { + path: path("/opt/agent/bin")?, + recursive: true, + writable: false, + executable: true, + }; + let data_mount = MountRequirement { + path: path("/opt/agent/bin")?, + recursive: true, + writable: false, + executable: false, + }; + + let alias = prepare_primary_executable_alias( + Some(&executable), + std::slice::from_ref(&executable_mount), + &[], + )? + .ok_or("missing primary executable alias")?; + assert_eq!(alias.path.as_str(), "/proc/self/exe"); + assert_eq!(alias.target, executable.as_path()); + assert!(prepare_primary_executable_alias(Some(&executable), &[data_mount], &[]).is_err()); + assert!( + prepare_primary_executable_alias( + Some(&executable), + &[executable_mount], + &[path("/proc")?], + ) + .is_err() + ); + Ok(()) + } +} diff --git a/crates/linux/src/seccomp.rs b/crates/linux/src/seccomp.rs new file mode 100644 index 0000000..d48beeb --- /dev/null +++ b/crates/linux/src/seccomp.rs @@ -0,0 +1,179 @@ +use std::{collections::BTreeMap, convert::TryFrom}; + +use seccompiler::{ + BpfProgram, SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, + SeccompRule, TargetArch, +}; + +use crate::{LinuxError, LinuxErrorKind}; + +pub(crate) struct SeccompPrograms { + clone3: BpfProgram, + topology: BpfProgram, + block_network: bool, + allow_subprocesses: bool, +} + +pub(crate) fn compile( + allow_subprocesses: bool, + block_network: bool, +) -> Result { + let architecture = TargetArch::try_from(std::env::consts::ARCH) + .map_err(|_| unsupported("seccomp architecture"))?; + + let clone3 = compile_filter( + BTreeMap::from([(libc::SYS_clone3, Vec::new())]), + SeccompAction::Errno(libc::ENOSYS as u32), + architecture, + )?; + + let mut denied = BTreeMap::from([ + (libc::SYS_unshare, Vec::new()), + (libc::SYS_setns, Vec::new()), + (libc::SYS_mount, Vec::new()), + (libc::SYS_umount2, Vec::new()), + (libc::SYS_pivot_root, Vec::new()), + (libc::SYS_chroot, Vec::new()), + (libc::SYS_open_tree, Vec::new()), + (libc::SYS_move_mount, Vec::new()), + (libc::SYS_mount_setattr, Vec::new()), + (libc::SYS_fsopen, Vec::new()), + (libc::SYS_fsconfig, Vec::new()), + (libc::SYS_fsmount, Vec::new()), + (libc::SYS_fspick, Vec::new()), + (libc::SYS_add_key, Vec::new()), + (libc::SYS_request_key, Vec::new()), + (libc::SYS_keyctl, Vec::new()), + ]); + + let namespace_flags = [ + libc::CLONE_NEWCGROUP, + libc::CLONE_NEWIPC, + libc::CLONE_NEWNET, + libc::CLONE_NEWNS, + libc::CLONE_NEWPID, + libc::CLONE_NEWTIME, + libc::CLONE_NEWUSER, + libc::CLONE_NEWUTS, + ]; + let mut clone_rules = namespace_flags + .into_iter() + .map(|flag| masked_clone_rule(flag as u64, flag as u64)) + .collect::, _>>()?; + if !allow_subprocesses { + clone_rules.push(masked_clone_rule(libc::CLONE_THREAD as u64, 0)?); + deny_arch_process_creation(&mut denied); + denied.insert(libc::SYS_execve, Vec::new()); + denied.insert(libc::SYS_execveat, Vec::new()); + } + denied.insert(libc::SYS_clone, clone_rules); + + if block_network { + // io_uring can create and connect sockets without entering the + // corresponding socket/connect syscalls, so BlockAll must disable the + // ring interface as one indivisible capability. + denied.insert(libc::SYS_io_uring_setup, Vec::new()); + denied.insert(libc::SYS_io_uring_enter, Vec::new()); + denied.insert(libc::SYS_io_uring_register, Vec::new()); + denied.insert(libc::SYS_socket, Vec::new()); + } + + let topology = compile_filter( + denied, + SeccompAction::Errno(libc::EPERM as u32), + architecture, + )?; + Ok(SeccompPrograms { + clone3, + topology, + block_network, + allow_subprocesses, + }) +} + +fn deny_arch_process_creation(_denied: &mut BTreeMap>) { + // AArch64 has no separate fork or vfork syscalls; libc implements both + // through clone. Other supported architectures retain the legacy entry + // points, which must be denied alongside clone. + #[cfg(target_arch = "x86_64")] + { + _denied.insert(libc::SYS_fork, Vec::new()); + _denied.insert(libc::SYS_vfork, Vec::new()); + } +} + +pub(crate) fn apply(programs: &SeccompPrograms) -> Result<(), LinuxError> { + seccompiler::apply_filter(&programs.clone3) + .and_then(|()| seccompiler::apply_filter(&programs.topology)) + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp application"))?; + if programs.block_network { + crate::ffi::verify_io_uring_blocked().map_err(|_| { + LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition") + })?; + } + crate::ffi::verify_clone3_blocked() + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition"))?; + crate::ffi::verify_namespace_change_blocked() + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition"))?; + crate::ffi::verify_keyring_operations_blocked() + .map_err(|_| LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition"))?; + if !programs.allow_subprocesses { + crate::ffi::verify_fork_blocked().map_err(|_| { + LinuxError::new(LinuxErrorKind::EnforcementFailed, "seccomp postcondition") + })?; + } + Ok(()) +} + +fn compile_filter( + rules: BTreeMap>, + match_action: SeccompAction, + architecture: TargetArch, +) -> Result { + SeccompFilter::new(rules, SeccompAction::Allow, match_action, architecture) + .and_then(BpfProgram::try_from) + .map_err(|_| preparation("seccomp compilation")) +} + +fn masked_clone_rule(mask: u64, value: u64) -> Result { + let condition = SeccompCondition::new( + 0, + SeccompCmpArgLen::Qword, + SeccompCmpOp::MaskedEq(mask), + value, + ) + .map_err(|_| preparation("seccomp compilation"))?; + SeccompRule::new(vec![condition]).map_err(|_| preparation("seccomp compilation")) +} + +fn preparation(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::PreparationFailed, phase) +} + +fn unsupported(phase: &'static str) -> LinuxError { + LinuxError::new(LinuxErrorKind::Unsupported, phase) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn compiles_both_process_modes() { + assert!(compile(false, true).is_ok()); + assert!(compile(true, false).is_ok()); + } + + #[test] + fn disabled_process_mode_includes_legacy_entry_points() { + let mut denied = BTreeMap::new(); + deny_arch_process_creation(&mut denied); + #[cfg(target_arch = "x86_64")] + { + assert!(denied.contains_key(&libc::SYS_fork)); + assert!(denied.contains_key(&libc::SYS_vfork)); + } + #[cfg(target_arch = "aarch64")] + assert!(denied.is_empty()); + } +} diff --git a/crates/linux/tests/live_linux.rs b/crates/linux/tests/live_linux.rs new file mode 100644 index 0000000..94c4a0c --- /dev/null +++ b/crates/linux/tests/live_linux.rs @@ -0,0 +1,532 @@ +#[cfg(target_os = "linux")] +mod linux { + use std::{ + env, fs, + net::{TcpListener, TcpStream, UdpSocket}, + os::{ + unix::fs::PermissionsExt as _, + unix::net::{UnixListener, UnixStream}, + }, + process::{Child, Command, ExitStatus}, + thread, + time::{Duration, Instant}, + }; + + use sandy_core::{ + AbsolutePath, AccessMode, ExecutableGrant, FileGrant, NetworkPolicy, PathScope, PolicySpec, + RuntimeCompatibility, ValidatedPolicy, WriteProtection, + }; + + const CHILD_MODE: &str = "SANDY_LINUX_LIVE_CHILD"; + const DEVICE_CHILD: &str = "SANDY_LINUX_DEVICE_CHILD"; + const PROCESS_CHILD: &str = "SANDY_LINUX_PROCESS_CHILD"; + const REPLACEMENT_CHILD: &str = "SANDY_LINUX_REPLACEMENT_CHILD"; + const EXPECT_UNSUPPORTED: &str = "SANDY_LINUX_EXPECT_UNSUPPORTED"; + const HOST_IPC_ID: &str = "SANDY_LINUX_HOST_IPC_ID"; + const HOST_KEY_ID: &str = "SANDY_LINUX_HOST_KEY_ID"; + const WORKSPACE: &str = "SANDY_LINUX_LIVE_WORKSPACE"; + const TEST_KEY_PAYLOAD: &[u8] = b"host-session-key"; + + pub(super) fn run() -> Result<(), Box> { + if env::var_os(CHILD_MODE).is_some() { + return child_checks(); + } + if env::var_os(DEVICE_CHILD).is_some() { + return exact_device_child(); + } + if env::var_os(PROCESS_CHILD).is_some() { + return disabled_process_child(); + } + if env::var_os(REPLACEMENT_CHILD).is_some() { + return replacement_child(); + } + if env::var_os(EXPECT_UNSUPPORTED).is_some() { + return restricted_host_is_rejected_before_enforcement(); + } + + exact_directory_grants_are_rejected_before_enforcement()?; + exact_device_grants_do_not_expose_adjacent_devices()?; + host_noexec_is_never_weakened()?; + write_protected_hard_link_aliases_are_rejected()?; + mount_source_replacement_is_rejected()?; + disabled_process_mode_preserves_threads_only()?; + + let root = tempfile::tempdir()?; + let workspace = root.path().join("workspace"); + fs::create_dir(&workspace)?; + fs::write(workspace.join("readable.txt"), "visible")?; + fs::write(workspace.join("locked.txt"), "locked")?; + for name in ["allowed-true", "data-true", "allowed-sleep"] { + let path = workspace.join(name); + let source = if name == "allowed-sleep" { + "/bin/sleep" + } else { + "/bin/true" + }; + fs::copy(source, &path)?; + let mut permissions = fs::metadata(&path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(path, permissions)?; + } + fs::write(root.path().join("outside.txt"), "hidden")?; + + let host_ipc = sandy_linux::live_test_support::create_private_message_queue()?; + let host_key = sandy_linux::live_test_support::create_session_test_key(TEST_KEY_PAYLOAD)?; + let mut command = Command::new(env::current_exe()?); + command + .env(CHILD_MODE, "1") + .env(WORKSPACE, &workspace) + .env(HOST_IPC_ID, host_ipc.to_string()) + .env(HOST_KEY_ID, host_key.to_string()); + let status = status_with_timeout(&mut command); + let cleanup = sandy_linux::live_test_support::remove_message_queue(host_ipc); + let status = status?; + cleanup?; + if !status.success() { + return Err("sacrificial sandbox child failed".into()); + } + Ok(()) + } + + fn exact_device_grants_do_not_expose_adjacent_devices() -> Result<(), Box> + { + let root = tempfile::tempdir()?; + let workspace = root.path().join("workspace"); + fs::create_dir(&workspace)?; + let mut command = Command::new(env::current_exe()?); + command.env(DEVICE_CHILD, "1").env(WORKSPACE, &workspace); + let status = status_with_timeout(&mut command)?; + if !status.success() { + return Err("exact device child failed".into()); + } + Ok(()) + } + + fn exact_device_child() -> Result<(), Box> { + let workspace = absolute_from_environment(WORKSPACE)?; + let null_device = absolute(std::path::Path::new("/dev/null"))?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![ + FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }, + FileGrant { + path: null_device, + access: AccessMode::ReadWrite, + scope: PathScope::Exact, + }, + ], + network: NetworkPolicy::AllowAll, + ..PolicySpec::default() + })?; + let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; + sandy_linux::apply(prepared)?; + + fs::write("/dev/null", b"discarded")?; + if fs::metadata("/dev/zero").is_ok() { + return Err("adjacent device remained visible".into()); + } + Ok(()) + } + + fn mount_source_replacement_is_rejected() -> Result<(), Box> { + let mut command = Command::new(env::current_exe()?); + command.env(REPLACEMENT_CHILD, "1"); + let status = status_with_timeout(&mut command)?; + if !status.success() { + return Err("mount source replacement child failed".into()); + } + Ok(()) + } + + fn replacement_child() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let workspace_path = root.path().join("workspace"); + fs::create_dir(&workspace_path)?; + let workspace = absolute(&workspace_path)?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; + + fs::rename(&workspace_path, root.path().join("replaced"))?; + fs::create_dir(&workspace_path)?; + let error = sandy_linux::apply(prepared) + .err() + .ok_or("replaced mount source unexpectedly applied")?; + if error.kind() != sandy_linux::LinuxErrorKind::EnforcementFailed { + return Err("mount replacement returned the wrong error class".into()); + } + Ok(()) + } + + fn host_noexec_is_never_weakened() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let executable = std::path::Path::new("/proc/version"); + if !executable.is_file() { + return Err("host procfs has no regular version file".into()); + } + let workspace = absolute(root.path())?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![ + FileGrant { + path: workspace.clone(), + access: AccessMode::Read, + scope: PathScope::Subtree, + }, + FileGrant { + path: absolute(executable)?, + access: AccessMode::Read, + scope: PathScope::Exact, + }, + ], + executables: vec![ExecutableGrant { + path: absolute(executable)?, + scope: PathScope::Exact, + }], + ..PolicySpec::default() + })?; + let error = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace) + .err() + .ok_or("executable grant on a host noexec mount was unexpectedly accepted")?; + if error.kind() != sandy_linux::LinuxErrorKind::Unsupported { + return Err("host noexec restriction returned the wrong error class".into()); + } + Ok(()) + } + + fn disabled_process_mode_preserves_threads_only() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let workspace = root.path().join("workspace"); + fs::create_dir(&workspace)?; + let mut command = Command::new(env::current_exe()?); + command.env(PROCESS_CHILD, "1").env(WORKSPACE, &workspace); + let status = status_with_timeout(&mut command)?; + if !status.success() { + return Err("disabled process-mode child failed".into()); + } + Ok(()) + } + + fn write_protected_hard_link_aliases_are_rejected() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let workspace_path = root.path().join("workspace"); + fs::create_dir(&workspace_path)?; + let protected_path = workspace_path.join("protected.txt"); + fs::write(&protected_path, "protected")?; + fs::hard_link(&protected_path, workspace_path.join("alias.txt"))?; + let workspace = absolute(&workspace_path)?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + write_protections: vec![WriteProtection { + path: absolute(&protected_path)?, + scope: PathScope::Exact, + }], + ..PolicySpec::default() + })?; + let error = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace) + .err() + .ok_or("write-protected hard-link alias was unexpectedly accepted")?; + if error.kind() != sandy_linux::LinuxErrorKind::Unsupported { + return Err("hard-link alias returned the wrong error class".into()); + } + Ok(()) + } + + fn disabled_process_child() -> Result<(), Box> { + let workspace = absolute_from_environment(WORKSPACE)?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }], + allow_subprocesses: false, + ..PolicySpec::default() + })?; + let prepared = sandy_linux::prepare(sandy_linux::plan(&policy)?, &workspace)?; + sandy_linux::apply(prepared)?; + + if !matches!(thread::spawn(|| 23).join(), Ok(23)) { + return Err("thread creation was not preserved".into()); + } + if Command::new("/bin/true").spawn().is_ok() { + return Err("process creation remained available".into()); + } + Ok(()) + } + + fn absolute_from_environment(name: &str) -> Result> { + let path = fs::canonicalize(env::var_os(name).ok_or("missing test path")?)?; + absolute(&path) + } + + fn absolute(path: &std::path::Path) -> Result> { + Ok(AbsolutePath::new( + path.to_str().ok_or("test path is not UTF-8")?.to_owned(), + )?) + } + + fn restricted_host_is_rejected_before_enforcement() -> Result<(), Box> { + let root = tempfile::tempdir()?; + let path = AbsolutePath::new( + fs::canonicalize(root.path())? + .to_str() + .ok_or("path is not UTF-8")? + .to_owned(), + )?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: path.clone(), + access: AccessMode::Read, + scope: PathScope::Subtree, + }], + network: NetworkPolicy::BlockAll, + ..PolicySpec::default() + })?; + let error = sandy_linux::prepare(sandy_linux::plan(&policy)?, &path) + .err() + .ok_or("restricted host unexpectedly passed preparation")?; + if error.kind() != sandy_linux::LinuxErrorKind::Unsupported { + return Err("restricted host returned the wrong error class".into()); + } + Ok(()) + } + + fn child_checks() -> Result<(), Box> { + let workspace = fs::canonicalize(env::var_os(WORKSPACE).ok_or("missing workspace")?)?; + let workspace = AbsolutePath::new( + workspace + .to_str() + .ok_or("workspace path is not UTF-8")? + .to_owned(), + )?; + let locked = AbsolutePath::new(format!("{}/locked.txt", workspace.as_str()))?; + let allowed_true = AbsolutePath::new(format!("{}/allowed-true", workspace.as_str()))?; + let allowed_sleep = AbsolutePath::new(format!("{}/allowed-sleep", workspace.as_str()))?; + let data_true = AbsolutePath::new(format!("{}/data-true", workspace.as_str()))?; + let system_libraries = absolute(std::path::Path::new("/usr/lib"))?; + let loader = absolute(&fs::canonicalize(dynamic_loader())?)?; + let primary_executable = absolute(&env::current_exe()?)?; + let host_ipc = env::var(HOST_IPC_ID)?.parse::()?; + let host_key = env::var(HOST_KEY_ID)?.parse::()?; + if !sandy_linux::live_test_support::message_queue_is_visible(host_ipc)? { + return Err("host IPC fixture was not inherited before enforcement".into()); + } + if sandy_linux::live_test_support::read_test_key(host_key)? != TEST_KEY_PAYLOAD { + return Err("host session key fixture was not inherited before enforcement".into()); + } + // This child starts before Landlock creates the sandbox signal domain. + // It therefore represents an unrelated host process even though this + // sacrificial process owns the handle needed for deterministic cleanup. + let mut outside_signal_target = Command::new("/bin/sleep").arg("2").spawn()?; + let mut files = vec![ + FileGrant { + path: workspace.clone(), + access: AccessMode::ReadWrite, + scope: PathScope::Subtree, + }, + FileGrant { + path: system_libraries.clone(), + access: AccessMode::Read, + scope: PathScope::Subtree, + }, + FileGrant { + path: primary_executable.clone(), + access: AccessMode::Read, + scope: PathScope::Exact, + }, + ]; + if let Ok(cache) = fs::canonicalize("/etc/ld.so.cache") { + files.push(FileGrant { + path: absolute(&cache)?, + access: AccessMode::Read, + scope: PathScope::Exact, + }); + } + let policy = ValidatedPolicy::try_from(PolicySpec { + files, + executables: vec![ + ExecutableGrant { + path: system_libraries, + scope: PathScope::Subtree, + }, + ExecutableGrant { + path: allowed_true.clone(), + scope: PathScope::Exact, + }, + ExecutableGrant { + path: allowed_sleep.clone(), + scope: PathScope::Exact, + }, + ExecutableGrant { + path: primary_executable.clone(), + scope: PathScope::Exact, + }, + ], + write_protections: vec![WriteProtection { + path: locked, + scope: PathScope::Exact, + }], + network: NetworkPolicy::BlockAll, + allow_subprocesses: true, + runtime_compatibility: RuntimeCompatibility::ForegroundCli, + ..PolicySpec::default() + })?; + + let prepared = sandy_linux::prepare_foreground_launch( + sandy_linux::plan(&policy)?, + &workspace, + &primary_executable, + )?; + sandy_linux::apply(prepared)?; + + if env::current_exe()? != primary_executable.as_path() + || !std::path::Path::new("/proc/self/exe").exists() + || std::path::Path::new("/proc/self/fd").exists() + || std::path::Path::new("/proc/1").exists() + { + return Err("bounded primary executable alias was not preserved".into()); + } + + if sandy_linux::live_test_support::message_queue_is_visible(host_ipc)? { + return Err("host System V IPC remained visible after enforcement".into()); + } + match sandy_linux::live_test_support::read_test_key(host_key) { + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {} + Ok(_) => return Err("inherited host session key remained readable".into()), + Err(error) => return Err(error.into()), + } + + match outside_signal_target.kill() { + Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {} + Ok(()) => { + let _ = outside_signal_target.wait(); + return Err("sandbox signaled a process outside its signal domain".into()); + } + Err(error) => return Err(error.into()), + } + let _ = wait_child_with_timeout(&mut outside_signal_target)?; + + let mut same_domain = Command::new(loader.as_path()) + .arg(allowed_sleep.as_path()) + .arg("30") + .spawn()?; + same_domain.kill()?; + let _ = wait_child_with_timeout(&mut same_domain)?; + + if fs::read_to_string("readable.txt")? != "visible" { + return Err("granted file contents changed".into()); + } + fs::write("created.txt", "created")?; + if fs::write("locked.txt", "changed").is_ok() { + return Err("write-protected file was mutable".into()); + } + if fs::metadata("/etc/passwd").is_ok() || fs::metadata("../outside.txt").is_ok() { + return Err("non-granted filesystem data remained visible".into()); + } + let mut allowed_command = Command::new(loader.as_path()); + allowed_command.arg(allowed_true.as_path()); + if !status_with_timeout(&mut allowed_command)?.success() { + return Err("explicit executable mapping was not preserved".into()); + } + let mut denied_command = Command::new(loader.as_path()); + denied_command.arg(data_true.as_path()); + if status_with_timeout(&mut denied_command)?.success() { + return Err("readable data was mapped executable".into()); + } + if TcpStream::connect_timeout(&"1.1.1.1:80".parse()?, Duration::from_millis(100)).is_ok() { + return Err("blocked network connection succeeded".into()); + } + if TcpListener::bind("127.0.0.1:0").is_ok() + || UdpSocket::bind("127.0.0.1:0").is_ok() + || UnixListener::bind("server.sock").is_ok() + { + return Err("addressable socket creation succeeded".into()); + } + let _local_pair = UnixStream::pair()?; + Ok(()) + } + + fn dynamic_loader() -> &'static std::path::Path { + #[cfg(target_arch = "x86_64")] + { + std::path::Path::new("/lib64/ld-linux-x86-64.so.2") + } + #[cfg(target_arch = "aarch64")] + { + std::path::Path::new("/lib/ld-linux-aarch64.so.1") + } + } + + fn exact_directory_grants_are_rejected_before_enforcement() + -> Result<(), Box> { + let root = tempfile::tempdir()?; + let path = AbsolutePath::new( + fs::canonicalize(root.path())? + .to_str() + .ok_or("path is not UTF-8")? + .to_owned(), + )?; + let policy = ValidatedPolicy::try_from(PolicySpec { + files: vec![FileGrant { + path: path.clone(), + access: AccessMode::Read, + scope: PathScope::Exact, + }], + ..PolicySpec::default() + })?; + + let error = sandy_linux::prepare(sandy_linux::plan(&policy)?, &path) + .err() + .ok_or("exact directory unexpectedly supported")?; + if error.kind() != sandy_linux::LinuxErrorKind::Unsupported { + return Err("exact directory returned the wrong error class".into()); + } + Ok(()) + } + + fn status_with_timeout( + command: &mut Command, + ) -> Result> { + let mut child = command.spawn()?; + wait_child_with_timeout(&mut child) + } + + fn wait_child_with_timeout( + child: &mut Child, + ) -> Result> { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + if Instant::now() >= deadline { + child.kill()?; + let _ = child.wait(); + return Err("live Linux fixture exceeded its hard timeout".into()); + } + thread::sleep(Duration::from_millis(10)); + } + } +} + +#[cfg(target_os = "linux")] +fn main() -> Result<(), Box> { + linux::run() +} + +#[cfg(not(target_os = "linux"))] +fn main() {} diff --git a/deny.toml b/deny.toml index c461e93..d2d1720 100644 --- a/deny.toml +++ b/deny.toml @@ -10,6 +10,7 @@ confidence-threshold = 0.8 allow = [ "Apache-2.0", "Apache-2.0 WITH LLVM-exception", + "BSD-3-Clause", "MIT", "Unicode-3.0", ] diff --git a/docs/LINUX_SECURITY_MODEL.md b/docs/LINUX_SECURITY_MODEL.md new file mode 100644 index 0000000..2ff4272 --- /dev/null +++ b/docs/LINUX_SECURITY_MODEL.md @@ -0,0 +1,162 @@ +# Linux security model + +The Linux backend is one native security boundary shared by the supported Rust +facade and the CLI bootstrap. It adds no product policy, profiles, arguments, +environment values, or implicit filesystem grants. + +```text +ValidatedPolicy + | + v +LinuxPolicyPlan deterministic and ambient-free + | + v +PreparedLinuxSandbox pins paths and compiles native rules + | + v +apply() irreversible current-process transition +``` + +## Enforcement transaction + +Preparation verifies the single-threaded precondition, rejects unsupported +policy combinations, opens policy paths with `openat2`, verifies that +executable grants do not require clearing an inherited host `noexec`, exercises +the namespace and modern mount transaction in a sacrificial child, creates a +hard-required Landlock ruleset, and compiles the seccomp filters. A preparation +failure leaves the process unrestricted. + +Application then: + +1. enters private user, mount, and IPC namespaces, plus a network namespace + for `BlockAll`; +2. installs same-ID, one-entry UID and GID maps and makes mount propagation + private, then replaces the inherited session keyring with a fresh anonymous + keyring; +3. reopens every grant in the new mount namespace and verifies its device, + inode, and type against the original pinned object; +4. constructs a private tmpfs root from the verified grants; +5. overlays typed write protections and enters the private root; +6. applies the complete Landlock ruleset and `no_new_privs`; +7. removes ambient, bounding, effective, permitted, and inheritable capabilities; +8. installs seccomp filters that freeze namespace and mount topology and enforce + subprocess and network-socket creation policy. + +An application failure can leave the current process partially restricted. The +caller must terminate immediately. The CLI bootstrap exits without executing +its target. + +## Fixed native semantics + +Sandy's fixed filesystem and signal-isolation baseline requires Linux 6.12 or +a vendor kernel carrying Landlock ABI 6. +Every accepted policy scopes signal delivery: a sandboxed process may signal +same-domain descendants but cannot signal an unsandboxed parent, sibling, or +other host process. The requirement is hard: a host never receives a weakened +version of a requested policy. A newer host ABI does not silently add +restrictions. Device ioctls on already-open descriptors remain inherited +capabilities. + +The host must permit the calling executable to create and configure user, +mount, and IPC namespaces and—when blocking network access—a network namespace. +Some distributions restrict those operations through a system security profile. +Sandy exercises the namespace and modern mount setup in a sacrificial child +during preparation and reports the backend as unsupported when the host denies +it; it never falls back to weaker enforcement. Host administrators must make +the namespace capability available through their normal system security policy. + +System V shared memory, message queues, and semaphores are confined to the +private IPC namespace. Descendants remain able to create and share new IPC +objects inside the sandbox domain; host IPC objects are not visible. Live tests +create a host message queue before application and prove that its identifier is +unusable afterward. + +Kernel keyrings are not isolated by an IPC namespace. Sandy therefore replaces +the inherited session keyring with a fresh anonymous ring before hiding the host +filesystem, then permanently denies `add_key`, `request_key`, and `keyctl` with +seccomp. A live test proves a key inherited before application is unreadable +afterward. Key payloads already copied into process memory remain part of the +public current-process residual-risk contract. + +Read authority never includes execute authority. The private filesystem marks +non-executable mounts `noexec`; an executable nested below a readable subtree +receives its own executable overmount only when the host mount is executable. +Sandy never clears an inherited `noexec` attribute and rejects an executable +grant that would require doing so. Landlock separately mediates `execve`. +Read/write authority uses a fixed ABI 8 filesystem-mutation set so later kernel +rights do not silently alter the accepted policy. + +`BlockAll` uses a private network namespace and denies creation of new +addressable socket descriptors. Exact pathname Unix-socket authority is not in +the initial Linux contract and is rejected during deterministic planning. +Unnamed Unix `socketpair` IPC remains available because it cannot connect to a +host endpoint. Ring setup, entry, and registration are disabled under +`BlockAll` so asynchronous socket operations cannot bypass these controls. +Already-open ordinary descriptors remain caller-held capabilities, matching the +public current-process contract. + +`clone3` is rejected with `ENOSYS` so standard libraries can use their legacy +`clone` fallback. Legacy `clone` always rejects namespace flags. When subprocess +creation is disabled it additionally permits only thread-forming clones and +rejects `fork`, `vfork`, `execve`, and `execveat`. + +## Filesystem view + +The backend pins each source path before enforcement. After namespace entry it +reopens the canonical path and rejects replacement unless device, inode, and +object type still match the original pin. It clones mounts only from those +new-namespace descriptors and attaches them to descriptor-pinned targets in a +private tmpfs root. Non-granted siblings, host `/proc`, `/sys`, `/run`, and the +former host root are absent. Open descriptors and memory acquired before +`apply()` are not revoked. + +An exact protected file is overmounted read-only. Filesystem enforcement is +pathname-based. A protected regular file with a pre-existing hard-link alias is +rejected, and Sandy verifies that its link count does not change between +preparation and namespace entry. Recursive write protections are rejected: a +hard link outside a recursively read-only mount could otherwise mutate the same +inode through a writable pathname. + +The following combinations are rejected before enforcement rather than being +weakened: + +- a confidential deny nested below a visible subtree, because a mount mask + would still expose placeholder metadata; +- recursive write protections, because a hard-link alias outside the protected + subtree could retain write authority; +- exact grants on directories, because native directory rules are hierarchical; +- subtree grants on non-directories; +- exact read/write file grants, because content mutation and parent-directory + replacement cannot both be represented without broadening authority; +- exact directory write protections, because pinning a mount point prevents + replacement but not every metadata mutation; +- pathname Unix-socket exceptions; +- global metadata compatibility; +- local-host-only TCP exceptions. + +The private root never mounts procfs. Process entries, descriptor magic links, +and the host process tree remain absent. A foreground CLI launch recreates +`/proc/self/exe` as a static symlink to the already-visible, executable primary +target. This lets that target identify or re-execute its own image without +adding underlying file authority. Descendants see the primary target rather +than a dynamic per-process link. `/proc/self/fd`, `/dev/fd`, and `/dev/shm` +remain unavailable. + +When the internal foreground-CLI compatibility capability is present, the +backend recreates a bounded set of host runtime symlinks (`/bin`, `/sbin`, +loader directories, timezone data, and the public CA-bundle path) only when +their canonical targets are already visible through explicit grants. This adds +no underlying file authority; it preserves the runtime spelling selected by +the host distribution. + +The requested working directory must be visible through an explicit grant. +Sandy rejects the policy before enforcement rather than silently changing the +working directory to the private root. + +## Unsafe boundary + +All Linux unsafe code and raw native calls are confined to +`crates/linux/src/ffi.rs`. It exposes owned descriptors, lifetime-bound +`BorrowedFd` inputs, and safe functions. Raw pointers, native structures, +unvalidated descriptor integers, and unsafe functions do not escape that +module.