diff --git a/AGENTS.md b/AGENTS.md index edd028ee80..0a4a9218a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-conformance-cli/` | Conformance CLI | Distributable `list` and `run` entrypoint for gateway conformance | | `crates/openshell-server/` | Gateway server | Control-plane API, sandbox lifecycle, auth boundary | | `crates/openshell-sandbox/` | Sandbox runtime | Container supervision, policy-enforced egress routing | +| `crates/openshell-binary-identity/` | Binary identity | Shared trusted procfs executable identity resolution for isolation backends | | `crates/openshell-isolation-interface/` | Isolation backend interface | RFC 0012 `IsolationBackend` trait + types; the supervisor-facing runtime contract for the boundary | | `crates/openshell-policy/` | Policy engine | Filesystem, network, process, and inference constraints | | `crates/openshell-router/` | Privacy router | Privacy-aware LLM routing | diff --git a/Cargo.lock b/Cargo.lock index eb27f2b468..0f5bbfd419 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3753,6 +3753,14 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openshell-binary-identity" +version = "0.0.0" +dependencies = [ + "openshell-isolation-interface", + "sha2 0.10.9", +] + [[package]] name = "openshell-bootstrap" version = "0.0.0" @@ -4168,7 +4176,9 @@ name = "openshell-isolation-interface" version = "0.0.0" dependencies = [ "async-trait", + "libc", "openshell-core", + "rustix 1.1.4", "tokio", ] @@ -4466,6 +4476,7 @@ name = "openshell-supervisor-network" version = "0.0.0" dependencies = [ "apollo-parser", + "async-trait", "aws-credential-types", "aws-sigv4", "aws-smithy-runtime-api", @@ -4480,7 +4491,9 @@ dependencies = [ "ipnet", "libc", "miette", + "openshell-binary-identity", "openshell-core", + "openshell-isolation-interface", "openshell-ocsf", "openshell-policy", "openshell-router", @@ -4519,6 +4532,7 @@ name = "openshell-supervisor-process" version = "0.0.0" dependencies = [ "anyhow", + "async-trait", "base64 0.22.1", "bytes", "capctl", @@ -4529,6 +4543,7 @@ dependencies = [ "miette", "nix 0.29.0", "openshell-core", + "openshell-isolation-interface", "openshell-ocsf", "openshell-policy", "rand 0.10.2", diff --git a/crates/openshell-binary-identity/Cargo.toml b/crates/openshell-binary-identity/Cargo.toml new file mode 100644 index 0000000000..a8b8714be4 --- /dev/null +++ b/crates/openshell-binary-identity/Cargo.toml @@ -0,0 +1,18 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-binary-identity" +description = "Trusted executable identity resolution for OpenShell isolation backends" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +openshell-isolation-interface = { path = "../openshell-isolation-interface" } +sha2 = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-binary-identity/README.md b/crates/openshell-binary-identity/README.md new file mode 100644 index 0000000000..9f11201b7e --- /dev/null +++ b/crates/openshell-binary-identity/README.md @@ -0,0 +1,17 @@ +# Binary identity + +`openshell-binary-identity` provides shared executable-identity resolution for +RFC 0012 isolation backends. Runtime-specific observers remain in their backend: +Docker obtains an authoritative thread ID from seccomp notification, while the +co-located Linux path maps an accepted socket to its owning processes. + +Given an authoritative Linux PID and an optional trusted process-tree root, the +crate reads the executable path from procfs, hashes the live `/proc//exe` +object, and collects bounded executable ancestry and diagnostic command-line +paths. Resolution failures are returned as `ResolveError` so the caller can +deny the associated connection. + +The crate does not intercept connections, authenticate remote observers, or +evaluate policy. The isolation backend remains responsible for binding the +resolved identity to the active boundary and exact accepted connection before +constructing `MediatedConnection`. diff --git a/crates/openshell-binary-identity/src/lib.rs b/crates/openshell-binary-identity/src/lib.rs new file mode 100644 index 0000000000..ed11a0be5d --- /dev/null +++ b/crates/openshell-binary-identity/src/lib.rs @@ -0,0 +1,374 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared executable-identity resolution for RFC 0012 isolation backends. +//! +//! Runtime-specific observation remains inside each isolation backend. Once an +//! observer has an authoritative PID in its procfs view, this crate +//! canonicalizes the executable path, hashes the live executable object, and +//! collects its process ancestry. Backends bind the returned identity to the +//! intercepted connection before constructing a `MediatedConnection`. + +#[cfg(target_os = "linux")] +use openshell_isolation_interface::contract::Sha256Digest; +use openshell_isolation_interface::contract::{BinaryIdentity, ResolveError}; + +/// Resolves executable identity from a Linux procfs process identifier. +/// +/// The configured scope bounds ancestry and cmdline collection to the observed +/// PID namespace or a known workload process tree. +#[derive(Clone, Copy, Debug)] +pub struct ProcfsIdentityResolver { + ancestry_scope: AncestryScope, +} + +#[derive(Clone, Copy, Debug)] +enum AncestryScope { + PidNamespace, + ProcessTree(u32), +} + +impl Default for ProcfsIdentityResolver { + fn default() -> Self { + Self::for_pid_namespace() + } +} + +impl ProcfsIdentityResolver { + /// Build a resolver that discovers a nested PID namespace's init process + /// and never reports host-runtime ancestors outside that namespace. + #[must_use] + pub const fn for_pid_namespace() -> Self { + Self { + ancestry_scope: AncestryScope::PidNamespace, + } + } + + /// Build a resolver bounded by the workload's trusted process-tree root. + #[must_use] + pub const fn for_process_tree(ancestor_root: u32) -> Self { + Self { + ancestry_scope: AncestryScope::ProcessTree(ancestor_root), + } + } + + /// Resolve the identity for an authoritative process ID. + pub fn resolve(self, pid: u32) -> Result { + #[cfg(target_os = "linux")] + { + let ancestor_root = match self.ancestry_scope { + AncestryScope::PidNamespace => nested_pid_namespace_init(pid), + AncestryScope::ProcessTree(root) => Some(root), + }; + resolve_linux_process(pid, ancestor_root) + } + + #[cfg(not(target_os = "linux"))] + { + match self.ancestry_scope { + AncestryScope::PidNamespace => {} + AncestryScope::ProcessTree(ancestor_root) => { + let _ = ancestor_root; + } + } + let _ = pid; + Err(ResolveError::Failed( + "procfs binary identity is only available on Linux".to_string(), + )) + } + } +} + +#[cfg(target_os = "linux")] +fn resolve_linux_process( + pid: u32, + ancestor_root: Option, +) -> Result { + let (snapshot, mut executable) = open_process_snapshot(pid)?; + let binary_path = snapshot.binary_path.clone(); + let binary_digest = Some(hash_executable(pid, &mut executable)?); + let ancestor_processes = collect_ancestor_processes(&snapshot, ancestor_root); + let ancestors = ancestor_processes + .iter() + .map(|snapshot| snapshot.binary_path.clone()) + .collect::>(); + + let mut excluded_paths = ancestors.clone(); + excluded_paths.push(binary_path.clone()); + let cmdline_paths = cmdline_absolute_paths(&snapshot.cmdline) + .into_iter() + .chain( + ancestor_processes + .iter() + .flat_map(|snapshot| cmdline_absolute_paths(&snapshot.cmdline)), + ) + .filter(|path| !excluded_paths.contains(path)) + .fold(Vec::new(), |mut paths, path| { + if !paths.contains(&path) { + paths.push(path); + } + paths + }); + + validate_process_snapshot(pid, &snapshot)?; + for ancestor in &ancestor_processes { + validate_process_snapshot(ancestor.pid, ancestor)?; + } + + Ok(BinaryIdentity { + binary_path, + binary_digest, + ancestors, + cmdline_paths, + }) +} + +#[cfg(target_os = "linux")] +#[derive(Debug, PartialEq, Eq)] +struct ProcessSnapshot { + pid: u32, + parent_pid: u32, + binary_path: std::path::PathBuf, + executable_device: u64, + executable_inode: u64, + start_time: u64, + cmdline: Vec, +} + +#[cfg(target_os = "linux")] +fn open_process_snapshot(pid: u32) -> Result<(ProcessSnapshot, std::fs::File), ResolveError> { + use std::os::unix::fs::MetadataExt as _; + + let path = format!("/proc/{pid}/exe"); + let binary_path = executable_path(pid)?; + let executable = std::fs::File::open(&path) + .map_err(|error| ResolveError::Failed(format!("open {path}: {error}")))?; + let metadata = executable + .metadata() + .map_err(|error| ResolveError::Failed(format!("stat {path}: {error}")))?; + let (parent_pid, start_time) = process_stat(pid)?; + let snapshot = ProcessSnapshot { + pid, + parent_pid, + binary_path, + executable_device: metadata.dev(), + executable_inode: metadata.ino(), + start_time, + cmdline: read_process_cmdline(pid)?, + }; + validate_process_snapshot(pid, &snapshot)?; + Ok((snapshot, executable)) +} + +#[cfg(target_os = "linux")] +fn validate_process_snapshot(pid: u32, expected: &ProcessSnapshot) -> Result<(), ResolveError> { + use std::os::unix::fs::MetadataExt as _; + + let path = format!("/proc/{pid}/exe"); + let metadata = std::fs::metadata(&path) + .map_err(|error| ResolveError::Failed(format!("stat {path}: {error}")))?; + let (parent_pid, start_time) = process_stat(pid)?; + let current = ProcessSnapshot { + pid, + parent_pid, + binary_path: executable_path(pid)?, + executable_device: metadata.dev(), + executable_inode: metadata.ino(), + start_time, + cmdline: read_process_cmdline(pid)?, + }; + if ¤t == expected { + Ok(()) + } else { + Err(ResolveError::Failed(format!( + "process {pid} changed while its executable identity was collected" + ))) + } +} + +#[cfg(target_os = "linux")] +fn process_stat(pid: u32) -> Result<(u32, u64), ResolveError> { + let path = format!("/proc/{pid}/stat"); + let stat = std::fs::read_to_string(&path) + .map_err(|error| ResolveError::Failed(format!("read {path}: {error}")))?; + let fields = stat + .rsplit_once(") ") + .map(|(_, fields)| fields) + .ok_or_else(|| ResolveError::Failed(format!("parse {path}: missing command field")))?; + let mut fields = fields.split_whitespace(); + let _state = fields.next(); + let parent_pid = fields + .next() + .ok_or_else(|| ResolveError::Failed(format!("parse {path}: missing parent PID")))? + .parse() + .map_err(|error| ResolveError::Failed(format!("parse {path} parent PID: {error}")))?; + let start_time = fields + .nth(17) + .ok_or_else(|| ResolveError::Failed(format!("parse {path}: missing start time")))? + .parse() + .map_err(|error| ResolveError::Failed(format!("parse {path} start time: {error}")))?; + Ok((parent_pid, start_time)) +} + +#[cfg(target_os = "linux")] +fn read_process_cmdline(pid: u32) -> Result, ResolveError> { + let path = format!("/proc/{pid}/cmdline"); + std::fs::read(&path).map_err(|error| ResolveError::Failed(format!("read {path}: {error}"))) +} + +#[cfg(target_os = "linux")] +fn executable_path(pid: u32) -> Result { + use std::ffi::OsString; + use std::io::ErrorKind; + use std::os::unix::ffi::{OsStrExt as _, OsStringExt as _}; + + const DELETED_SUFFIX: &[u8] = b" (deleted)"; + + let link = format!("/proc/{pid}/exe"); + let target = std::fs::read_link(&link) + .map_err(|error| ResolveError::Failed(format!("read {link}: {error}")))?; + let target_missing = + matches!(std::fs::metadata(&target), Err(error) if error.kind() == ErrorKind::NotFound); + let bytes = target.as_os_str().as_bytes(); + + if target_missing && bytes.ends_with(DELETED_SUFFIX) { + let stripped = bytes[..bytes.len() - DELETED_SUFFIX.len()].to_vec(); + return Ok(std::path::PathBuf::from(OsString::from_vec(stripped))); + } + + Ok(target) +} + +#[cfg(target_os = "linux")] +fn hash_executable(pid: u32, executable: &mut std::fs::File) -> Result { + use sha2::{Digest as _, Sha256}; + use std::io::Read as _; + + let path = format!("/proc/{pid}/exe"); + let mut digest = Sha256::new(); + let mut buffer = [0_u8; 8 * 1024]; + loop { + let length = executable + .read(&mut buffer) + .map_err(|error| ResolveError::Failed(format!("hash {path}: {error}")))?; + if length == 0 { + break; + } + digest.update(&buffer[..length]); + } + format!("{:x}", digest.finalize()).parse() +} + +#[cfg(target_os = "linux")] +fn collect_ancestor_processes( + process: &ProcessSnapshot, + ancestor_root: Option, +) -> Vec { + const MAX_DEPTH: usize = 64; + + if ancestor_root == Some(process.pid) { + return Vec::new(); + } + + let mut ancestors = Vec::new(); + let mut parent = process.parent_pid; + for _ in 0..MAX_DEPTH { + if parent == 0 + || ancestors + .iter() + .any(|current: &ProcessSnapshot| current.pid == parent) + { + break; + } + + // PID 1 is host or guest init rather than workload ancestry unless it + // is the explicitly supplied process-tree root. + if parent == 1 && ancestor_root != Some(1) { + break; + } + + let Ok((snapshot, _executable)) = open_process_snapshot(parent) else { + break; + }; + let next_parent = snapshot.parent_pid; + ancestors.push(snapshot); + if ancestor_root == Some(parent) || parent == 1 { + break; + } + parent = next_parent; + } + ancestors +} + +#[cfg(target_os = "linux")] +fn parent_pid(pid: u32) -> Option { + std::fs::read_to_string(format!("/proc/{pid}/status")) + .ok()? + .lines() + .find_map(|line| line.strip_prefix("PPid:"))? + .trim() + .parse() + .ok() +} + +#[cfg(target_os = "linux")] +fn nested_pid_namespace_init(pid: u32) -> Option { + const MAX_DEPTH: usize = 64; + + let mut current = pid; + for _ in 0..MAX_DEPTH { + if namespace_pid(current) == Some(1) { + // Host PID 1 is outside every workload. A nested namespace init + // has a distinct host PID and is a valid workload ancestry root. + return (current != 1).then_some(current); + } + current = parent_pid(current).filter(|parent| *parent > 0 && *parent != current)?; + } + None +} + +#[cfg(target_os = "linux")] +fn namespace_pid(pid: u32) -> Option { + std::fs::read_to_string(format!("/proc/{pid}/status")) + .ok()? + .lines() + .find_map(|line| line.strip_prefix("NSpid:"))? + .split_whitespace() + .next_back()? + .parse() + .ok() +} + +#[cfg(target_os = "linux")] +fn cmdline_absolute_paths(cmdline: &[u8]) -> Vec { + cmdline + .split(|byte| *byte == 0) + .filter(|argument| argument.first() == Some(&b'/')) + .map(|argument| std::path::PathBuf::from(String::from_utf8_lossy(argument).into_owned())) + .collect() +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + + #[test] + fn resolves_current_process_from_live_executable() { + let identity = ProcfsIdentityResolver::for_pid_namespace() + .resolve(std::process::id()) + .expect("resolve current process"); + + assert!(identity.binary_path.is_absolute()); + assert!(identity.binary_digest.is_some()); + } + + #[test] + fn process_tree_root_does_not_escape_into_host_ancestry() { + let pid = std::process::id(); + let identity = ProcfsIdentityResolver::for_process_tree(pid) + .resolve(pid) + .expect("resolve process-tree root"); + + assert!(identity.ancestors.is_empty()); + } +} diff --git a/crates/openshell-isolation-interface/Cargo.toml b/crates/openshell-isolation-interface/Cargo.toml index 647f19dad4..2384732773 100644 --- a/crates/openshell-isolation-interface/Cargo.toml +++ b/crates/openshell-isolation-interface/Cargo.toml @@ -15,6 +15,10 @@ openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" tokio = { workspace = true } +[target.'cfg(target_os = "linux")'.dependencies] +libc = "0.2" +rustix = { workspace = true, features = ["fs", "process"] } + [dev-dependencies] tokio = { workspace = true } diff --git a/crates/openshell-isolation-interface/src/lib.rs b/crates/openshell-isolation-interface/src/lib.rs index d7f4e32183..d5dff7b06f 100644 --- a/crates/openshell-isolation-interface/src/lib.rs +++ b/crates/openshell-isolation-interface/src/lib.rs @@ -48,3 +48,7 @@ pub struct AgentSpec { } pub mod contract; + +/// Linux-only primitives shared by capability-free sandbox implementations. +#[cfg(target_os = "linux")] +pub mod linux; diff --git a/crates/openshell-isolation-interface/src/linux/child_seccomp.rs b/crates/openshell-isolation-interface/src/linux/child_seccomp.rs new file mode 100644 index 0000000000..21d5005e9d --- /dev/null +++ b/crates/openshell-isolation-interface/src/linux/child_seccomp.rs @@ -0,0 +1,514 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Prepared seccomp self-protection for same-UID workload children. +//! +//! The program is built before `fork` and installed by the workload launcher +//! after the child has inherited the network user-notification filter. It does +//! not allocate while installing and deliberately leaves mediated networking +//! syscalls alone so the older `USER_NOTIF` action can still win. + +#![allow(unsafe_code)] + +use std::io; + +const SECCOMP_SET_MODE_FILTER: libc::c_uint = 1; +const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000; +const SECCOMP_RET_ERRNO: u32 = 0x0005_0000; +const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; + +const BPF_LD_W_ABS: u16 = 0x20; +const BPF_JMP_JEQ_K: u16 = 0x15; +const BPF_JMP_JSET_K: u16 = 0x45; +const BPF_RET_K: u16 = 0x06; + +const SECCOMP_DATA_NR_OFFSET: u32 = 0; +const SECCOMP_DATA_ARCH_OFFSET: u32 = 4; +const SECCOMP_DATA_ARGS_OFFSET: u32 = 16; +#[cfg(target_arch = "x86_64")] +const X32_SYSCALL_BIT: u32 = 0x4000_0000; + +const CLOSE_RANGE_UNSHARE_FLAG: u32 = 1 << 1; +const F_SETOWN_COMMAND: u32 = 8; +const F_SETSIG_COMMAND: u32 = 10; +const F_SETOWN_EX_COMMAND: u32 = 15; +const FIOSETOWN_REQUEST: u32 = 0x8901; +const SIOCSPGRP_REQUEST: u32 = 0x8902; +const CLONE_NAMESPACE_FLAGS: u32 = (libc::CLONE_NEWCGROUP + | libc::CLONE_NEWIPC + | libc::CLONE_NEWNET + | libc::CLONE_NEWNS + | libc::CLONE_NEWPID + | libc::CLONE_NEWUSER + | libc::CLONE_NEWUTS) as u32; + +/// A prebuilt child filter that can be installed without heap allocation. +pub struct ChildHardeningProgram { + instructions: Vec, +} + +impl ChildHardeningProgram { + /// Install this filter on the calling thread only. + /// + /// The caller must invoke this from the post-fork child after all + /// sandbox-wide TSYNC work and the launcher's `NEW_LISTENER` filter. + pub fn install(&mut self) -> io::Result<()> { + set_no_new_privileges()?; + let len = u16::try_from(self.instructions.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "child seccomp filter is too large", + ) + })?; + let mut program = libc::sock_fprog { + len, + filter: self.instructions.as_mut_ptr(), + }; + // SAFETY: `program` references the prebuilt cBPF instruction vector + // for the complete syscall. No TSYNC flag is used. + let result = unsafe { + libc::syscall( + libc::SYS_seccomp, + SECCOMP_SET_MODE_FILTER, + 0, + std::ptr::addr_of_mut!(program), + ) + }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } + } + + /// Number of cBPF instructions, exposed for admission diagnostics. + #[must_use] + pub fn instruction_count(&self) -> usize { + self.instructions.len() + } +} + +/// Build the same-UID workload self-protection program before `fork`. +/// +/// `sandbox_tgid` is the sandbox PID as visible from its workload namespace. +/// The filter blocks all direct thread-targeting through `tkill`, and blocks +/// process-directed operations that name the trusted sandbox leader. Worker +/// threads share that TGID and are therefore covered by `tgkill` and the +/// process-level APIs. +pub fn prepare(sandbox_tgid: u32) -> io::Result { + if sandbox_tgid == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "sandbox TGID must be nonzero", + )); + } + + let mut instructions = vec![ + stmt(BPF_LD_W_ABS, SECCOMP_DATA_ARCH_OFFSET), + jump(BPF_JMP_JEQ_K, native_audit_arch(), 1, 0), + stmt(BPF_RET_K, SECCOMP_RET_KILL_PROCESS), + stmt(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET), + ]; + #[cfg(target_arch = "x86_64")] + instructions.extend([ + jump(BPF_JMP_JSET_K, X32_SYSCALL_BIT, 0, 1), + stmt(BPF_RET_K, SECCOMP_RET_KILL_PROCESS), + ]); + + for syscall in [ + libc::SYS_ptrace, + libc::SYS_process_vm_readv, + libc::SYS_process_vm_writev, + libc::SYS_pidfd_getfd, + libc::SYS_pidfd_send_signal, + libc::SYS_kcmp, + libc::SYS_process_madvise, + libc::SYS_process_mrelease, + libc::SYS_tkill, + libc::SYS_unshare, + libc::SYS_setns, + libc::SYS_mount, + libc::SYS_umount2, + libc::SYS_pivot_root, + libc::SYS_chroot, + libc::SYS_fsopen, + libc::SYS_fsconfig, + libc::SYS_fsmount, + libc::SYS_fspick, + libc::SYS_move_mount, + libc::SYS_open_tree, + libc::SYS_bpf, + libc::SYS_perf_event_open, + libc::SYS_userfaultfd, + libc::SYS_io_uring_setup, + libc::SYS_io_uring_enter, + libc::SYS_io_uring_register, + libc::SYS_capset, + libc::SYS_setuid, + libc::SYS_setgid, + libc::SYS_setreuid, + libc::SYS_setregid, + libc::SYS_setresuid, + libc::SYS_setresgid, + libc::SYS_setfsuid, + libc::SYS_setfsgid, + libc::SYS_setgroups, + libc::SYS_sethostname, + libc::SYS_setdomainname, + libc::SYS_setpriority, + libc::SYS_ioprio_set, + ] { + append_unconditional_deny(&mut instructions, syscall)?; + } + + // Modern launchers fall back from clone3 and pidfd_open only for ENOSYS. + // Returning EPERM here breaks otherwise portable process creation. The + // fallback paths remain constrained: namespace creation is denied from + // clone's scalar flags and no pidfd can be acquired. + append_unconditional_errno(&mut instructions, libc::SYS_clone3, libc::ENOSYS)?; + append_unconditional_errno(&mut instructions, libc::SYS_pidfd_open, libc::ENOSYS)?; + append_argument_masked_deny(&mut instructions, libc::SYS_clone, 0, CLONE_NAMESPACE_FLAGS)?; + + for (syscall, argument) in [ + (libc::SYS_kill, 0), + (libc::SYS_tgkill, 0), + (libc::SYS_rt_sigqueueinfo, 0), + (libc::SYS_rt_tgsigqueueinfo, 0), + ] { + append_argument_equal_deny(&mut instructions, syscall, argument, sandbox_tgid)?; + } + for syscall in [libc::SYS_kill, libc::SYS_rt_sigqueueinfo] { + // PID zero targets the caller's process group. Deny it even though + // OpenShell normally gives each workload a dedicated process group: + // an untrusted child can otherwise rejoin a trusted group first. + append_argument_equal_deny(&mut instructions, syscall, 0, 0)?; + } + // Negative PID arguments target process groups or every signalable + // process. The workload never needs that authority and must not be able + // to include the trusted sandbox workers in a broad signal operation. + append_argument_masked_deny(&mut instructions, libc::SYS_kill, 0, 1 << 31)?; + append_argument_masked_deny(&mut instructions, libc::SYS_rt_sigqueueinfo, 0, 1 << 31)?; + + for syscall in [ + libc::SYS_prlimit64, + libc::SYS_sched_setaffinity, + libc::SYS_sched_setparam, + libc::SYS_sched_setscheduler, + ] { + append_argument_nonzero_deny(&mut instructions, syscall, 0)?; + } + + // The ordinary workload filter is installed after this program and owns + // the final ban on further seccomp installation. Blocking it here would + // prevent the sandbox from completing the prepared filter stack. + append_argument_masked_deny( + &mut instructions, + libc::SYS_close_range, + 2, + CLOSE_RANGE_UNSHARE_FLAG, + )?; + + for command in [F_SETOWN_COMMAND, F_SETSIG_COMMAND, F_SETOWN_EX_COMMAND] { + append_argument_equal_deny(&mut instructions, libc::SYS_fcntl, 1, command)?; + } + for request in [FIOSETOWN_REQUEST, SIOCSPGRP_REQUEST] { + append_argument_equal_deny(&mut instructions, libc::SYS_ioctl, 1, request)?; + } + + instructions.push(stmt(BPF_RET_K, SECCOMP_RET_ALLOW)); + Ok(ChildHardeningProgram { instructions }) +} + +fn append_unconditional_deny( + instructions: &mut Vec, + syscall: i64, +) -> io::Result<()> { + let syscall = syscall_number(syscall)?; + instructions.extend([ + stmt(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET), + jump(BPF_JMP_JEQ_K, syscall, 0, 1), + errno(libc::EPERM), + ]); + Ok(()) +} + +fn append_unconditional_errno( + instructions: &mut Vec, + syscall: i64, + error: i32, +) -> io::Result<()> { + let syscall = syscall_number(syscall)?; + instructions.extend([ + stmt(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET), + jump(BPF_JMP_JEQ_K, syscall, 0, 1), + errno(error), + ]); + Ok(()) +} + +fn append_argument_equal_deny( + instructions: &mut Vec, + syscall: i64, + argument: u32, + value: u32, +) -> io::Result<()> { + let syscall = syscall_number(syscall)?; + instructions.extend([ + stmt(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET), + jump(BPF_JMP_JEQ_K, syscall, 0, 3), + stmt(BPF_LD_W_ABS, argument_word_offset(argument)), + jump(BPF_JMP_JEQ_K, value, 0, 1), + errno(libc::EPERM), + ]); + Ok(()) +} + +fn append_argument_nonzero_deny( + instructions: &mut Vec, + syscall: i64, + argument: u32, +) -> io::Result<()> { + let syscall = syscall_number(syscall)?; + instructions.extend([ + stmt(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET), + jump(BPF_JMP_JEQ_K, syscall, 0, 3), + stmt(BPF_LD_W_ABS, argument_word_offset(argument)), + jump(BPF_JMP_JEQ_K, 0, 1, 0), + errno(libc::EPERM), + ]); + Ok(()) +} + +fn append_argument_masked_deny( + instructions: &mut Vec, + syscall: i64, + argument: u32, + mask: u32, +) -> io::Result<()> { + let syscall = syscall_number(syscall)?; + instructions.extend([ + stmt(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET), + jump(BPF_JMP_JEQ_K, syscall, 0, 3), + stmt(BPF_LD_W_ABS, argument_word_offset(argument)), + jump(BPF_JMP_JSET_K, mask, 0, 1), + errno(libc::EPERM), + ]); + Ok(()) +} + +fn syscall_number(syscall: i64) -> io::Result { + u32::try_from(syscall) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "negative syscall number")) +} + +const fn argument_word_offset(argument: u32) -> u32 { + SECCOMP_DATA_ARGS_OFFSET + argument * 8 +} + +const fn errno(value: i32) -> libc::sock_filter { + stmt(BPF_RET_K, SECCOMP_RET_ERRNO | value.cast_unsigned()) +} + +#[cfg(target_arch = "x86_64")] +const fn native_audit_arch() -> u32 { + 0xc000_003e +} + +#[cfg(target_arch = "aarch64")] +const fn native_audit_arch() -> u32 { + 0xc000_00b7 +} + +const fn stmt(code: u16, value: u32) -> libc::sock_filter { + libc::sock_filter { + code, + jt: 0, + jf: 0, + k: value, + } +} + +const fn jump(code: u16, value: u32, jt: u8, jf: u8) -> libc::sock_filter { + libc::sock_filter { + code, + jt, + jf, + k: value, + } +} + +fn set_no_new_privileges() -> io::Result<()> { + // SAFETY: PR_SET_NO_NEW_PRIVS is a one-way scalar transition. + if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_zero_sandbox_tgid() { + assert_eq!( + prepare(0).err().expect("zero TGID must fail").kind(), + io::ErrorKind::InvalidInput + ); + } + + #[test] + fn filter_blocks_same_uid_sandbox_control() { + // SAFETY: the child uses only raw syscalls after fork and exits with + // `_exit`, so it does not run copied Rust cleanup state. + let child = unsafe { libc::fork() }; + assert!(child >= 0, "fork: {}", io::Error::last_os_error()); + if child == 0 { + let sandbox_tgid = unsafe { libc::getppid() }; + let Ok(mut filter) = prepare(u32::try_from(sandbox_tgid).unwrap_or(0)) else { + unsafe { libc::_exit(1) }; + }; + if filter.install().is_err() { + unsafe { libc::_exit(2) }; + } + let mut local = 0_u8; + let mut remote = 0_u8; + let local_iov = libc::iovec { + iov_base: std::ptr::addr_of_mut!(local).cast(), + iov_len: 1, + }; + let remote_iov = libc::iovec { + iov_base: std::ptr::addr_of_mut!(remote).cast(), + iov_len: 1, + }; + let process_vm = unsafe { + libc::process_vm_readv( + sandbox_tgid, + std::ptr::addr_of!(local_iov), + 1, + std::ptr::addr_of!(remote_iov), + 1, + 0, + ) + }; + if process_vm != -1 || io::Error::last_os_error().raw_os_error() != Some(libc::EPERM) { + unsafe { libc::_exit(3) }; + } + if unsafe { libc::kill(sandbox_tgid, 0) } != -1 + || io::Error::last_os_error().raw_os_error() != Some(libc::EPERM) + { + unsafe { libc::_exit(4) }; + } + if unsafe { libc::kill(0, 0) } != -1 + || io::Error::last_os_error().raw_os_error() != Some(libc::EPERM) + { + unsafe { libc::_exit(10) }; + } + let sandbox_group = -unsafe { libc::getpgrp() }; + if unsafe { libc::kill(sandbox_group, 0) } != -1 + || io::Error::last_os_error().raw_os_error() != Some(libc::EPERM) + { + unsafe { libc::_exit(7) }; + } + if unsafe { libc::syscall(libc::SYS_prlimit64, sandbox_tgid, libc::RLIMIT_CORE, 0, 0) } + != -1 + || io::Error::last_os_error().raw_os_error() != Some(libc::EPERM) + { + unsafe { libc::_exit(5) }; + } + if unsafe { libc::fcntl(libc::STDIN_FILENO, libc::F_SETOWN, sandbox_tgid) } != -1 + || io::Error::last_os_error().raw_os_error() != Some(libc::EPERM) + { + unsafe { libc::_exit(8) }; + } + let mut owner = sandbox_tgid; + if unsafe { + libc::ioctl( + libc::STDIN_FILENO, + libc::c_ulong::from(FIOSETOWN_REQUEST), + &raw mut owner, + ) + } != -1 + || io::Error::last_os_error().raw_os_error() != Some(libc::EPERM) + { + unsafe { libc::_exit(9) }; + } + unsafe { libc::_exit(0) }; + } + + let mut status = 0; + // SAFETY: `child` names our live direct child and status is writable. + assert_eq!(unsafe { libc::waitpid(child, &raw mut status, 0) }, child); + assert!(libc::WIFEXITED(status)); + assert_eq!(libc::WEXITSTATUS(status), 0); + } + + #[test] + fn filter_preserves_thread_and_process_creation() { + if std::env::var_os("OPENSHELL_CHILD_SECCOMP_CREATION_PROBE").is_some() { + let mut filter = prepare(std::process::id().saturating_add(1)) + .expect("prepare child hardening filter"); + filter.install().expect("install child hardening filter"); + + // A direct clone3 request must report ENOSYS so libc can use its + // established clone fallback. + let result = + unsafe { libc::syscall(libc::SYS_clone3, std::ptr::null::(), 0) }; + assert_eq!(result, -1); + assert_eq!( + io::Error::last_os_error().raw_os_error(), + Some(libc::ENOSYS) + ); + + // Process launchers such as uv also probe pidfd_open and require + // ENOSYS to select their non-pidfd fallback. + let result = unsafe { libc::syscall(libc::SYS_pidfd_open, libc::getpid(), 0) }; + assert_eq!(result, -1); + assert_eq!( + io::Error::last_os_error().raw_os_error(), + Some(libc::ENOSYS) + ); + + let joined = std::thread::spawn(|| 17_u8) + .join() + .expect("pthread-style child must start"); + assert_eq!(joined, 17); + assert!( + std::process::Command::new("/bin/true") + .status() + .expect("posix-spawn-style child must start") + .success() + ); + + let namespaced = unsafe { + libc::syscall( + libc::SYS_clone, + u64::from(CLONE_NAMESPACE_FLAGS & libc::CLONE_NEWUSER as u32) + | u64::from(libc::SIGCHLD as u32), + 0, + 0, + 0, + 0, + ) + }; + assert_eq!(namespaced, -1); + assert_eq!(io::Error::last_os_error().raw_os_error(), Some(libc::EPERM)); + return; + } + + let output = + std::process::Command::new(std::env::current_exe().expect("current test executable")) + .arg("--exact") + .arg("linux::child_seccomp::tests::filter_preserves_thread_and_process_creation") + .arg("--nocapture") + .env("OPENSHELL_CHILD_SECCOMP_CREATION_PROBE", "1") + .output() + .expect("run isolated child-hardening probe"); + assert!( + output.status.success(), + "isolated probe failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/crates/openshell-isolation-interface/src/linux/landlock.rs b/crates/openshell-isolation-interface/src/linux/landlock.rs new file mode 100644 index 0000000000..33c84bf2c4 --- /dev/null +++ b/crates/openshell-isolation-interface/src/linux/landlock.rs @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Race-resistant handles for an explicit Landlock root allow-list. + +use std::collections::BTreeSet; +use std::ffi::{OsStr, OsString}; +use std::io; +use std::os::fd::OwnedFd; +use std::os::unix::ffi::OsStrExt; +use std::path::Path; + +use rustix::fs::{AtFlags, Mode, OFlags, Stat, fstat, open, openat, statat}; + +/// One verified immediate child of the sandbox root. +pub struct RootEntryHandle { + name: OsString, + fd: OwnedFd, + stat: Stat, +} + +impl RootEntryHandle { + /// Immediate-root entry name. + #[must_use] + pub fn name(&self) -> &OsStr { + &self.name + } + + /// Open, no-follow handle suitable for a later Landlock `PathBeneath` rule. + #[must_use] + pub fn fd(&self) -> &OwnedFd { + &self.fd + } + + /// Device number captured when the entry was opened. + #[must_use] + pub fn device(&self) -> u64 { + self.stat.st_dev + } + + /// Inode number captured when the entry was opened. + #[must_use] + pub fn inode(&self) -> u64 { + self.stat.st_ino + } +} + +/// Open exactly the named root entries while proving that none is the private +/// sandbox hierarchy, a symlink, or a raced replacement. +/// +/// Unnamed root entries are deliberately not returned and therefore cannot be +/// admitted accidentally. The caller obtains the allow-list names from trusted +/// image/driver policy, not by blindly allowing everything present in `/`. +pub fn open_root_allowlist( + root: &Path, + allowed_names: &BTreeSet, + private_name: &OsStr, +) -> io::Result> { + validate_component(private_name)?; + if allowed_names.contains(private_name) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "private sandbox root cannot appear in the Landlock allow-list", + )); + } + + let root_fd = open( + root, + OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + let mut result = Vec::with_capacity(allowed_names.len()); + for name in allowed_names { + validate_component(name)?; + let before = statat(&root_fd, name, AtFlags::SYMLINK_NOFOLLOW)?; + if before.st_mode & libc::S_IFMT == libc::S_IFLNK { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Landlock root entry {} is a symlink", + Path::new(name).display() + ), + )); + } + let fd = openat( + &root_fd, + name, + OFlags::PATH | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::empty(), + )?; + let after = fstat(&fd)?; + if before.st_dev != after.st_dev + || before.st_ino != after.st_ino + || before.st_mode & libc::S_IFMT != after.st_mode & libc::S_IFMT + { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Landlock root entry {} changed while it was opened", + Path::new(name).display() + ), + )); + } + result.push(RootEntryHandle { + name: name.clone(), + fd, + stat: after, + }); + } + Ok(result) +} + +fn validate_component(name: &OsStr) -> io::Result<()> { + let bytes = name.as_bytes(); + if bytes.is_empty() || bytes == b"." || bytes == b".." || bytes.contains(&b'/') { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("invalid immediate-root entry {}", Path::new(name).display()), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::os::unix::fs::symlink; + use std::sync::atomic::{AtomicU64, Ordering}; + + use super::*; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + fn temp_root() -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "openshell-landlock-root-{}-{}", + std::process::id(), + NEXT_TEMP.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&path).expect("create temp root"); + path + } + + #[test] + fn opens_only_explicit_entries_and_omits_private_root() { + let root = temp_root(); + fs::create_dir(root.join("bin")).expect("create bin"); + fs::create_dir(root.join("sandbox")).expect("create workspace"); + fs::create_dir(root.join(".openshell")).expect("create private root"); + fs::create_dir(root.join("unexpected")).expect("create unexpected root"); + + let allowed = BTreeSet::from([OsString::from("bin"), OsString::from("sandbox")]); + let entries = open_root_allowlist(&root, &allowed, OsStr::new(".openshell")) + .expect("open allow-list"); + assert_eq!( + entries + .iter() + .map(|entry| entry.name().to_owned()) + .collect::>(), + vec![OsString::from("bin"), OsString::from("sandbox")] + ); + + fs::remove_dir_all(root).expect("remove temp root"); + } + + #[test] + fn rejects_private_entry_and_symlink() { + let root = temp_root(); + fs::create_dir(root.join(".openshell")).expect("create private root"); + symlink(".openshell", root.join("runtime")).expect("create symlink"); + + let private = BTreeSet::from([OsString::from(".openshell")]); + assert!(open_root_allowlist(&root, &private, OsStr::new(".openshell")).is_err()); + let symlinked = BTreeSet::from([OsString::from("runtime")]); + assert!(open_root_allowlist(&root, &symlinked, OsStr::new(".openshell")).is_err()); + + fs::remove_dir_all(root).expect("remove temp root"); + } +} diff --git a/crates/openshell-isolation-interface/src/linux/mod.rs b/crates/openshell-isolation-interface/src/linux/mod.rs new file mode 100644 index 0000000000..ceba35e1f1 --- /dev/null +++ b/crates/openshell-isolation-interface/src/linux/mod.rs @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Audited Linux primitives used by the capability-free sandbox. +//! +//! This module intentionally contains mechanisms, not sandbox orchestration. +//! The in-workload sandbox owns lifecycle, policy, and failure handling. + +pub mod child_seccomp; +pub mod landlock; +pub mod proc_fd; +pub mod seccomp_notify; +pub mod socket_registry; +pub mod task_memory; +pub mod workload_launcher; diff --git a/crates/openshell-isolation-interface/src/linux/proc_fd.rs b/crates/openshell-isolation-interface/src/linux/proc_fd.rs new file mode 100644 index 0000000000..8fee46b680 --- /dev/null +++ b/crates/openshell-isolation-interface/src/linux/proc_fd.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Strict `/proc//fd` socket identity helpers. + +#![allow(unsafe_code)] + +use std::fs; +use std::io; +use std::os::fd::RawFd; + +/// Return the socket inode currently installed at `fd` in `tid`'s descriptor +/// table. +/// +/// The result is only a snapshot. Callers must revalidate the seccomp +/// notification, task generation, and any retained socket cookie before a +/// state-changing operation. +pub fn socket_inode(tid: u32, fd: RawFd) -> io::Result { + if tid == 0 || fd < 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "TID must be nonzero and FD must be nonnegative", + )); + } + let target = fs::read_link(format!("/proc/{tid}/fd/{fd}"))?; + let target = target.to_str().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "procfs descriptor target is not UTF-8", + ) + })?; + let digits = target + .strip_prefix("socket:[") + .and_then(|value| value.strip_suffix(']')) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "procfs descriptor is not a socket", + ) + })?; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "procfs socket inode has an invalid representation", + )); + } + digits.parse::().map_err(|error| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("procfs socket inode does not fit u64: {error}"), + ) + }) +} + +#[cfg(test)] +mod tests { + use std::fs::File; + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; + + use super::*; + + #[test] + fn identifies_socket_and_rejects_regular_file() { + let mut pair = [-1; 2]; + // SAFETY: pair points to storage for exactly two returned descriptors. + let result = unsafe { + libc::socketpair( + libc::AF_UNIX, + libc::SOCK_STREAM | libc::SOCK_CLOEXEC, + 0, + pair.as_mut_ptr(), + ) + }; + assert_eq!(result, 0, "socketpair: {}", io::Error::last_os_error()); + // SAFETY: successful socketpair returned two independently owned FDs. + let left = unsafe { OwnedFd::from_raw_fd(pair[0]) }; + // SAFETY: successful socketpair returned two independently owned FDs. + let _right = unsafe { OwnedFd::from_raw_fd(pair[1]) }; + assert!(socket_inode(std::process::id(), left.as_raw_fd()).unwrap() > 0); + + let file = File::open("/dev/null").expect("open regular descriptor"); + assert_eq!( + socket_inode(std::process::id(), file.as_raw_fd()) + .expect_err("regular descriptor") + .kind(), + io::ErrorKind::InvalidInput + ); + } +} diff --git a/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs b/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs new file mode 100644 index 0000000000..8e20fd070b --- /dev/null +++ b/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs @@ -0,0 +1,965 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal, typed wrappers for Linux seccomp user notification. +//! +//! The wrappers validate notification IDs around every operation and keep raw +//! UAPI structures private. Production policy and queueing belong to the +//! sandbox crate; this module owns only the kernel ABI and active conformance +//! probe. + +#![allow(unsafe_code)] + +use std::io; +use std::mem::size_of; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +const SECCOMP_SET_MODE_FILTER: libc::c_uint = 1; +const SECCOMP_GET_NOTIF_SIZES: libc::c_uint = 3; +const SECCOMP_FILTER_FLAG_NEW_LISTENER: libc::c_ulong = 1 << 3; +const SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV: libc::c_ulong = 1 << 5; + +const SECCOMP_RET_KILL_PROCESS: u32 = 0x8000_0000; +const SECCOMP_RET_USER_NOTIF: u32 = 0x7fc0_0000; +const SECCOMP_RET_ALLOW: u32 = 0x7fff_0000; + +const BPF_LD_W_ABS: u16 = 0x20; +const BPF_JMP_JEQ_K: u16 = 0x15; +#[cfg(target_arch = "x86_64")] +const BPF_JMP_JSET_K: u16 = 0x45; +const BPF_ALU_AND_K: u16 = 0x54; +const BPF_RET_K: u16 = 0x06; + +const SECCOMP_DATA_NR_OFFSET: u32 = 0; +const SECCOMP_DATA_ARCH_OFFSET: u32 = 4; +const SECCOMP_DATA_ARGS_OFFSET: u32 = 16; +#[cfg(target_arch = "x86_64")] +const X32_SYSCALL_BIT: u32 = 0x4000_0000; + +const SECCOMP_ADDFD_FLAG_SEND: u32 = 1 << 1; +const SECCOMP_USER_NOTIF_FLAG_CONTINUE: u32 = 1; + +const CONNECTED_SEND_FLAGS: u32 = + (libc::MSG_DONTWAIT | libc::MSG_EOR | libc::MSG_MORE | libc::MSG_NOSIGNAL | libc::MSG_OOB) + as u32; +const PROBE_NOTIFICATION_TIMEOUT: Duration = Duration::from_secs(5); + +const IOC_NRBITS: u32 = 8; +const IOC_TYPEBITS: u32 = 8; +const IOC_SIZEBITS: u32 = 14; +const IOC_NRSHIFT: u32 = 0; +const IOC_TYPESHIFT: u32 = IOC_NRSHIFT + IOC_NRBITS; +const IOC_SIZESHIFT: u32 = IOC_TYPESHIFT + IOC_TYPEBITS; +const IOC_DIRSHIFT: u32 = IOC_SIZESHIFT + IOC_SIZEBITS; +const IOC_WRITE: u32 = 1; +const IOC_READ: u32 = 2; +const SECCOMP_IOC_MAGIC: u32 = b'!' as u32; + +#[allow(clippy::cast_possible_truncation)] +const fn ioc(direction: u32, number: u32, size: usize) -> libc::c_ulong { + ((direction << IOC_DIRSHIFT) + | (SECCOMP_IOC_MAGIC << IOC_TYPESHIFT) + | (number << IOC_NRSHIFT) + | ((size as u32) << IOC_SIZESHIFT)) as libc::c_ulong +} + +const fn iowr(number: u32) -> libc::c_ulong { + ioc(IOC_READ | IOC_WRITE, number, size_of::()) +} + +const fn iow(number: u32) -> libc::c_ulong { + ioc(IOC_WRITE, number, size_of::()) +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct SeccompData { + nr: i32, + arch: u32, + instruction_pointer: u64, + args: [u64; 6], +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct RawNotification { + id: u64, + pid: u32, + flags: u32, + data: SeccompData, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct RawResponse { + id: u64, + val: i64, + error: i32, + flags: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct RawAddFd { + id: u64, + flags: u32, + srcfd: u32, + newfd: u32, + newfd_flags: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default)] +struct RawNotificationSizes { + notification: u16, + response: u16, + data: u16, +} + +const SECCOMP_IOCTL_NOTIF_RECV: libc::c_ulong = iowr::(0); +const SECCOMP_IOCTL_NOTIF_SEND: libc::c_ulong = iowr::(1); +const SECCOMP_IOCTL_NOTIF_ID_VALID: libc::c_ulong = iow::(2); +const SECCOMP_IOCTL_NOTIF_ADDFD: libc::c_ulong = iow::(3); + +/// One validated seccomp user-notification request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Notification { + /// Kernel-unique notification identifier. + pub id: u64, + /// Notifying Linux thread ID. + pub tid: u32, + /// Native syscall number. + pub syscall: i32, + /// Raw syscall arguments. + pub args: [u64; 6], +} + +/// Result of exercising the unprivileged notification API under the active +/// kernel, outer seccomp profile, and LSM posture. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NotificationProbeReport { + /// Whether `SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV` was accepted. + pub wait_killable_recv: bool, + features: NotificationProbeFeatures, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct NotificationProbeFeatures(u8); + +impl NotificationProbeReport { + /// Whether ID validation and response delivery completed. + #[must_use] + pub fn notification_round_trip(self) -> bool { + self.features.0 & 1 != 0 + } + + /// Whether atomic ADDFD-SEND injected a close-on-exec descriptor. + #[must_use] + pub fn addfd_send(self) -> bool { + self.features.0 & 2 != 0 + } + + /// Whether process-VM read and write syscalls are admitted for same-process + /// memory, before the stronger child-credential probe runs in a driver. + #[must_use] + pub fn task_memory_copy(self) -> bool { + self.features.0 & 4 != 0 + } + + /// Whether connected null-destination `sendto` bypassed notification while + /// destination-bearing and unsafe-flag variants remained mediated. + #[must_use] + pub fn connected_send_fast_path(self) -> bool { + self.features.0 & 8 != 0 + } +} + +/// Owned listener returned by `SECCOMP_FILTER_FLAG_NEW_LISTENER`. +pub struct NotificationListener { + fd: OwnedFd, + wait_killable_recv: bool, +} + +impl NotificationListener { + /// Raw listener descriptor for readiness integration and diagnostics. + #[must_use] + pub fn as_raw_fd(&self) -> RawFd { + self.fd.as_raw_fd() + } + + /// Whether the listener was installed with killable receive waits. + #[must_use] + pub fn wait_killable_recv(&self) -> bool { + self.wait_killable_recv + } + + /// Receive the next kernel notification. + pub fn receive(&self) -> io::Result { + let mut raw = RawNotification::default(); + ioctl_ptr( + self.fd.as_raw_fd(), + SECCOMP_IOCTL_NOTIF_RECV, + std::ptr::addr_of_mut!(raw).cast(), + )?; + Ok(Notification { + id: raw.id, + tid: raw.pid, + syscall: raw.data.nr, + args: raw.data.args, + }) + } + + /// Verify that a notification still refers to a blocked live task. + pub fn validate_id(&self, id: u64) -> io::Result<()> { + let mut id = id; + ioctl_ptr( + self.fd.as_raw_fd(), + SECCOMP_IOCTL_NOTIF_ID_VALID, + std::ptr::addr_of_mut!(id).cast(), + )?; + Ok(()) + } + + /// Return a successful scalar result to the notifying syscall. + pub fn respond_value(&self, id: u64, value: i64) -> io::Result<()> { + self.validate_id(id)?; + let mut response = RawResponse { + id, + val: value, + error: 0, + flags: 0, + }; + ioctl_ptr( + self.fd.as_raw_fd(), + SECCOMP_IOCTL_NOTIF_SEND, + std::ptr::addr_of_mut!(response).cast(), + )?; + Ok(()) + } + + /// Return `errno` to the notifying syscall. + pub fn respond_errno(&self, id: u64, errno: i32) -> io::Result<()> { + if errno <= 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "seccomp response errno must be positive", + )); + } + self.validate_id(id)?; + let mut response = RawResponse { + id, + val: 0, + error: -errno, + flags: 0, + }; + ioctl_ptr( + self.fd.as_raw_fd(), + SECCOMP_IOCTL_NOTIF_SEND, + std::ptr::addr_of_mut!(response).cast(), + )?; + Ok(()) + } + + /// Continue a verified local-kernel operation in the notifying task. + /// + /// Callers must not use this for an external INET operation or where a + /// mutable workload pointer is part of the authorization decision. + pub fn respond_continue(&self, id: u64) -> io::Result<()> { + self.validate_id(id)?; + let mut response = RawResponse { + id, + val: 0, + error: 0, + flags: SECCOMP_USER_NOTIF_FLAG_CONTINUE, + }; + ioctl_ptr( + self.fd.as_raw_fd(), + SECCOMP_IOCTL_NOTIF_SEND, + std::ptr::addr_of_mut!(response).cast(), + )?; + Ok(()) + } + + /// Atomically inject `source` and complete the notifying syscall with the + /// allocated target FD. The target receives `O_CLOEXEC` when requested. + pub fn add_fd_and_send( + &self, + notification_id: u64, + source: RawFd, + close_on_exec: bool, + ) -> io::Result { + self.validate_id(notification_id)?; + let srcfd = u32::try_from(source) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "source FD is negative"))?; + let mut addfd = RawAddFd { + id: notification_id, + flags: SECCOMP_ADDFD_FLAG_SEND, + srcfd, + newfd: 0, + newfd_flags: if close_on_exec { + u32::try_from(libc::O_CLOEXEC).expect("O_CLOEXEC fits u32") + } else { + 0 + }, + }; + ioctl_ptr( + self.fd.as_raw_fd(), + SECCOMP_IOCTL_NOTIF_ADDFD, + std::ptr::addr_of_mut!(addfd).cast(), + ) + .and_then(|fd| { + RawFd::try_from(fd).map_err(|_| io::Error::other("injected FD does not fit RawFd")) + }) + } +} + +/// Install a non-TSYNC listener filter on the calling thread. +/// +/// Only the named syscalls notify. Unexpected architectures are killed, x32 +/// syscalls are killed on x86-64, and all other native syscalls are allowed. +pub fn install_listener(syscalls: &[i64]) -> io::Result { + if syscalls.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "at least one notified syscall is required", + )); + } + verify_notification_sizes()?; + set_no_new_privileges()?; + + match install_listener_with_flags(syscalls, true) { + Ok(listener) => Ok(listener), + Err(error) if error.raw_os_error() == Some(libc::EINVAL) => { + install_listener_with_flags(syscalls, false) + } + Err(error) => Err(error), + } +} + +/// Install the capability-free workload networking listener on the calling +/// launcher thread. +/// +/// The filter mediates every syscall that can create, select, or materially +/// reconfigure an INET endpoint. Connected `send()`/null-destination +/// `sendto()` retains the audited cBPF fast path. +pub fn install_workload_listener() -> io::Result { + install_listener(&[ + libc::SYS_socket, + libc::SYS_connect, + libc::SYS_bind, + libc::SYS_listen, + libc::SYS_accept, + libc::SYS_accept4, + libc::SYS_sendto, + libc::SYS_sendmsg, + libc::SYS_sendmmsg, + libc::SYS_getpeername, + libc::SYS_getsockname, + libc::SYS_setsockopt, + ]) +} + +/// Run a no-capability conformance probe. +/// +/// This uses the production launcher-thread shape: the listener is created on +/// one dedicated thread and moved to an unfiltered broker thread through an +/// in-process channel. +pub fn probe_notification_api() -> io::Result { + let wait_killable_recv = probe_scalar_round_trip()?; + probe_addfd_send()?; + probe_task_memory_copy()?; + probe_connected_sendto_fast_path()?; + Ok(NotificationProbeReport { + wait_killable_recv, + features: NotificationProbeFeatures(1 | 2 | 4 | 8), + }) +} + +fn probe_scalar_round_trip() -> io::Result { + const PROBE_VALUE: libc::c_long = 0x5a17; + let (sender, receiver) = mpsc::sync_channel(1); + let launcher = thread::spawn(move || -> io::Result { + let listener = install_listener(&[libc::SYS_getppid])?; + let wait_killable = listener.wait_killable_recv(); + sender + .send((listener, wait_killable)) + .map_err(|_| io::Error::other("notification broker disappeared"))?; + // SAFETY: getppid has no pointer arguments. The installed filter causes + // the kernel to block here until the broker validates and responds. + Ok(unsafe { libc::syscall(libc::SYS_getppid) }) + }); + + let (listener, wait_killable) = receiver + .recv() + .map_err(|_| io::Error::other("notification launcher disappeared"))?; + let notification = match receive_probe_notification(&listener) { + Ok(notification) => notification, + Err(error) => { + let _ = launcher.join(); + return Err(error); + } + }; + if i64::from(notification.syscall) != libc::SYS_getppid { + return Err(io::Error::other("unexpected scalar probe syscall")); + } + listener.respond_value(notification.id, PROBE_VALUE)?; + let observed = launcher + .join() + .map_err(|_| io::Error::other("notification launcher panicked"))??; + if observed != PROBE_VALUE { + return Err(io::Error::other("seccomp response value was not delivered")); + } + Ok(wait_killable) +} + +fn probe_addfd_send() -> io::Result<()> { + let (sender, receiver) = mpsc::sync_channel(1); + let launcher = thread::spawn(move || -> io::Result<()> { + let listener = install_listener(&[libc::SYS_socket])?; + sender + .send(listener) + .map_err(|_| io::Error::other("ADDFD broker disappeared"))?; + // SAFETY: arguments are scalar constants; the intercepted syscall is + // completed by ADDFD-SEND and returns the injected descriptor number. + let injected = unsafe { + libc::syscall( + libc::SYS_socket, + libc::AF_INET, + libc::SOCK_STREAM | libc::SOCK_CLOEXEC, + libc::IPPROTO_TCP, + ) + }; + if injected < 0 { + return Err(io::Error::last_os_error()); + } + let injected = RawFd::try_from(injected) + .map_err(|_| io::Error::other("injected descriptor does not fit RawFd"))?; + // SAFETY: ADDFD-SEND returned one newly owned descriptor to this task. + let injected = unsafe { OwnedFd::from_raw_fd(injected) }; + // SAFETY: `injected` was returned as an open descriptor by the kernel. + let descriptor_flags = unsafe { libc::fcntl(injected.as_raw_fd(), libc::F_GETFD) }; + if descriptor_flags < 0 { + return Err(io::Error::last_os_error()); + } + if descriptor_flags & libc::FD_CLOEXEC == 0 { + return Err(io::Error::other("ADDFD did not preserve close-on-exec")); + } + let mut value = 0_u64; + // SAFETY: eventfd reads exactly one u64 into a valid aligned pointer. + let read = unsafe { + libc::read( + injected.as_raw_fd(), + std::ptr::addr_of_mut!(value).cast(), + size_of::(), + ) + }; + let word_size = isize::try_from(size_of::()).expect("u64 size fits isize"); + if read != word_size || value != 7 { + return Err(io::Error::other("injected eventfd was not usable")); + } + Ok(()) + }); + + let listener = receiver + .recv() + .map_err(|_| io::Error::other("ADDFD launcher disappeared"))?; + let notification = match receive_probe_notification(&listener) { + Ok(notification) => notification, + Err(error) => { + let _ = launcher.join(); + return Err(error); + } + }; + if i64::from(notification.syscall) != libc::SYS_socket { + return Err(io::Error::other("unexpected ADDFD probe syscall")); + } + // SAFETY: eventfd has no pointer arguments and returns an owned descriptor. + let source = unsafe { libc::eventfd(7, libc::EFD_CLOEXEC) }; + if source < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: eventfd returned a new owned descriptor. + let source = unsafe { OwnedFd::from_raw_fd(source) }; + listener.add_fd_and_send(notification.id, source.as_raw_fd(), true)?; + launcher + .join() + .map_err(|_| io::Error::other("ADDFD launcher panicked"))??; + Ok(()) +} + +fn probe_task_memory_copy() -> io::Result<()> { + let source = 0x1122_3344_5566_7788_u64; + let mut copied = 0_u64; + let local = libc::iovec { + iov_base: std::ptr::addr_of_mut!(copied).cast(), + iov_len: size_of::(), + }; + let remote = libc::iovec { + iov_base: std::ptr::addr_of!(source).cast_mut().cast(), + iov_len: size_of::(), + }; + // SAFETY: both iovecs point to live same-process u64 values for the full + // call. This is an admission probe, not the cross-task production codec. + let read = unsafe { + libc::process_vm_readv( + libc::getpid(), + std::ptr::addr_of!(local), + 1, + std::ptr::addr_of!(remote), + 1, + 0, + ) + }; + let word_size = isize::try_from(size_of::()).expect("u64 size fits isize"); + if read != word_size || copied != source { + return Err(io::Error::last_os_error()); + } + + let replacement = 0xaabb_ccdd_eeff_0011_u64; + let local = libc::iovec { + iov_base: std::ptr::addr_of!(replacement).cast_mut().cast(), + iov_len: size_of::(), + }; + let remote = libc::iovec { + iov_base: std::ptr::addr_of_mut!(copied).cast(), + iov_len: size_of::(), + }; + // SAFETY: both iovecs point to live same-process u64 values for the full + // call. The write is bounded to the destination value. + let written = unsafe { + libc::process_vm_writev( + libc::getpid(), + std::ptr::addr_of!(local), + 1, + std::ptr::addr_of!(remote), + 1, + 0, + ) + }; + if written != word_size || copied != replacement { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +fn probe_connected_sendto_fast_path() -> io::Result<()> { + let mut pair = [-1; 2]; + // SAFETY: `pair` points to storage for exactly two returned descriptors. + let result = unsafe { + libc::socketpair( + libc::AF_UNIX, + libc::SOCK_STREAM | libc::SOCK_CLOEXEC, + 0, + pair.as_mut_ptr(), + ) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: successful socketpair returned two independently owned FDs. + let sender_fd = unsafe { OwnedFd::from_raw_fd(pair[0]) }; + // SAFETY: successful socketpair returned two independently owned FDs. + let receiver_fd = unsafe { OwnedFd::from_raw_fd(pair[1]) }; + + let (sender, receiver) = mpsc::sync_channel(1); + let launcher = thread::spawn(move || -> io::Result<()> { + let listener = install_listener(&[libc::SYS_sendto])?; + sender + .send(listener) + .map_err(|_| io::Error::other("sendto broker disappeared"))?; + + let direct = b"direct"; + // SAFETY: the buffer is live and a null destination on this connected + // socket is equivalent to send(). The filter must allow this call + // without a broker round trip. + let sent = unsafe { + libc::sendto( + sender_fd.as_raw_fd(), + direct.as_ptr().cast(), + direct.len(), + libc::MSG_NOSIGNAL, + std::ptr::null(), + 0, + ) + }; + if sent != isize::try_from(direct.len()).expect("probe length fits isize") { + return Err(io::Error::last_os_error()); + } + + let mut payload = [0_u8; 6]; + // SAFETY: the receive buffer is live for its full declared length. + let read = unsafe { + libc::read( + receiver_fd.as_raw_fd(), + payload.as_mut_ptr().cast(), + payload.len(), + ) + }; + if read != isize::try_from(payload.len()).expect("probe length fits isize") + || &payload != direct + { + return Err(io::Error::other( + "connected sendto fast path did not relay data", + )); + } + + let destination = libc::sockaddr_un { + sun_family: libc::sa_family_t::try_from(libc::AF_UNIX) + .expect("AF_UNIX fits sa_family_t"), + sun_path: [0; 108], + }; + // SAFETY: all pointers refer to live values. This deliberately + // destination-bearing call must be denied by the broker. + let result = unsafe { + libc::sendto( + sender_fd.as_raw_fd(), + direct.as_ptr().cast(), + direct.len(), + 0, + std::ptr::addr_of!(destination).cast(), + libc::socklen_t::try_from(size_of::()) + .expect("sockaddr family size fits socklen_t"), + ) + }; + if result != -1 || io::Error::last_os_error().raw_os_error() != Some(libc::EACCES) { + return Err(io::Error::other( + "destination-bearing sendto bypassed notification", + )); + } + + // A null destination with Fast Open must not use the connected-send + // fast path either. + // SAFETY: the live buffer and null address form a valid syscall; the + // broker supplies the expected denial. + let result = unsafe { + libc::sendto( + sender_fd.as_raw_fd(), + direct.as_ptr().cast(), + direct.len(), + libc::MSG_FASTOPEN, + std::ptr::null(), + 0, + ) + }; + if result != -1 || io::Error::last_os_error().raw_os_error() != Some(libc::EOPNOTSUPP) { + return Err(io::Error::other( + "MSG_FASTOPEN sendto bypassed notification", + )); + } + Ok(()) + }); + + let listener = receiver + .recv() + .map_err(|_| io::Error::other("sendto launcher disappeared"))?; + let destination = match receive_probe_notification(&listener) { + Ok(notification) => notification, + Err(error) => { + let _ = launcher.join(); + return Err(error); + } + }; + if i64::from(destination.syscall) != libc::SYS_sendto + || destination.args[4] == 0 + || destination.args[5] == 0 + { + return Err(io::Error::other( + "destination-bearing sendto notification was malformed", + )); + } + listener.respond_errno(destination.id, libc::EACCES)?; + + let fast_open = match receive_probe_notification(&listener) { + Ok(notification) => notification, + Err(error) => { + let _ = launcher.join(); + return Err(error); + } + }; + if i64::from(fast_open.syscall) != libc::SYS_sendto + || fast_open.args[4] != 0 + || fast_open.args[5] != 0 + || fast_open.args[3] & u64::from(libc::MSG_FASTOPEN as u32) == 0 + { + return Err(io::Error::other( + "Fast Open sendto notification was malformed", + )); + } + listener.respond_errno(fast_open.id, libc::EOPNOTSUPP)?; + + launcher + .join() + .map_err(|_| io::Error::other("sendto launcher panicked"))??; + Ok(()) +} + +fn receive_probe_notification(listener: &NotificationListener) -> io::Result { + let mut descriptor = libc::pollfd { + fd: listener.as_raw_fd(), + events: libc::POLLIN | libc::POLLHUP, + revents: 0, + }; + let timeout = i32::try_from(PROBE_NOTIFICATION_TIMEOUT.as_millis()) + .expect("probe timeout fits poll milliseconds"); + // SAFETY: descriptor points to one live pollfd for the duration of poll. + let ready = unsafe { libc::poll(&raw mut descriptor, 1, timeout) }; + if ready < 0 { + return Err(io::Error::last_os_error()); + } + if ready == 0 { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "seccomp notification probe timed out", + )); + } + if descriptor.revents & libc::POLLIN == 0 { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "seccomp notification probe listener closed", + )); + } + listener.receive() +} + +fn install_listener_with_flags( + syscalls: &[i64], + wait_killable_recv: bool, +) -> io::Result { + let mut program = build_filter(syscalls)?; + let length = u16::try_from(program.len()) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "seccomp filter is too large"))?; + let mut fprog = libc::sock_fprog { + len: length, + filter: program.as_mut_ptr(), + }; + let flags = SECCOMP_FILTER_FLAG_NEW_LISTENER + | if wait_killable_recv { + SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV + } else { + 0 + }; + // SAFETY: `fprog` points to a live classic-BPF program for the duration of + // the syscall. The returned nonnegative value is a newly owned FD. + let result = unsafe { + libc::syscall( + libc::SYS_seccomp, + SECCOMP_SET_MODE_FILTER, + flags, + std::ptr::addr_of_mut!(fprog), + ) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + let fd = RawFd::try_from(result) + .map_err(|_| io::Error::other("seccomp listener FD does not fit RawFd"))?; + // SAFETY: successful NEW_LISTENER returns one newly owned descriptor. + let fd = unsafe { OwnedFd::from_raw_fd(fd) }; + Ok(NotificationListener { + fd, + wait_killable_recv, + }) +} + +fn build_filter(syscalls: &[i64]) -> io::Result> { + let mut program = vec![ + stmt(BPF_LD_W_ABS, SECCOMP_DATA_ARCH_OFFSET), + jump(BPF_JMP_JEQ_K, native_audit_arch(), 1, 0), + stmt(BPF_RET_K, SECCOMP_RET_KILL_PROCESS), + stmt(BPF_LD_W_ABS, SECCOMP_DATA_NR_OFFSET), + ]; + + #[cfg(target_arch = "x86_64")] + program.extend([ + jump(BPF_JMP_JSET_K, X32_SYSCALL_BIT, 0, 1), + stmt(BPF_RET_K, SECCOMP_RET_KILL_PROCESS), + ]); + + let mut syscalls = syscalls.to_vec(); + syscalls.sort_unstable(); + syscalls.dedup(); + for syscall in syscalls { + if syscall == libc::SYS_sendto { + append_sendto_filter(&mut program)?; + continue; + } + let syscall = u32::try_from(syscall) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "negative syscall number"))?; + program.extend([ + jump(BPF_JMP_JEQ_K, syscall, 0, 1), + stmt(BPF_RET_K, SECCOMP_RET_USER_NOTIF), + ]); + } + program.push(stmt(BPF_RET_K, SECCOMP_RET_ALLOW)); + Ok(program) +} + +fn append_sendto_filter(program: &mut Vec) -> io::Result<()> { + const SPECIAL_LENGTH: u8 = 20; + let syscall = u32::try_from(libc::SYS_sendto) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "negative sendto syscall"))?; + program.push(jump(BPF_JMP_JEQ_K, syscall, 0, SPECIAL_LENGTH)); + + for offset in [ + argument_word_offset(4, 0), + argument_word_offset(4, 1), + argument_word_offset(5, 0), + argument_word_offset(5, 1), + argument_word_offset(3, 1), + ] { + program.extend([ + stmt(BPF_LD_W_ABS, offset), + jump(BPF_JMP_JEQ_K, 0, 1, 0), + stmt(BPF_RET_K, SECCOMP_RET_USER_NOTIF), + ]); + } + program.extend([ + stmt(BPF_LD_W_ABS, argument_word_offset(3, 0)), + stmt(BPF_ALU_AND_K, !CONNECTED_SEND_FLAGS), + jump(BPF_JMP_JEQ_K, 0, 1, 0), + stmt(BPF_RET_K, SECCOMP_RET_USER_NOTIF), + stmt(BPF_RET_K, SECCOMP_RET_ALLOW), + ]); + Ok(()) +} + +const fn argument_word_offset(argument: u32, word: u32) -> u32 { + SECCOMP_DATA_ARGS_OFFSET + argument * 8 + word * 4 +} + +#[cfg(target_arch = "x86_64")] +const fn native_audit_arch() -> u32 { + 0xc000_003e +} + +#[cfg(target_arch = "aarch64")] +const fn native_audit_arch() -> u32 { + 0xc000_00b7 +} + +const fn stmt(code: u16, value: u32) -> libc::sock_filter { + libc::sock_filter { + code, + jt: 0, + jf: 0, + k: value, + } +} + +const fn jump(code: u16, value: u32, jt: u8, jf: u8) -> libc::sock_filter { + libc::sock_filter { + code, + jt, + jf, + k: value, + } +} + +fn set_no_new_privileges() -> io::Result<()> { + // SAFETY: PR_SET_NO_NEW_PRIVS accepts scalar arguments and only tightens + // the calling thread's privilege behavior. + let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +fn verify_notification_sizes() -> io::Result<()> { + let mut sizes = RawNotificationSizes::default(); + // SAFETY: the kernel writes only the fixed-size `RawNotificationSizes` + // object supplied here. + let result = unsafe { + libc::syscall( + libc::SYS_seccomp, + SECCOMP_GET_NOTIF_SIZES, + 0, + std::ptr::addr_of_mut!(sizes), + ) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + for (name, kernel, local) in [ + ( + "notification", + usize::from(sizes.notification), + size_of::(), + ), + ( + "response", + usize::from(sizes.response), + size_of::(), + ), + ("data", usize::from(sizes.data), size_of::()), + ] { + if kernel != local { + return Err(io::Error::new( + io::ErrorKind::Unsupported, + format!("kernel seccomp {name} size {kernel} differs from supported size {local}"), + )); + } + } + Ok(()) +} + +fn ioctl_ptr(fd: RawFd, request: libc::c_ulong, argument: *mut libc::c_void) -> io::Result { + // SAFETY: every caller supplies the UAPI structure encoded into `request`, + // alive and writable for the ioctl duration. + // `libc::ioctl` models the request as `c_ulong` for glibc and `c_int` + // for musl. Linux UAPI request values fit both representations. + #[cfg(target_env = "musl")] + let request = u32::try_from(request).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidInput, "ioctl request exceeds 32 bits") + })?; + #[cfg(target_env = "musl")] + let request = libc::c_int::from_ne_bytes(request.to_ne_bytes()); + let result = unsafe { libc::ioctl(fd, request, argument) }; + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(i64::from(result)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filter_rejects_empty_syscall_set() { + let error = install_listener(&[]).err().expect("empty filter must fail"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + } + + #[test] + fn active_notification_probe_passes() { + let report = probe_notification_api().expect("active notification probe"); + assert!(report.notification_round_trip()); + assert!(report.addfd_send()); + assert!(report.task_memory_copy()); + assert!(report.connected_send_fast_path()); + } + + #[test] + fn errno_response_rejects_nonpositive_values() { + // The input validation occurs before the listener FD is used. + // SAFETY: dup takes one valid descriptor and returns a new descriptor + // or a negative error without modifying memory. + let duplicated = unsafe { libc::dup(libc::STDERR_FILENO) }; + assert!(duplicated >= 0, "duplicate stderr for validation test"); + let listener = NotificationListener { + // SAFETY: successful dup returned a new owned descriptor. + fd: unsafe { OwnedFd::from_raw_fd(duplicated) }, + wait_killable_recv: false, + }; + let error = listener + .respond_errno(1, 0) + .expect_err("zero errno must fail"); + assert_eq!(error.kind(), io::ErrorKind::InvalidInput); + } +} diff --git a/crates/openshell-isolation-interface/src/linux/socket_registry.rs b/crates/openshell-isolation-interface/src/linux/socket_registry.rs new file mode 100644 index 0000000000..c5a39d2ce5 --- /dev/null +++ b/crates/openshell-isolation-interface/src/linux/socket_registry.rs @@ -0,0 +1,412 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded registry for socket-time seccomp virtualization. + +#![allow(unsafe_code)] + +use std::collections::BTreeMap; +use std::io; +use std::mem::size_of; +use std::net::SocketAddr; +use std::os::fd::{AsRawFd, BorrowedFd, OwnedFd, RawFd}; + +use rustix::fs::fstat; + +use super::proc_fd; + +/// Stable identity for one mediated socket within a listener generation. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct SocketIdentity { + /// Generation of the seccomp listener that created the socket. + pub listener_generation: u64, + /// Socket inode observed from the source descriptor. + pub inode: u64, + /// Kernel `SO_COOKIE` value. + pub cookie: u64, +} + +/// Supported INET address family. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InetFamily { + /// `AF_INET`. + V4, + /// `AF_INET6`. + V6, +} + +/// Supported INET socket kind and protocol. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InetKind { + /// TCP stream socket. + Tcp, + /// UDP datagram socket restricted to the DNS relay. + DnsUdp, +} + +/// Immutable socket metadata captured before ADDFD-SEND. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SocketMetadata { + /// Address family. + pub family: InetFamily, + /// Socket kind/protocol. + pub kind: InetKind, + /// Whether the injected descriptor must be close-on-exec. + pub close_on_exec: bool, + /// Whether the socket's open-file description is nonblocking. + pub nonblocking: bool, + /// Task generation that created the socket. + pub creator_generation: u64, +} + +/// Stable state of one socket open-file description. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum SocketState { + /// Created but not bound or connected. + Created, + /// Explicitly bound by the workload. + Bound { local: SocketAddr }, + /// Connected through an external supervisor relay. + Connected { original_peer: SocketAddr }, + /// Connected directly to an allowed workload loopback endpoint. + Local { peer: SocketAddr }, + /// UDP socket pinned to the exact local DNS relay. + DnsUdp { relay: SocketAddr }, + /// TCP socket pinned to the exact local DNS relay. + DnsTcp { relay: SocketAddr }, + /// Workload-owned listening socket. + Listening { local: SocketAddr }, + /// Stream accepted from a verified local peer. + AcceptedLocal { peer: SocketAddr }, + /// A committed relay failed after connection. + Failed { errno: i32 }, +} + +/// One committed registry entry. +#[derive(Debug)] +pub struct SocketEntry { + identity: SocketIdentity, + metadata: SocketMetadata, + state: SocketState, + retained_preconnect: Option, +} + +impl SocketEntry { + /// Stable socket identity. + #[must_use] + pub fn identity(&self) -> SocketIdentity { + self.identity + } + + /// Immutable creation metadata. + #[must_use] + pub fn metadata(&self) -> SocketMetadata { + self.metadata + } + + /// Current stable state. + #[must_use] + pub fn state(&self) -> &SocketState { + &self.state + } + + /// Retained source descriptor used to perform pre-connect operations on + /// the exact injected open-file description. + pub fn retained_preconnect(&self) -> io::Result<&OwnedFd> { + self.retained_preconnect.as_ref().ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotConnected, + "socket no longer has a retained pre-connect descriptor", + ) + }) + } + + /// Replace the stable state. Callers perform policy and notification + /// revalidation before invoking this commit primitive. + pub fn set_state(&mut self, state: SocketState) { + self.state = state; + } + + /// Close the temporary source descriptor after a connection commits. + pub fn release_preconnect(&mut self) { + self.retained_preconnect = None; + } + + /// Verify that the retained source still has the registered cookie and + /// inode. + pub fn validate_retained_identity(&self) -> io::Result<()> { + let retained = self.retained_preconnect()?; + let identity = socket_identity(retained.as_raw_fd(), self.identity.listener_generation)?; + if identity == self.identity { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + "retained socket identity changed", + )) + } + } +} + +/// Tentative socket metadata that is invisible until ADDFD-SEND succeeds. +#[derive(Debug)] +pub struct TentativeSocket { + identity: SocketIdentity, + metadata: SocketMetadata, + source: OwnedFd, +} + +impl TentativeSocket { + /// Stable identity used to correlate the ADDFD transaction. + #[must_use] + pub fn identity(&self) -> SocketIdentity { + self.identity + } + + /// Source descriptor passed to ADDFD-SEND. + #[must_use] + pub fn source_fd(&self) -> RawFd { + self.source.as_raw_fd() + } +} + +/// Bounded committed socket registry. +pub struct SocketRegistry { + listener_generation: u64, + capacity: usize, + entries: BTreeMap, +} + +impl SocketRegistry { + /// Create an empty registry for one nonzero listener generation. + pub fn new(listener_generation: u64, capacity: usize) -> io::Result { + if listener_generation == 0 || capacity == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "listener generation and registry capacity must be nonzero", + )); + } + Ok(Self { + listener_generation, + capacity, + entries: BTreeMap::new(), + }) + } + + /// Number of committed entries. + #[must_use] + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Whether no sockets are committed. + #[must_use] + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Stage a newly created source descriptor without publishing it. + pub fn stage(&self, source: OwnedFd, metadata: SocketMetadata) -> io::Result { + if self.entries.len() >= self.capacity { + return Err(io::Error::from_raw_os_error(libc::EMFILE)); + } + let identity = socket_identity(source.as_raw_fd(), self.listener_generation)?; + if self.entries.contains_key(&identity.inode) { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "socket inode is already registered", + )); + } + Ok(TentativeSocket { + identity, + metadata, + source, + }) + } + + /// Publish a tentative socket only after ADDFD-SEND has succeeded. + pub fn commit(&mut self, tentative: TentativeSocket) -> io::Result { + if self.entries.len() >= self.capacity { + return Err(io::Error::from_raw_os_error(libc::EMFILE)); + } + if tentative.identity.listener_generation != self.listener_generation + || self.entries.contains_key(&tentative.identity.inode) + { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "socket identity cannot be committed to this registry", + )); + } + let identity = tentative.identity; + self.entries.insert( + identity.inode, + SocketEntry { + identity, + metadata: tentative.metadata, + state: SocketState::Created, + retained_preconnect: Some(tentative.source), + }, + ); + Ok(identity) + } + + /// Resolve a notifying task's installed descriptor to a committed entry. + pub fn resolve(&self, tid: u32, fd: RawFd) -> io::Result<&SocketEntry> { + let inode = proc_fd::socket_inode(tid, fd)?; + let entry = self.entries.get(&inode).ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "socket inode is not registered for this sandbox", + ) + })?; + if entry.retained_preconnect.is_some() { + entry.validate_retained_identity()?; + } + Ok(entry) + } + + /// Mutable form of [`Self::resolve`]. + pub fn resolve_mut(&mut self, tid: u32, fd: RawFd) -> io::Result<&mut SocketEntry> { + let inode = proc_fd::socket_inode(tid, fd)?; + let entry = self.entries.get_mut(&inode).ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "socket inode is not registered for this sandbox", + ) + })?; + if entry.retained_preconnect.is_some() { + entry.validate_retained_identity()?; + } + Ok(entry) + } + + /// Remove metadata after descendant-FD collection proves no installed + /// alias remains. + pub fn remove_inode(&mut self, inode: u64) -> bool { + self.entries.remove(&inode).is_some() + } +} + +fn socket_identity(fd: RawFd, listener_generation: u64) -> io::Result { + // SAFETY: `fd` remains open for this function; the borrow never escapes. + let borrowed = unsafe { BorrowedFd::borrow_raw(fd) }; + let stat = fstat(borrowed)?; + if stat.st_mode & libc::S_IFMT != libc::S_IFSOCK { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "registry source descriptor is not a socket", + )); + } + let mut cookie = 0_u64; + let mut length = + libc::socklen_t::try_from(size_of::()).expect("SO_COOKIE length fits socklen_t"); + // SAFETY: getsockopt writes at most the supplied u64 and socklen_t. + let result = unsafe { + libc::getsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_COOKIE, + std::ptr::addr_of_mut!(cookie).cast(), + std::ptr::addr_of_mut!(length), + ) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + if usize::try_from(length).ok() != Some(size_of::()) || cookie == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "kernel returned an invalid SO_COOKIE", + )); + } + Ok(SocketIdentity { + listener_generation, + inode: stat.st_ino, + cookie, + }) +} + +#[cfg(test)] +mod tests { + use std::os::fd::FromRawFd; + + use super::*; + + fn tcp_socket() -> OwnedFd { + // SAFETY: socket returns one newly owned descriptor on success. + let fd = unsafe { + libc::socket( + libc::AF_INET, + libc::SOCK_STREAM | libc::SOCK_CLOEXEC, + libc::IPPROTO_TCP, + ) + }; + assert!(fd >= 0, "socket: {}", io::Error::last_os_error()); + // SAFETY: successful socket returned one owned descriptor. + unsafe { OwnedFd::from_raw_fd(fd) } + } + + fn metadata() -> SocketMetadata { + SocketMetadata { + family: InetFamily::V4, + kind: InetKind::Tcp, + close_on_exec: true, + nonblocking: false, + creator_generation: 11, + } + } + + #[test] + fn tentative_entry_is_invisible_until_commit() { + let mut registry = SocketRegistry::new(7, 1).unwrap(); + let socket = tcp_socket(); + let fd = socket.as_raw_fd(); + let tentative = registry.stage(socket, metadata()).unwrap(); + assert!(registry.is_empty()); + assert_eq!( + registry + .resolve(std::process::id(), fd) + .expect_err("tentative socket must be invisible") + .kind(), + io::ErrorKind::PermissionDenied + ); + + let identity = registry.commit(tentative).unwrap(); + let entry = registry.resolve(std::process::id(), fd).unwrap(); + assert_eq!(entry.identity(), identity); + assert_eq!(entry.metadata(), metadata()); + assert_eq!(entry.state(), &SocketState::Created); + assert_eq!(registry.len(), 1); + + assert_eq!( + registry + .stage(tcp_socket(), metadata()) + .expect_err("quota must fail before injection") + .raw_os_error(), + Some(libc::EMFILE) + ); + } + + #[test] + fn dup_alias_resolves_to_same_open_file_description() { + let mut registry = SocketRegistry::new(9, 4).unwrap(); + let socket = tcp_socket(); + let original_fd = socket.as_raw_fd(); + // SAFETY: dup returns a new descriptor for the same open-file + // description or a negative error. + let alias_fd = unsafe { libc::dup(original_fd) }; + assert!(alias_fd >= 0, "dup: {}", io::Error::last_os_error()); + // SAFETY: successful dup returned one owned descriptor. + let alias = unsafe { OwnedFd::from_raw_fd(alias_fd) }; + + let tentative = registry.stage(socket, metadata()).unwrap(); + let identity = registry.commit(tentative).unwrap(); + assert_eq!( + registry + .resolve(std::process::id(), alias.as_raw_fd()) + .unwrap() + .identity(), + identity + ); + } +} diff --git a/crates/openshell-isolation-interface/src/linux/task_memory.rs b/crates/openshell-isolation-interface/src/linux/task_memory.rs new file mode 100644 index 0000000000..00c5ab921f --- /dev/null +++ b/crates/openshell-isolation-interface/src/linux/task_memory.rs @@ -0,0 +1,214 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Bounded, exact access to a notifying task's memory. +//! +//! Seccomp user-notification arguments contain addresses in the notifying +//! task. Callers must copy pointer-bearing inputs once into trusted memory and +//! must never treat a partial copy as valid. + +#![allow(unsafe_code)] + +use std::io; + +/// Maximum number of task-memory bytes copied by one operation. +pub const MAX_TASK_MEMORY_COPY: usize = 64 * 1024; + +/// Read exactly `destination.len()` bytes from `address` in `tid`. +/// +/// Empty and oversized requests, null addresses, and partial reads fail +/// closed. The caller must still revalidate the notification and task +/// generation after the copy. +pub fn read_exact(tid: u32, address: u64, destination: &mut [u8]) -> io::Result<()> { + validate_request(tid, address, destination.len())?; + let pid = libc::pid_t::try_from(tid) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "TID does not fit pid_t"))?; + let remote_address = usize::try_from(address).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "remote address does not fit usize", + ) + })?; + let local = libc::iovec { + iov_base: destination.as_mut_ptr().cast(), + iov_len: destination.len(), + }; + let remote = libc::iovec { + iov_base: remote_address as *mut libc::c_void, + iov_len: destination.len(), + }; + + // SAFETY: the local iovec spans the caller-provided live buffer. The + // remote address is untrusted but bounded; the kernel validates it in the + // target process and returns EFAULT or a short count when unavailable. + let copied = retry_eintr(|| unsafe { + libc::process_vm_readv( + pid, + std::ptr::addr_of!(local), + 1, + std::ptr::addr_of!(remote), + 1, + 0, + ) + })?; + require_exact(copied, destination.len(), "task-memory read") +} + +/// Write exactly all of `source` to `address` in `tid`. +/// +/// This is used only for syscall outputs such as `getpeername` and +/// `sendmmsg.msg_len`. Revalidate the notification, task generation, and +/// destination layout immediately before calling it. +pub fn write_exact(tid: u32, address: u64, source: &[u8]) -> io::Result<()> { + validate_request(tid, address, source.len())?; + let pid = libc::pid_t::try_from(tid) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "TID does not fit pid_t"))?; + let remote_address = usize::try_from(address).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "remote address does not fit usize", + ) + })?; + let local = libc::iovec { + iov_base: source.as_ptr().cast_mut().cast(), + iov_len: source.len(), + }; + let remote = libc::iovec { + iov_base: remote_address as *mut libc::c_void, + iov_len: source.len(), + }; + + // SAFETY: the local iovec spans the caller-provided live buffer. The + // remote address is untrusted but bounded; the kernel validates that it is + // writable in the target process. + let copied = retry_eintr(|| unsafe { + libc::process_vm_writev( + pid, + std::ptr::addr_of!(local), + 1, + std::ptr::addr_of!(remote), + 1, + 0, + ) + })?; + require_exact(copied, source.len(), "task-memory write") +} + +fn validate_request(tid: u32, address: u64, length: usize) -> io::Result<()> { + if tid == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "task-memory TID must be nonzero", + )); + } + if address == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "task-memory address must be nonzero", + )); + } + if length == 0 || length > MAX_TASK_MEMORY_COPY { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("task-memory copy length must be between 1 and {MAX_TASK_MEMORY_COPY} bytes"), + )); + } + let start = usize::try_from(address) + .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "remote address is too large"))?; + start.checked_add(length - 1).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "task-memory address range overflows", + ) + })?; + Ok(()) +} + +fn retry_eintr(mut operation: impl FnMut() -> isize) -> io::Result { + loop { + let result = operation(); + if result >= 0 { + return usize::try_from(result) + .map_err(|_| io::Error::other("task-memory result does not fit usize")); + } + let error = io::Error::last_os_error(); + if error.kind() != io::ErrorKind::Interrupted { + return Err(error); + } + } +} + +fn require_exact(copied: usize, expected: usize, operation: &str) -> io::Result<()> { + if copied == expected { + Ok(()) + } else { + Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("{operation} was partial: copied {copied} of {expected} bytes"), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_and_writes_exact_same_process_memory() { + let source = 0x1122_3344_5566_7788_u64; + let mut destination = 0_u64; + let mut bytes = [0_u8; size_of::()]; + + read_exact( + std::process::id(), + std::ptr::addr_of!(source) as u64, + &mut bytes, + ) + .expect("read source"); + assert_eq!(u64::from_ne_bytes(bytes), source); + + let replacement = 0xaabb_ccdd_eeff_0011_u64; + write_exact( + std::process::id(), + std::ptr::addr_of_mut!(destination) as u64, + &replacement.to_ne_bytes(), + ) + .expect("write destination"); + assert_eq!(destination, replacement); + } + + #[test] + fn rejects_invalid_ranges_before_syscall() { + let mut byte = [0_u8; 1]; + assert_eq!( + read_exact(0, 1, &mut byte).expect_err("zero TID").kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + read_exact(std::process::id(), 0, &mut byte) + .expect_err("null address") + .kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + read_exact(std::process::id(), 1, &mut []) + .expect_err("empty copy") + .kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + validate_request(std::process::id(), 1, MAX_TASK_MEMORY_COPY + 1) + .expect_err("oversized copy") + .kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + validate_request(std::process::id(), u64::MAX, 2) + .expect_err("overflowing range") + .kind(), + io::ErrorKind::InvalidInput + ); + } + + use std::mem::size_of; +} diff --git a/crates/openshell-isolation-interface/src/linux/workload_launcher.rs b/crates/openshell-isolation-interface/src/linux/workload_launcher.rs new file mode 100644 index 0000000000..9197cf28f0 --- /dev/null +++ b/crates/openshell-isolation-interface/src/linux/workload_launcher.rs @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! One-listener launch thread for capability-free workload descendants. +//! +//! Seccomp filters are per-thread. This launcher installs the networking +//! listener without TSYNC, then serializes every fork/exec operation on that +//! thread. Children inherit the filter while the sandbox's broker and +//! lifecycle threads remain unfiltered. The listener moves to the caller over +//! an in-process channel; no descriptor handoff syscall or reusable exception +//! is needed. + +use std::io; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc; +use std::thread; + +use super::seccomp_notify::{NotificationListener, install_workload_listener}; + +type LaunchJob = Box; + +/// Serialized child-launch executor whose thread owns the inherited listener +/// filter. +#[derive(Clone)] +pub struct WorkloadLauncher { + jobs: mpsc::SyncSender, + alive: Arc, +} + +impl WorkloadLauncher { + /// Execute one prebuilt spawn operation on the filtered launcher thread. + /// + /// The closure must only perform audited launch work. It must not open an + /// INET socket itself: the launcher is trusted and deliberately has no + /// notification broker. + pub fn execute( + &self, + operation: impl FnOnce() -> T + Send + 'static, + ) -> io::Result { + if !self.alive.load(Ordering::Acquire) { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "workload launcher is not running", + )); + } + let (result_tx, result_rx) = mpsc::sync_channel(1); + self.jobs + .send(Box::new(move || { + let _ = result_tx.send(operation()); + })) + .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "workload launcher stopped"))?; + result_rx.recv().map_err(|_| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "workload launcher dropped the spawn result", + ) + }) + } + + /// Whether the launch thread is still able to accept work. + #[must_use] + pub fn is_alive(&self) -> bool { + self.alive.load(Ordering::Acquire) + } +} + +/// Start the only workload launcher and return its listener to an unfiltered +/// sandbox thread. +pub fn start() -> io::Result<(WorkloadLauncher, NotificationListener)> { + let (jobs_tx, jobs_rx) = mpsc::sync_channel::(64); + let (ready_tx, ready_rx) = mpsc::sync_channel(1); + let alive = Arc::new(AtomicBool::new(true)); + let thread_alive = alive.clone(); + thread::Builder::new() + .name("openshell-workload-launcher".to_string()) + .spawn(move || { + match install_workload_listener() { + Ok(listener) => { + if ready_tx.send(Ok(listener)).is_err() { + thread_alive.store(false, Ordering::Release); + return; + } + } + Err(error) => { + let _ = ready_tx.send(Err(io::Error::new( + error.kind(), + format!("install workload listener: {error}"), + ))); + thread_alive.store(false, Ordering::Release); + return; + } + } + while let Ok(job) = jobs_rx.recv() { + job(); + } + thread_alive.store(false, Ordering::Release); + }) + .map_err(|error| io::Error::other(format!("start workload launcher thread: {error}")))?; + + let listener = ready_rx.recv().map_err(|_| { + io::Error::new( + io::ErrorKind::BrokenPipe, + "workload launcher exited before publishing its listener", + ) + })??; + Ok(( + WorkloadLauncher { + jobs: jobs_tx, + alive, + }, + listener, + )) +} + +#[cfg(test)] +#[allow(unsafe_code)] +mod tests { + use std::mem::size_of; + use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd}; + + use super::*; + + #[test] + fn one_listener_mediates_launcher_and_inherited_child() { + let (launcher, listener) = start().expect("start launcher"); + let executable = std::env::current_exe().expect("test executable"); + let mut child = launcher + .execute(move || { + let mut command = std::process::Command::new(executable); + command + .arg("--exact") + .arg("linux::workload_launcher::tests::inherited_listener_child") + .arg("--nocapture") + .env("OPENSHELL_WORKLOAD_LAUNCHER_CHILD", "1"); + command.spawn() + }) + .expect("launcher result") + .expect("spawn child"); + let notification = listener.receive().expect("receive child socket"); + assert_eq!(i64::from(notification.syscall), libc::SYS_socket); + assert!( + std::path::Path::new(&format!("/proc/{}/task/{}", child.id(), notification.tid)) + .exists() + ); + // SAFETY: eventfd returns one newly owned descriptor on success. + let eventfd = unsafe { libc::eventfd(7, libc::EFD_CLOEXEC) }; + assert!(eventfd >= 0, "eventfd: {}", io::Error::last_os_error()); + // SAFETY: successful eventfd returned one owned descriptor. + let eventfd = unsafe { OwnedFd::from_raw_fd(eventfd) }; + listener + .add_fd_and_send(notification.id, eventfd.as_raw_fd(), true) + .expect("inject child descriptor"); + assert!(child.wait().expect("wait child").success()); + assert!(launcher.is_alive()); + assert!(listener.as_raw_fd() >= 0); + } + + #[test] + fn inherited_listener_child() { + if std::env::var_os("OPENSHELL_WORKLOAD_LAUNCHER_CHILD").is_none() { + return; + } + // SAFETY: the inherited listener intercepts this scalar socket call + // and returns the descriptor injected by the parent test. + let descriptor = unsafe { + libc::socket( + libc::AF_INET, + libc::SOCK_STREAM | libc::SOCK_CLOEXEC, + libc::IPPROTO_TCP, + ) + }; + assert!(descriptor >= 0, "socket: {}", io::Error::last_os_error()); + let mut value = 0_u64; + // SAFETY: the broker injected an eventfd and `value` is live storage. + let read = unsafe { + libc::read( + descriptor, + std::ptr::addr_of_mut!(value).cast(), + size_of::(), + ) + }; + // SAFETY: descriptor is owned by this process. + unsafe { libc::close(descriptor) }; + assert_eq!( + read, + isize::try_from(size_of::()).expect("u64 size fits") + ); + assert_eq!(value, 7); + } +} diff --git a/crates/openshell-router/src/lib.rs b/crates/openshell-router/src/lib.rs index 79bbfe6ca3..c52239f63b 100644 --- a/crates/openshell-router/src/lib.rs +++ b/crates/openshell-router/src/lib.rs @@ -37,8 +37,20 @@ pub struct Router { impl Router { pub fn new() -> Result { - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(30)) + Self::with_dns_overrides(std::iter::empty::<(&str, std::net::IpAddr)>()) + } + + /// Build a router with trusted, static DNS overrides for its upstream + /// HTTP client. URL hostnames remain unchanged for HTTP and TLS; only the + /// dial address is replaced. + pub fn with_dns_overrides<'a>( + overrides: impl IntoIterator, + ) -> Result { + let mut builder = reqwest::Client::builder().connect_timeout(Duration::from_secs(30)); + for (host, ip) in overrides { + builder = builder.resolve(host, std::net::SocketAddr::new(ip, 0)); + } + let client = builder .build() .map_err(|e| RouterError::Internal(format!("failed to build HTTP client: {e}")))?; Ok(Self { @@ -186,4 +198,40 @@ mod tests { let err = Router::from_config(&config).unwrap_err(); assert!(matches!(err, RouterError::Internal(_))); } + + #[tokio::test] + async fn trusted_dns_override_preserves_url_host_and_port() { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test server"); + let port = listener.local_addr().expect("server address").port(); + let server = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.expect("accept request"); + let mut request = vec![0_u8; 1024]; + let length = stream.read(&mut request).await.expect("read request"); + assert!( + String::from_utf8_lossy(&request[..length]) + .to_ascii_lowercase() + .contains(&format!("host: host.openshell.internal:{port}")) + ); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await + .expect("write response"); + }); + + let router = + Router::with_dns_overrides([("host.openshell.internal", "127.0.0.1".parse().unwrap())]) + .expect("build router"); + let response = router + .client + .get(format!("http://host.openshell.internal:{port}/health")) + .send() + .await + .expect("request through DNS override"); + assert_eq!(response.status(), reqwest::StatusCode::OK); + server.await.expect("server task"); + } } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 7afae200b5..845a594207 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -554,6 +554,7 @@ pub async fn run_sandbox( agent_proposals.clone(), workspace_rx.clone(), &upstream_proxy_args, + None, #[cfg(target_os = "linux")] transparent_runtime, ) diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 34d9c32a47..e5701ab63d 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -11,12 +11,16 @@ repository.workspace = true rust-version.workspace = true [dependencies] +openshell-binary-identity = { path = "../openshell-binary-identity" } openshell-core = { path = "../openshell-core", features = ["oauth"] } +openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-router = { path = "../openshell-router" } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } +async-trait = "0.1" + apollo-parser = { workspace = true } aws-sigv4 = { version = "1", features = ["sign-http", "http1"] } aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } diff --git a/crates/openshell-supervisor-network/src/identity_source.rs b/crates/openshell-supervisor-network/src/identity_source.rs new file mode 100644 index 0000000000..f5446ddbda --- /dev/null +++ b/crates/openshell-supervisor-network/src/identity_source.rs @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The in-pod binary-identity resolver (RFC 0012 runtime contract). +//! +//! RFC 0012 delivers executable identity on every +//! [`MediatedConnection`](openshell_isolation_interface::contract::MediatedConnection): +//! the backend resolves identity for the accepted connection before mediation. +//! An unresolved identity denies that connection. This is the in-pod +//! resolution mechanism — procfs, keyed by the workload-side TCP peer port — +//! kept in this crate on purpose: the proxy that consumes identity is here, and +//! so are procfs and the binary identity cache. Stronger backends may use a +//! different resolution mechanism without changing the contract. The result +//! type lives in the lower `openshell-isolation-interface` crate (network -> +//! interface -> core, acyclic). +//! +//! The legacy listener still resolves identity in the proxy hot path. The RFC +//! 0012 co-located source invokes this resolver before returning each accepted +//! connection, so mediation consumes the bound identity result. + +use std::sync::Arc; +use std::sync::atomic::AtomicU32; + +#[cfg(target_os = "linux")] +use openshell_binary_identity::ProcfsIdentityResolver as SharedProcfsIdentityResolver; +use openshell_isolation_interface::contract::{BinaryIdentity, ResolveError}; + +/// In-pod binary-identity resolver: reads and hashes the executable resolved +/// for an accepted connection from procfs. Resolution fails closed; it never +/// fabricates identity fields. +#[derive(Clone)] +pub struct ProcfsIdentityResolver { + /// The workload entrypoint PID, whose network namespace owns the peer + /// sockets the proxy resolves. Published once the agent starts. + pub entrypoint_pid: Arc, +} + +impl ProcfsIdentityResolver { + /// Resolve the executable identity behind an accepted workload connection. + pub fn resolve_connection( + &self, + workload_addr: std::net::SocketAddr, + proxy_addr: std::net::SocketAddr, + ) -> Result { + // procfs resolution is Linux-only; on other targets the supervisor has + // no procfs to read, so resolution fails closed. + #[cfg(target_os = "linux")] + { + self.resolve_via_procfs(workload_addr, proxy_addr) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (workload_addr, proxy_addr); + Err(ResolveError::Failed( + "no procfs on this platform; identity resolution unavailable".to_string(), + )) + } + } +} + +#[cfg(target_os = "linux")] +impl ProcfsIdentityResolver { + fn resolve_via_procfs( + &self, + workload_addr: std::net::SocketAddr, + proxy_addr: std::net::SocketAddr, + ) -> Result { + use std::sync::atomic::Ordering; + + let entrypoint_pid = self.entrypoint_pid.load(Ordering::Acquire); + if entrypoint_pid == 0 { + // No workload yet: nothing to attribute the connection to. Fail + // closed so a binary-scoped rule cannot match an unattributed peer. + return Err(ResolveError::NotFound); + } + + let connection = crate::procfs::WorkloadProxyTcpConnection::new(workload_addr, proxy_addr); + let owners = crate::procfs::resolve_tcp_peer_socket_owners(entrypoint_pid, connection) + .map_err(|_| ResolveError::NotFound)?; + let resolver = SharedProcfsIdentityResolver::for_process_tree(entrypoint_pid); + let mut identities = Vec::with_capacity(owners.owners.len()); + for owner in owners.owners { + identities.push(resolver.resolve(owner.pid)?); + } + let Some(identity) = identities.first().cloned() else { + return Err(ResolveError::NotFound); + }; + if identities.iter().skip(1).any(|candidate| { + candidate.binary_path != identity.binary_path + || candidate.binary_digest != identity.binary_digest + || candidate.ancestors != identity.ancestors + || candidate.cmdline_paths != identity.cmdline_paths + }) { + return Err(ResolveError::Failed( + "shared socket owners have different policy identities".to_string(), + )); + } + Ok(identity) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Stands in for the mediation service: a binary-scoped rule can only be + /// authorized by a resolved identity carrying the fields it requires. + fn admits_binary_rule(result: Result) -> bool { + matches!(result, Ok(identity) if identity.binary_digest.is_some()) + } + + #[test] + fn fails_closed_before_the_workload_starts() { + // entrypoint_pid == 0 means no agent yet; identity must fail closed so a + // binary-scoped rule cannot be satisfied by an unattributed connection. + let resolver = ProcfsIdentityResolver { + entrypoint_pid: Arc::new(AtomicU32::new(0)), + }; + assert!(!admits_binary_rule(resolver.resolve_connection( + "127.0.0.1:12345".parse().unwrap(), + "127.0.0.1:3128".parse().unwrap(), + ))); + } + + #[test] + fn unknown_peer_fails_closed() { + // A peer port no live workload connection owns must resolve to an error, + // never a fabricated identity. + let resolver = ProcfsIdentityResolver { + entrypoint_pid: Arc::new(AtomicU32::new(u32::MAX - 1)), + }; + assert!(!admits_binary_rule(resolver.resolve_connection( + "127.0.0.1:1".parse().unwrap(), + "127.0.0.1:3128".parse().unwrap(), + ))); + } +} diff --git a/crates/openshell-supervisor-network/src/inference_routes.rs b/crates/openshell-supervisor-network/src/inference_routes.rs index 22b406b8dd..90a24aa0e3 100644 --- a/crates/openshell-supervisor-network/src/inference_routes.rs +++ b/crates/openshell-supervisor-network/src/inference_routes.rs @@ -106,6 +106,22 @@ pub async fn build_inference_context( sandbox_id: Option<&str>, openshell_endpoint: Option<&str>, inference_routes: Option<&str>, +) -> Result>> { + build_inference_context_with_host_gateway( + sandbox_id, + openshell_endpoint, + inference_routes, + None, + ) + .await +} + +#[allow(clippy::similar_names)] +pub async fn build_inference_context_with_host_gateway( + sandbox_id: Option<&str>, + openshell_endpoint: Option<&str>, + inference_routes: Option<&str>, + host_gateway_ip: Option, ) -> Result>> { use openshell_router::Router; use openshell_router::config::RouterConfig; @@ -250,13 +266,18 @@ pub async fn build_inference_context( // Partition routes by name into user-facing and system caches. let (user_routes, system_routes) = partition_routes(routes); - let router = - Router::new().map_err(|e| miette::miette!("failed to initialize inference router: {e}"))?; + let inference_router = Router::with_dns_overrides(host_gateway_ip.into_iter().flat_map(|ip| { + crate::proxy::HOST_GATEWAY_ALIASES + .iter() + .copied() + .map(move |host| (host, ip)) + })) + .map_err(|e| miette::miette!("failed to initialize inference router: {e}"))?; let patterns = crate::l7::inference::default_patterns(); let ctx = Arc::new(crate::proxy::InferenceContext::new( patterns, - router, + inference_router, user_routes, system_routes, )); diff --git a/crates/openshell-supervisor-network/src/l7/tls.rs b/crates/openshell-supervisor-network/src/l7/tls.rs index 2275a60d34..d3def44743 100644 --- a/crates/openshell-supervisor-network/src/l7/tls.rs +++ b/crates/openshell-supervisor-network/src/l7/tls.rs @@ -17,7 +17,6 @@ use std::io::BufReader; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use tokio::io::{AsyncRead, AsyncWrite}; -use tokio::net::TcpStream; use tokio_rustls::{TlsAcceptor, TlsConnector}; const MAX_CACHED_CERTS: usize = 256; @@ -170,11 +169,14 @@ impl ProxyTlsState { /// Accept TLS from a sandbox client, presenting a dynamic cert for the hostname. /// /// Returns a TLS stream that can be used for plaintext HTTP inspection. -pub async fn tls_terminate_client( - client: TcpStream, +pub async fn tls_terminate_client( + client: S, tls_state: &ProxyTlsState, hostname: &str, -) -> Result { +) -> Result +where + S: AsyncRead + AsyncWrite + Unpin + Send, +{ let acceptor = tls_state.acceptor_for(hostname)?; let tls_stream = acceptor.accept(client).await.into_diagnostic()?; Ok(tls_stream) diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 4fec48b300..a828f75fba 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -9,6 +9,7 @@ //! aggregate them. pub mod identity; +pub mod identity_source; pub mod inference_routes; pub mod l7; pub mod opa; diff --git a/crates/openshell-supervisor-network/src/policy_dns/mod.rs b/crates/openshell-supervisor-network/src/policy_dns/mod.rs index b7dd13ca9a..60cea680a5 100644 --- a/crates/openshell-supervisor-network/src/policy_dns/mod.rs +++ b/crates/openshell-supervisor-network/src/policy_dns/mod.rs @@ -285,6 +285,7 @@ fn eligible_endpoints( let destination_plan = build_validation_plan( name.as_str(), name.as_str(), + None, trusted_host_gateway, &raw_allowed_ips, exact_declared_host, diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 177d640fd8..d7a6c697a3 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -97,7 +97,7 @@ fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { /// machine. Traffic to these names is eligible for the trusted-gateway SSRF /// exemption when the resolved IP matches the driver-injected value read from /// `/etc/hosts` at proxy startup. -const HOST_GATEWAY_ALIASES: &[&str] = &[ +pub(crate) const HOST_GATEWAY_ALIASES: &[&str] = &[ "host.openshell.internal", "host.containers.internal", "host.docker.internal", @@ -255,6 +255,7 @@ impl ProxyHandle { activity_tx: Option, engine_ready: tokio::sync::watch::Receiver, upstream_proxy_args: &upstream_proxy::UpstreamProxyArgs, + backend_host_gateway: Option, ) -> Result { // Use override bind_addr, fall back to policy http_addr, then default // to loopback:3128. The default allows the proxy to function when no @@ -287,6 +288,7 @@ impl ProxyHandle { // runs. This is read once at startup so later /etc/hosts modifications // by sandbox workloads cannot influence the stored value. let trusted_host_gateway: Arc> = Arc::new(detect_trusted_host_gateway()); + let backend_host_gateway = Arc::new(backend_host_gateway); if let Some(ref ip) = *trusted_host_gateway { tracing::info!( %ip, @@ -384,6 +386,7 @@ impl ProxyHandle { let policy_local = policy_local_ctx.clone(); let proposals = agent_proposals.clone(); let gw = trusted_host_gateway.clone(); + let backend_gw = backend_host_gateway.clone(); let up_proxy = upstream_proxy.clone(); let credentials = provider_credentials.clone(); let resolver = provider_credentials @@ -407,6 +410,7 @@ impl ProxyHandle { inf, policy_local, proposals, + backend_gw, gw, up_proxy, credentials, @@ -1691,6 +1695,7 @@ async fn handle_tcp_connection( inference_ctx: Option>, policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, + backend_host_gateway: Arc>, trusted_host_gateway: Arc>, upstream_proxy: Arc>, provider_credentials: Option, @@ -1762,6 +1767,7 @@ async fn handle_tcp_connection( entrypoint_pid, policy_local_ctx, agent_proposals, + backend_host_gateway, trusted_host_gateway, provider_credentials, secret_resolver, @@ -1935,7 +1941,7 @@ async fn handle_tcp_connection( let sandbox_entrypoint_pid = entrypoint_pid.load(Ordering::Acquire); - match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { + match hydrate_destination_plan(&mut decision, *backend_host_gateway, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { deny_connect_destination( @@ -3464,6 +3470,7 @@ fn hydrate_tls_mode(decision: &mut EgressDecision) { fn hydrate_destination_plan( decision: &mut EgressDecision, + backend_host_gateway: Option, trusted_host_gateway: Option, ) -> std::result::Result<(), DestinationDenial> { let host = decision.intent.destination.host.clone(); @@ -3472,6 +3479,7 @@ fn hydrate_destination_plan( let plan = build_validation_plan( &host, &host.to_ascii_lowercase(), + backend_host_gateway, trusted_host_gateway, &raw_allowed_ips, exact_declared_host, @@ -4824,6 +4832,7 @@ async fn handle_forward_proxy( entrypoint_pid: Arc, policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, + backend_host_gateway: Arc>, trusted_host_gateway: Arc>, provider_credentials: Option, secret_resolver: Option>, @@ -5580,7 +5589,7 @@ async fn handle_forward_proxy( // - Otherwise: reject internal IPs, allow public IPs through. // When the policy host is already a literal IP address, treat it as // implicitly allowed — the user explicitly declared the destination. - match hydrate_destination_plan(&mut decision, *trusted_host_gateway) { + match hydrate_destination_plan(&mut decision, *backend_host_gateway, *trusted_host_gateway) { Ok(()) => {} Err(denial) => { deny_forward_destination( @@ -6530,6 +6539,7 @@ network_policies: {} AgentProposals::default(), Arc::new(None), Arc::new(None), + Arc::new(None), None, None, None, @@ -6642,6 +6652,7 @@ network_policies: None, AgentProposals::default(), Arc::new(None), + Arc::new(None), None, None, None, @@ -6775,6 +6786,7 @@ network_policies: None, AgentProposals::default(), Arc::new(None), + Arc::new(None), None, None, None, @@ -12098,6 +12110,7 @@ network_policies: None, // inference_ctx None, // policy_local_ctx AgentProposals::default(), // agent_proposals + Arc::new(None), // backend_host_gateway Arc::new(None), // trusted_host_gateway Arc::new(None), // upstream_proxy None, // provider_credentials @@ -12167,6 +12180,7 @@ network_policies: AgentProposals::default(), Arc::new(None), Arc::new(None), + Arc::new(None), None, None, None, diff --git a/crates/openshell-supervisor-network/src/proxy/destination.rs b/crates/openshell-supervisor-network/src/proxy/destination.rs index 1ce514133a..16e0e03999 100644 --- a/crates/openshell-supervisor-network/src/proxy/destination.rs +++ b/crates/openshell-supervisor-network/src/proxy/destination.rs @@ -29,6 +29,10 @@ pub(crate) enum AddressAuthorization { TrustedGatewayAlias { expected_ip: IpAddr, }, + /// A backend-provided host-side dial target. The backend is the trusted + /// authority for this mapping, so the supervisor does not consult its own + /// resolver before dialing it. + BackendPinnedGateway(IpAddr), /// Addresses already resolved and authorized by policy DNS. This mode must /// never resolve `DestinationRequest::host` again before constructing the /// unopened connector. @@ -79,11 +83,16 @@ impl DestinationDenial { pub(crate) fn build_validation_plan( host: &str, normalized_host: &str, + backend_host_gateway: Option, trusted_host_gateway: Option, raw_allowed_ips: &[String], exact_declared_endpoint_host: bool, ) -> Result { let address_authorization = if is_host_gateway_alias(normalized_host) + && let Some(expected_ip) = backend_host_gateway + { + AddressAuthorization::BackendPinnedGateway(expected_ip) + } else if is_host_gateway_alias(normalized_host) && let Some(expected_ip) = trusted_host_gateway { AddressAuthorization::TrustedGatewayAlias { expected_ip } @@ -140,7 +149,8 @@ pub(crate) fn filter_resolved_addresses( resolved_ips: &[IpAddr], ) -> Result, DestinationDenial> { let (kind, control_plane_blocked) = match &plan.address_authorization { - AddressAuthorization::TrustedGatewayAlias { .. } => { + AddressAuthorization::TrustedGatewayAlias { .. } + | AddressAuthorization::BackendPinnedGateway(_) => { (DestinationDenialKind::TrustedGateway, true) } AddressAuthorization::ExplicitAllowedIps(_) @@ -211,6 +221,20 @@ pub(crate) fn filter_resolved_addresses( None } } + AddressAuthorization::BackendPinnedGateway(expected_ip) => { + if is_cloud_metadata_ip(ip) { + Some(format!( + "{host} resolves to cloud metadata address {ip}, connection rejected" + )) + } else if ip != *expected_ip { + Some(format!( + "{host} resolves to {ip} which does not match backend host gateway \ + {expected_ip}, connection rejected" + )) + } else { + None + } + } AddressAuthorization::PinnedResolved(pinned) if !pinned.contains(&ip) => Some(format!( "{host} resolves to unpinned address {ip}, connection rejected" )), @@ -296,6 +320,23 @@ pub(crate) async fn validate_destination( DestinationDenial::new(DestinationDenialKind::TrustedGateway, reason) })? } + AddressAuthorization::BackendPinnedGateway(ip) => { + if BLOCKED_CONTROL_PLANE_PORTS.contains(&port) { + return Err(DestinationDenial::new( + DestinationDenialKind::TrustedGateway, + format!("port {port} is a blocked control-plane port, connection rejected"), + )); + } + if is_cloud_metadata_ip(*ip) { + return Err(DestinationDenial::new( + DestinationDenialKind::TrustedGateway, + format!( + "backend host gateway resolves to cloud metadata address {ip}, connection rejected" + ), + )); + } + vec![SocketAddr::new(*ip, port)] + } AddressAuthorization::ExplicitAllowedIps(networks) => { resolve_and_check_allowed_ips(host, port, networks, sandbox_entrypoint_pid) .await @@ -381,6 +422,7 @@ mod tests { "api.example.test", "api.example.test", None, + None, &["not-an-ip".to_string()], false, ) @@ -516,10 +558,26 @@ mod tests { #[test] fn validation_mode_precedence_is_explicit_and_stable() { + let backend_ip = IpAddr::V4(Ipv4Addr::LOCALHOST); let trusted_ip = IpAddr::V4(Ipv4Addr::new(169, 254, 1, 2)); + let backend = build_validation_plan( + "host.openshell.internal", + "host.openshell.internal", + Some(backend_ip), + Some(trusted_ip), + &["10.0.0.0/8".to_string()], + true, + ) + .unwrap(); + assert_eq!( + backend.address_authorization, + AddressAuthorization::BackendPinnedGateway(backend_ip) + ); + let trusted = build_validation_plan( "host.openshell.internal", "host.openshell.internal", + None, Some(trusted_ip), &["10.0.0.0/8".to_string()], true, @@ -536,6 +594,7 @@ mod tests { "10.2.3.4", "10.2.3.4", None, + None, &["10.0.0.0/8".to_string()], true, ) @@ -545,21 +604,24 @@ mod tests { AddressAuthorization::ExplicitAllowedIps(vec!["10.0.0.0/8".parse().unwrap()]) ); - let implicit = build_validation_plan("10.2.3.4", "10.2.3.4", None, &[], true).unwrap(); + let implicit = + build_validation_plan("10.2.3.4", "10.2.3.4", None, None, &[], true).unwrap(); assert_eq!( implicit.address_authorization, AddressAuthorization::ImplicitIpLiteral("10.2.3.4".parse().unwrap()) ); let declared = - build_validation_plan("private.example", "private.example", None, &[], true).unwrap(); + build_validation_plan("private.example", "private.example", None, None, &[], true) + .unwrap(); assert_eq!( declared.address_authorization, AddressAuthorization::ExactDeclaredHost ); let default = - build_validation_plan("*.example.com", "*.example.com", None, &[], false).unwrap(); + build_validation_plan("*.example.com", "*.example.com", None, None, &[], false) + .unwrap(); assert_eq!( default.address_authorization, AddressAuthorization::DefaultPublicOnly diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 186d156086..e89c08225f 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -549,6 +549,7 @@ network_policies: AgentProposals::default(), Arc::new(None), Arc::new(None), + Arc::new(None), None, None, None, diff --git a/crates/openshell-supervisor-network/src/run.rs b/crates/openshell-supervisor-network/src/run.rs index 2a71702b4b..0f29e331ff 100644 --- a/crates/openshell-supervisor-network/src/run.rs +++ b/crates/openshell-supervisor-network/src/run.rs @@ -196,6 +196,7 @@ pub async fn run_networking( agent_proposals: AgentProposals, workspace_rx: tokio::sync::watch::Receiver, upstream_proxy_args: &crate::upstream_proxy::UpstreamProxyArgs, + host_gateway_ip: Option, #[cfg(target_os = "linux")] transparent_runtime: Option, ) -> Result { // Build the policy-local route context. The orchestrator's policy poll @@ -426,10 +427,11 @@ pub async fn run_networking( }); // Build inference context for local routing of intercepted inference calls. - let inference_ctx = crate::inference_routes::build_inference_context( + let inference_ctx = crate::inference_routes::build_inference_context_with_host_gateway( sandbox_id, openshell_endpoint, inference_routes, + host_gateway_ip, ) .await?; @@ -447,6 +449,7 @@ pub async fn run_networking( activity_tx.clone(), engine_ready_rx, upstream_proxy_args, + host_gateway_ip, ) .await?; Some(proxy_handle) diff --git a/crates/openshell-supervisor-process/Cargo.toml b/crates/openshell-supervisor-process/Cargo.toml index 2e2120f1d0..aa80aaeb60 100644 --- a/crates/openshell-supervisor-process/Cargo.toml +++ b/crates/openshell-supervisor-process/Cargo.toml @@ -12,10 +12,12 @@ rust-version.workspace = true [dependencies] openshell-core = { path = "../openshell-core" } +openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } anyhow = { workspace = true } +async-trait = "0.1" base64 = { workspace = true } bytes = { workspace = true } hex = "0.4" diff --git a/crates/openshell-supervisor-process/src/boundary_exec.rs b/crates/openshell-supervisor-process/src/boundary_exec.rs new file mode 100644 index 0000000000..eea12cac38 --- /dev/null +++ b/crates/openshell-supervisor-process/src/boundary_exec.rs @@ -0,0 +1,697 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Co-located implementation of RFC 0012 in-boundary exec. + +use std::collections::HashMap; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; + +use async_trait::async_trait; +use nix::pty::{Winsize, openpty}; +use nix::sys::signal::{Signal, killpg}; +use nix::unistd::Pid; +use openshell_core::policy::SandboxPolicy; +use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_isolation_interface::contract::{ + BackendError, BoundaryExec, BoundaryExitStatus, BoundaryInput, BoundaryOutput, BoundaryProcess, + BoundarySignal, BoundaryTerminal, ExecSession, ExecSpec, +}; + +use crate::process::{ProcessEnforcementMode, ResolvedProcessIdentity}; + +/// The co-located executor. Every spawn reuses the same admitted policy and +/// execution-environment controls while taking a fresh provider credential +/// snapshot. +#[derive(Clone)] +pub struct LocalBoundaryExec { + policy: SandboxPolicy, + base_workdir: Option, + netns_fd: Option>, + proxy_url: Option, + ca_file_paths: Option>, + provider_credentials: ProviderCredentialState, + user_environment: HashMap, + resolved_identity: ResolvedProcessIdentity, + enforcement_mode: ProcessEnforcementMode, + runtime: Arc, +} + +impl LocalBoundaryExec { + /// Construct one executor for an active co-located boundary. + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + policy: SandboxPolicy, + base_workdir: Option, + netns_fd: Option>, + proxy_url: Option, + ca_file_paths: Option>, + provider_credentials: ProviderCredentialState, + user_environment: HashMap, + resolved_identity: ResolvedProcessIdentity, + enforcement_mode: ProcessEnforcementMode, + runtime: Arc, + ) -> Self { + Self { + policy, + base_workdir, + netns_fd, + proxy_url, + ca_file_paths, + provider_credentials, + user_environment, + resolved_identity, + enforcement_mode, + runtime, + } + } + + fn command(&self, spec: &ExecSpec) -> Result { + if spec.program.is_empty() { + return Err(BackendError::Process("exec program is empty".to_string())); + } + let mut command = Command::new(&spec.program); + command.args(&spec.args); + let effective_workdir = spec.workdir.as_deref().or(self.base_workdir.as_deref()); + let (session_user, session_home) = + crate::process::session_user_and_home(&self.policy, effective_workdir); + crate::ssh::apply_child_env( + &mut command, + &session_home, + &session_user, + if spec.pty { "xterm-256color" } else { "dumb" }, + self.proxy_url.as_deref(), + self.ca_file_paths.as_deref(), + &self.provider_credentials.child_env_with_gcp_resolved(), + &self.user_environment, + ); + for (key, value) in &spec.env { + if !key.starts_with("OPENSHELL_") { + command.env(key, value); + } + } + if let Some(workdir) = spec.workdir.as_deref().or(self.base_workdir.as_deref()) { + command.current_dir(workdir); + } + Ok(command) + } + + #[cfg(target_os = "linux")] + fn prepare_sandbox( + &self, + workdir: Option<&str>, + ) -> Result, BackendError> { + if self.enforcement_mode.enforces_child_sandbox() { + crate::sandbox::linux::log_sandbox_readiness(&self.policy, workdir); + } + crate::process::prepare_child_sandbox(&self.policy, workdir, self.enforcement_mode) + .map_err(|error| BackendError::Process(error.to_string())) + } + + fn spawn_piped(&self, spec: &ExecSpec) -> Result { + self.runtime.ensure_active()?; + let mut command = self.command(spec)?; + command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let effective_workdir = spec.workdir.as_deref().or(self.base_workdir.as_deref()); + #[cfg(target_os = "linux")] + let prepared = self.prepare_sandbox(effective_workdir)?; + crate::ssh::unsafe_pty::install_dedicated_process_group(&mut command); + crate::ssh::unsafe_pty::install_pre_exec_no_pty( + &mut command, + self.policy.clone(), + effective_workdir.map(str::to_string), + self.netns_fd.as_deref().map(AsRawFd::as_raw_fd), + self.resolved_identity, + self.enforcement_mode, + #[cfg(target_os = "linux")] + prepared, + ); + #[cfg(target_os = "linux")] + let mut child_registry = crate::managed_children::lock(); + let mut child = command + .spawn() + .map_err(|error| BackendError::Process(error.to_string()))?; + let pid = child.id(); + let process_terminal = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let signal_lock = Arc::new(std::sync::Mutex::new(())); + if let Err(error) = + self.runtime + .register_process_group(pid, process_terminal.clone(), signal_lock.clone()) + { + let _ = killpg( + Pid::from_raw(i32::try_from(pid).unwrap_or(i32::MAX)), + Signal::SIGKILL, + ); + let _ = child.wait(); + return Err(error); + } + #[cfg(target_os = "linux")] + let managed_child = child_registry.register(pid); + #[cfg(target_os = "linux")] + drop(child_registry); + let stdin = child.stdin.take().map(|file| -> BoundaryInput { + let fd: OwnedFd = file.into(); + Box::new(tokio::fs::File::from_std(std::fs::File::from(fd))) + }); + let stdout = child + .stdout + .take() + .map(|file| -> BoundaryOutput { + let fd: OwnedFd = file.into(); + Box::new(tokio::fs::File::from_std(std::fs::File::from(fd))) + }) + .ok_or_else(|| BackendError::Process("exec stdout pipe missing".to_string()))?; + let stderr = child.stderr.take().map(|file| -> BoundaryOutput { + let fd: OwnedFd = file.into(); + Box::new(tokio::fs::File::from_std(std::fs::File::from(fd))) + }); + let process = Arc::new(LocalExecProcess::new( + child, + pid, + self.runtime.clone(), + process_terminal, + signal_lock, + #[cfg(target_os = "linux")] + managed_child, + )); + Ok(SpawnedExec { + session: Some(ExecSession { + process: process.clone(), + stdin, + stdout, + stderr, + terminal: None, + }), + process, + armed: true, + }) + } + + fn spawn_pty(&self, spec: &ExecSpec) -> Result { + self.runtime.ensure_active()?; + let winsize = Winsize { + ws_row: 24, + ws_col: 80, + ws_xpixel: 0, + ws_ypixel: 0, + }; + let pty = openpty(Some(&winsize), None) + .map_err(|error| BackendError::Process(error.to_string()))?; + let master = std::fs::File::from(pty.master); + let slave = std::fs::File::from(pty.slave); + let slave_fd = slave.as_raw_fd(); + let input = master + .try_clone() + .map_err(|error| BackendError::Process(error.to_string()))?; + let output = master + .try_clone() + .map_err(|error| BackendError::Process(error.to_string()))?; + let stdin = slave + .try_clone() + .map_err(|error| BackendError::Process(error.to_string()))?; + let stdout = slave + .try_clone() + .map_err(|error| BackendError::Process(error.to_string()))?; + let mut command = self.command(spec)?; + command.stdin(stdin).stdout(stdout).stderr(slave); + let effective_workdir = spec.workdir.as_deref().or(self.base_workdir.as_deref()); + #[cfg(target_os = "linux")] + let prepared = self.prepare_sandbox(effective_workdir)?; + crate::ssh::unsafe_pty::install_pre_exec( + &mut command, + self.policy.clone(), + effective_workdir.map(str::to_string), + slave_fd, + self.netns_fd.as_deref().map(AsRawFd::as_raw_fd), + self.resolved_identity, + self.enforcement_mode, + #[cfg(target_os = "linux")] + prepared, + ); + #[cfg(target_os = "linux")] + let mut child_registry = crate::managed_children::lock(); + let mut child = command + .spawn() + .map_err(|error| BackendError::Process(error.to_string()))?; + let pid = child.id(); + let process_terminal = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let signal_lock = Arc::new(std::sync::Mutex::new(())); + if let Err(error) = + self.runtime + .register_process_group(pid, process_terminal.clone(), signal_lock.clone()) + { + let _ = killpg( + Pid::from_raw(i32::try_from(pid).unwrap_or(i32::MAX)), + Signal::SIGKILL, + ); + let _ = child.wait(); + return Err(error); + } + #[cfg(target_os = "linux")] + let managed_child = child_registry.register(pid); + #[cfg(target_os = "linux")] + drop(child_registry); + let terminal: Arc = Arc::new(LocalTerminal { master }); + let process = Arc::new(LocalExecProcess::new( + child, + pid, + self.runtime.clone(), + process_terminal, + signal_lock, + #[cfg(target_os = "linux")] + managed_child, + )); + Ok(SpawnedExec { + session: Some(ExecSession { + process: process.clone(), + stdin: Some(Box::new(tokio::fs::File::from_std(input))), + stdout: Box::new(tokio::fs::File::from_std(output)), + stderr: None, + terminal: Some(terminal), + }), + process, + armed: true, + }) + } +} + +struct SpawnedExec { + session: Option, + process: Arc, + armed: bool, +} + +impl SpawnedExec { + fn into_session(mut self) -> ExecSession { + self.armed = false; + self.session.take().expect("spawned exec session") + } +} + +impl Drop for SpawnedExec { + fn drop(&mut self) { + if self.armed { + let _ = self.process.deliver(Signal::SIGKILL); + } + } +} + +#[async_trait] +impl BoundaryExec for LocalBoundaryExec { + async fn exec(&self, spec: ExecSpec) -> Result { + let executor = self.clone(); + let (send, receive) = tokio::sync::oneshot::channel(); + tokio::task::spawn_blocking(move || { + let result = if spec.pty { + executor.spawn_pty(&spec) + } else { + executor.spawn_piped(&spec) + }; + // If the caller cancelled, either send fails and drops the armed + // process guard here, or the queued guard is dropped with the + // receiver. Both paths terminate an unobservable exec process. + let _ = send.send(result); + }); + receive + .await + .map_err(|_| BackendError::Process("exec spawn task failed".to_string()))? + .map(SpawnedExec::into_session) + } +} + +struct LocalTerminal { + master: std::fs::File, +} + +#[async_trait] +impl BoundaryTerminal for LocalTerminal { + async fn resize(&self, cols: u16, rows: u16) -> Result<(), BackendError> { + crate::ssh::unsafe_pty::set_winsize( + self.master.as_raw_fd(), + Winsize { + ws_row: rows.max(1), + ws_col: cols.max(1), + ws_xpixel: 0, + ws_ypixel: 0, + }, + ) + .map_err(|error| BackendError::Process(error.to_string())) + } +} + +struct LocalExecProcess { + pid: u32, + result: Arc>>>, + exited: Arc, + runtime: Arc, + terminal: Arc, + signal_lock: Arc>, +} + +impl LocalExecProcess { + fn new( + child: Child, + pid: u32, + runtime: Arc, + terminal: Arc, + signal_lock: Arc>, + #[cfg(target_os = "linux")] managed_child: Option, + ) -> Self { + let result = Arc::new(std::sync::Mutex::new(None)); + let exited = Arc::new(tokio::sync::Notify::new()); + let result_for_wait = result.clone(); + let exited_for_wait = exited.clone(); + let runtime_for_wait = runtime.clone(); + let terminal_for_wait = terminal.clone(); + let registration_terminal = terminal.clone(); + #[cfg(target_os = "linux")] + let signal_lock_for_wait = signal_lock.clone(); + tokio::spawn(async move { + let waited = tokio::task::spawn_blocking(move || { + let mut child = child; + #[cfg(target_os = "linux")] + { + let terminal_observed = crate::managed_children::wait_until_terminal(pid); + let _signal_guard = signal_lock_for_wait + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let result = child.wait(); + terminal_for_wait.store(true, std::sync::atomic::Ordering::Release); + if let Some(managed_child) = managed_child { + crate::managed_children::unregister(managed_child); + } + match (terminal_observed, result) { + (_, Ok(status)) => Ok(status), + (Err(observe_error), Err(wait_error)) => Err(std::io::Error::other( + format!( + "observe exec terminal state: {observe_error}; reap exec: {wait_error}" + ), + )), + (Ok(()), Err(wait_error)) => Err(wait_error), + } + } + #[cfg(not(target_os = "linux"))] + { + let result = child.wait(); + terminal_for_wait.store(true, std::sync::atomic::Ordering::Release); + result + } + }) + .await + .map_err(|error| error.to_string()) + .and_then(|status| status.map_err(|error| error.to_string())) + .map(|status| { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(signal) = status.signal() { + return BoundaryExitStatus::Signaled(signal); + } + } + BoundaryExitStatus::Exited(status.code().unwrap_or(1)) + }); + runtime_for_wait.unregister_process_group(pid, ®istration_terminal); + if let Ok(mut slot) = result_for_wait.lock() { + *slot = Some(waited); + } + exited_for_wait.notify_waiters(); + }); + Self { + pid, + result, + exited, + runtime, + terminal, + signal_lock, + } + } + + fn deliver(&self, signal: Signal) -> Result<(), BackendError> { + self.runtime.ensure_active()?; + let _signal_guard = self + .signal_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.terminal.load(std::sync::atomic::Ordering::Acquire) { + return Err(BackendError::Terminated("process has exited".to_string())); + } + let pid = i32::try_from(self.pid).unwrap_or(i32::MAX); + killpg(Pid::from_raw(pid), signal).map_err(|error| BackendError::Process(error.to_string())) + } +} + +#[async_trait] +impl BoundaryProcess for LocalExecProcess { + async fn wait(&self) -> Result { + loop { + let notified = self.exited.notified(); + let result = self + .result + .lock() + .map_err(|_| BackendError::Process("exec result lock poisoned".to_string()))? + .clone(); + if let Some(result) = result { + return result.map_err(BackendError::Process); + } + notified.await; + } + } + + async fn signal(&self, signal: BoundarySignal) -> Result<(), BackendError> { + self.deliver(match signal { + BoundarySignal::Term => Signal::SIGTERM, + BoundarySignal::Kill => Signal::SIGKILL, + BoundarySignal::Int => Signal::SIGINT, + BoundarySignal::Hup => Signal::SIGHUP, + }) + } + + async fn terminate(&self) -> Result<(), BackendError> { + self.deliver(Signal::SIGKILL) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + fn executor() -> LocalBoundaryExec { + LocalBoundaryExec::new( + SandboxPolicy { + version: 1, + filesystem: openshell_core::policy::FilesystemPolicy::default(), + network: openshell_core::policy::NetworkPolicy::default(), + landlock: openshell_core::policy::LandlockPolicy::default(), + process: openshell_core::policy::ProcessPolicy::default(), + }, + None, + None, + None, + None, + ProviderCredentialState::from_environment( + 0, + HashMap::new(), + HashMap::new(), + HashMap::new(), + ), + HashMap::new(), + ResolvedProcessIdentity::default(), + ProcessEnforcementMode::NetworkOnly, + crate::boundary_io::BoundaryRuntimeState::new(), + ) + } + + #[tokio::test] + async fn non_pty_exec_preserves_stdin_stdout_and_stderr() { + let mut session = executor() + .exec(ExecSpec { + program: "/bin/sh".to_string(), + args: vec![ + "-c".to_string(), + "read line; printf 'out:%s' \"$line\"; printf 'err:%s' \"$line\" >&2" + .to_string(), + ], + env: vec![], + workdir: None, + pty: false, + }) + .await + .expect("spawn exec"); + let mut stdin = session.stdin.take().expect("stdin"); + stdin.write_all(b"value\n").await.expect("write stdin"); + drop(stdin); + let mut stdout = String::new(); + let mut stderr = String::new(); + session + .stdout + .read_to_string(&mut stdout) + .await + .expect("read stdout"); + session + .stderr + .take() + .expect("stderr") + .read_to_string(&mut stderr) + .await + .expect("read stderr"); + assert_eq!( + session.process.wait().await.unwrap(), + BoundaryExitStatus::Exited(0) + ); + assert_eq!(stdout, "out:value"); + assert_eq!(stderr, "err:value"); + } + + #[tokio::test] + async fn exec_rejects_after_boundary_end() { + let executor = executor(); + executor.runtime.deactivate(); + let result = executor + .exec(ExecSpec { + program: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "exit 0".to_string()], + env: vec![], + workdir: None, + pty: false, + }) + .await; + assert!(matches!(result, Err(BackendError::Terminated(_)))); + } + + #[tokio::test] + async fn failed_exec_leaves_boundary_active_without_registered_processes() { + let executor = executor(); + let runtime = executor.runtime.clone(); + let result = executor + .exec(ExecSpec { + program: "/definitely/missing/openshell-exec".to_string(), + args: vec![], + env: vec![], + workdir: None, + pty: false, + }) + .await; + assert!(matches!(result, Err(BackendError::Process(_)))); + runtime.ensure_active().expect("boundary remains active"); + assert_eq!(runtime.registered_process_group_count(), 0); + } + + #[tokio::test] + async fn cancelled_exec_does_not_leave_a_registered_process() { + let executor = executor(); + let runtime = executor.runtime.clone(); + let task = tokio::spawn(async move { + executor + .exec(ExecSpec { + program: "/bin/sleep".to_string(), + args: vec!["30".to_string()], + env: vec![], + workdir: None, + pty: false, + }) + .await + }); + tokio::task::yield_now().await; + task.abort(); + let _ = task.await; + + // Give the detached blocking setup time to reach its cancelled + // handoff, including the case where cancellation won before spawn. + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while runtime.registered_process_group_count() != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("cancelled exec process must be terminated and reaped"); + runtime.ensure_active().expect("boundary remains active"); + } + + #[tokio::test] + async fn dropping_undelivered_exec_guard_terminates_process() { + let executor = executor(); + let runtime = executor.runtime.clone(); + let spawned = tokio::task::spawn_blocking(move || { + executor.spawn_piped(&ExecSpec { + program: "/bin/sleep".to_string(), + args: vec!["30".to_string()], + env: vec![], + workdir: None, + pty: false, + }) + }) + .await + .expect("spawn task") + .expect("spawn exec"); + assert_eq!(runtime.registered_process_group_count(), 1); + + // This is the post-send/pre-receive cancellation case: dropping the + // queued ownership guard must kill the process before it is observable. + drop(spawned); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while runtime.registered_process_group_count() != 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("undelivered exec process must be terminated and reaped"); + runtime.ensure_active().expect("boundary remains active"); + } + + #[tokio::test] + async fn completed_exec_removes_its_process_group_registration() { + let executor = executor(); + let runtime = executor.runtime.clone(); + let session = executor + .exec(ExecSpec { + program: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "exit 0".to_string()], + env: vec![], + workdir: None, + pty: false, + }) + .await + .expect("spawn exec"); + assert_eq!( + session.process.wait().await.unwrap(), + BoundaryExitStatus::Exited(0) + ); + assert_eq!(runtime.registered_process_group_count(), 0); + } + + #[tokio::test] + async fn pty_exec_exposes_resize_and_stable_wait() { + let session = executor() + .exec(ExecSpec { + program: "/bin/sh".to_string(), + args: vec!["-c".to_string(), "exit 7".to_string()], + env: vec![], + workdir: None, + pty: true, + }) + .await + .expect("spawn pty exec"); + session + .terminal + .as_ref() + .expect("terminal") + .resize(120, 40) + .await + .expect("resize"); + assert_eq!( + session.process.wait().await.unwrap(), + BoundaryExitStatus::Exited(7) + ); + assert_eq!( + session.process.wait().await.unwrap(), + BoundaryExitStatus::Exited(7) + ); + } +} diff --git a/crates/openshell-supervisor-process/src/boundary_io.rs b/crates/openshell-supervisor-process/src/boundary_io.rs new file mode 100644 index 0000000000..fab37a0062 --- /dev/null +++ b/crates/openshell-supervisor-process/src/boundary_io.rs @@ -0,0 +1,317 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The in-pod [`BoundaryPortForward`] interface (RFC 0012 runtime contract). +//! +//! This is the live in-boundary port-forward for the in-pod placement. It lives +//! in this crate on purpose: the SSH server and supervisor session that consume +//! it are here, and so is the primitive it wraps +//! ([`connect_in_netns`](crate::ssh::connect_in_netns)). The interface trait +//! lives in the lower `openshell-isolation-interface` crate, so this crate +//! depends on the trait (process -> interface -> core, acyclic) and the SSH server drives a +//! `&dyn BoundaryPortForward` without depending on the backend. +//! +//! The SSH server and supervisor session are wired to this through the +//! `RunningBoundary::port_forward()` accessor: swapping in a kernel-separated +//! backend swaps this implementation (where `connect` tunnels into the guest) +//! and touches no consumer code. + +use async_trait::async_trait; +use openshell_isolation_interface::contract::{ + BackendError, BoundaryDuplexStream, BoundaryPortForward, LoopbackTarget, +}; +use std::collections::HashMap; +use std::os::fd::{AsRawFd, OwnedFd}; +use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex}; + +/// Shared liveness and child-process ownership for one active boundary. +pub struct BoundaryRuntimeState { + state: AtomicU8, + process_groups: Mutex>, + exclusive_pid_namespace: bool, +} + +impl BoundaryRuntimeState { + #[must_use] + pub fn new() -> Arc { + Arc::new(Self { + state: AtomicU8::new(0), + process_groups: Mutex::new(HashMap::new()), + exclusive_pid_namespace: false, + }) + } + + /// Construct state for a boundary that exclusively owns its PID namespace. + #[must_use] + pub fn new_exclusive_pid_namespace() -> Arc { + Arc::new(Self { + state: AtomicU8::new(0), + process_groups: Mutex::new(HashMap::new()), + exclusive_pid_namespace: true, + }) + } + + #[must_use] + pub const fn requires_dedicated_process_group(&self) -> bool { + self.exclusive_pid_namespace + } + + pub fn ensure_active(&self) -> Result<(), BackendError> { + if self.state.load(Ordering::Acquire) == 0 { + Ok(()) + } else { + Err(BackendError::Terminated("boundary has ended".to_string())) + } + } + + #[must_use] + pub fn is_active(&self) -> bool { + self.state.load(Ordering::Acquire) == 0 + } + + #[must_use] + pub fn enforcement_was_lost(&self) -> bool { + self.state.load(Ordering::Acquire) == 2 + } + + pub fn register_process_group( + &self, + pid: u32, + terminal: Arc, + signal_lock: Arc>, + ) -> Result<(), BackendError> { + let mut groups = self + .process_groups + .lock() + .map_err(|_| BackendError::Process("boundary process registry poisoned".to_string()))?; + self.ensure_active()?; + groups.insert( + pid, + RegisteredProcessGroup { + pid, + terminal, + signal_lock, + }, + ); + Ok(()) + } + + pub fn unregister_process_group( + &self, + pid: u32, + terminal: &Arc, + ) { + if let Ok(mut groups) = self.process_groups.lock() + && groups + .get(&pid) + .is_some_and(|group| Arc::ptr_eq(&group.terminal, terminal)) + { + groups.remove(&pid); + } + } + + #[cfg(test)] + pub fn registered_process_group_count(&self) -> usize { + self.process_groups.lock().map_or(0, |groups| groups.len()) + } + + /// End the boundary and terminate every registered workload process group. + pub fn deactivate(&self) { + if self + .state + .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + self.terminate_registered_processes(); + } + } + + /// End the boundary because required standing enforcement was lost. + /// + /// Returns `true` only to the caller that won the active-to-terminated + /// transition. A concurrent normal teardown cannot later be reclassified + /// as enforcement loss. + pub fn deactivate_for_enforcement_loss(&self) -> bool { + if self + .state + .compare_exchange(0, 2, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return false; + } + self.terminate_registered_processes(); + true + } + + fn terminate_registered_processes(&self) { + let groups = self + .process_groups + .lock() + .map(|groups| groups.values().cloned().collect::>()) + .unwrap_or_default(); + for group in groups { + group.terminate(); + } + } +} + +#[derive(Clone)] +struct RegisteredProcessGroup { + pid: u32, + terminal: Arc, + signal_lock: Arc>, +} + +impl RegisteredProcessGroup { + fn terminate(&self) { + let _signal_guard = self + .signal_lock + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if self.terminal.load(Ordering::Acquire) { + return; + } + if let Ok(pid) = i32::try_from(self.pid) { + let _ = nix::sys::signal::killpg( + nix::unistd::Pid::from_raw(pid), + nix::sys::signal::Signal::SIGKILL, + ); + } + } +} + +/// In-pod loopback port-forward: connects to a loopback target from inside the +/// workload's network namespace via [`connect_in_netns`](crate::ssh::connect_in_netns). +pub struct NetnsPortForward { + /// File descriptor of the boundary's network namespace, or `None` to + /// connect from the supervisor's own namespace. + netns_fd: Option>, + runtime: Option>, +} + +impl NetnsPortForward { + #[must_use] + pub fn new(netns_fd: Option>, runtime: Option>) -> Self { + Self { netns_fd, runtime } + } +} + +#[async_trait] +impl BoundaryPortForward for NetnsPortForward { + async fn connect(&self, target: LoopbackTarget) -> Result { + if let Some(runtime) = &self.runtime { + runtime.ensure_active()?; + } + let addr = std::net::SocketAddr::new(target.host(), target.port()); + let addr_string = addr.to_string(); + let stream = crate::ssh::connect_in_netns( + &addr_string, + self.netns_fd.as_deref().map(AsRawFd::as_raw_fd), + ) + .await + .map_err(|e| BackendError::Process(format!("port-forward connect to {addr}: {e}")))?; + if let Some(runtime) = &self.runtime { + runtime.ensure_active()?; + } + Ok(Box::new(stream)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + /// Stands in for the SSH server's port-forward path: connect through the + /// interface, write, read the echo. With `netns_fd: None` the connect happens in + /// the supervisor's namespace, so this exercises the real primitive without + /// requiring a network namespace. + #[tokio::test] + async fn port_forward_connects_and_round_trips() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 4]; + sock.read_exact(&mut buf).await.unwrap(); + sock.write_all(&buf).await.unwrap(); + }); + + let pf = NetnsPortForward::new(None, None); + let target = + LoopbackTarget::new(Ipv4Addr::LOCALHOST.into(), addr.port()).expect("loopback target"); + let mut conn = pf.connect(target).await.expect("connect through interface"); + conn.write_all(b"ping").await.unwrap(); + let mut buf = [0u8; 4]; + conn.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"ping"); + } + + /// Drive the port-forward interface through a generic `&dyn` consumer, proving a + /// kernel-separated backend (tunneling into a guest) would use the same call. + #[tokio::test] + async fn port_forward_is_driven_via_dyn() { + async fn forward_one(pf: &dyn BoundaryPortForward, target: LoopbackTarget) -> bool { + pf.connect(target).await.is_ok() + } + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ = listener.accept().await; + }); + let pf = NetnsPortForward::new(None, None); + let target = LoopbackTarget::new(Ipv4Addr::LOCALHOST.into(), addr.port()).unwrap(); + assert!(forward_one(&pf, target).await); + } + + #[tokio::test] + async fn port_forward_rejects_after_boundary_end() { + let runtime = BoundaryRuntimeState::new(); + let pf = NetnsPortForward::new(None, Some(runtime.clone())); + runtime.deactivate(); + let target = LoopbackTarget::new(Ipv4Addr::LOCALHOST.into(), 1).unwrap(); + assert!(matches!( + pf.connect(target).await, + Err(BackendError::Terminated(_)) + )); + } + + #[tokio::test] + async fn failed_port_forward_keeps_boundary_active() { + let listener = tokio::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)) + .await + .unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let runtime = BoundaryRuntimeState::new(); + let pf = NetnsPortForward::new(None, Some(runtime.clone())); + let target = LoopbackTarget::new(Ipv4Addr::LOCALHOST.into(), port).unwrap(); + assert!(matches!( + pf.connect(target).await, + Err(BackendError::Process(_)) + )); + runtime.ensure_active().expect("boundary remains active"); + } + + #[test] + fn stale_unregister_preserves_reused_process_group_registration() { + let runtime = BoundaryRuntimeState::new(); + let first_terminal = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let second_terminal = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let pid = 42; + runtime + .register_process_group(pid, first_terminal.clone(), Arc::new(Mutex::new(()))) + .expect("first registration"); + runtime + .register_process_group(pid, second_terminal.clone(), Arc::new(Mutex::new(()))) + .expect("replacement registration"); + + runtime.unregister_process_group(pid, &first_terminal); + assert_eq!(runtime.registered_process_group_count(), 1); + + runtime.unregister_process_group(pid, &second_terminal); + assert_eq!(runtime.registered_process_group_count(), 0); + } +} diff --git a/crates/openshell-supervisor-process/src/lib.rs b/crates/openshell-supervisor-process/src/lib.rs index 743942faa4..ee6bedeb22 100644 --- a/crates/openshell-supervisor-process/src/lib.rs +++ b/crates/openshell-supervisor-process/src/lib.rs @@ -8,6 +8,8 @@ //! and log push. Populated by follow-up commits as modules migrate out of //! `openshell-sandbox`. +pub mod boundary_exec; +pub mod boundary_io; pub mod child_env; pub mod debug_rpc; #[cfg(unix)] diff --git a/crates/openshell-supervisor-process/src/managed_children.rs b/crates/openshell-supervisor-process/src/managed_children.rs index 311c80693f..64b444e1f8 100644 --- a/crates/openshell-supervisor-process/src/managed_children.rs +++ b/crates/openshell-supervisor-process/src/managed_children.rs @@ -10,44 +10,113 @@ #![cfg(target_os = "linux")] -use std::collections::HashSet; -use std::sync::{LazyLock, Mutex}; - -static MANAGED_CHILDREN: LazyLock>> = - LazyLock::new(|| Mutex::new(HashSet::new())); - -/// Add `pid` to the supervised-child set. Non-positive or out-of-range values -/// are silently ignored. -pub fn register(pid: u32) { - let Ok(pid) = i32::try_from(pid) else { - return; - }; - if pid <= 0 { - return; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex, MutexGuard}; + +static MANAGED_CHILDREN: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +static NEXT_GENERATION: AtomicU64 = AtomicU64::new(1); + +/// Identity of one registry entry. The generation prevents an old waiter from +/// removing a newer child that reused the same numeric PID after reap. +#[derive(Clone, Copy)] +pub struct ManagedChild { + pid: i32, + generation: u64, +} + +/// Exclusive access to the managed-child registry. +/// +/// A process spawner holds this guard from immediately before `spawn` or +/// `fork` until the returned PID is registered. The orphan reaper holds the +/// same guard while deciding whether to reap an exited child. This closes the +/// otherwise unavoidable window in which a fast-exiting managed child exists +/// but its PID has not yet been published. +pub struct RegistryGuard(MutexGuard<'static, HashMap>); + +impl RegistryGuard { + /// Add a newly spawned managed child. + pub fn register(&mut self, pid: u32) -> Option { + let Ok(pid) = i32::try_from(pid) else { + return None; + }; + if pid <= 0 { + return None; + } + let generation = NEXT_GENERATION.fetch_add(1, Ordering::Relaxed); + self.0.insert(pid, generation); + Some(ManagedChild { pid, generation }) } - if let Ok(mut children) = MANAGED_CHILDREN.lock() { - children.insert(pid); + + /// Return whether the PID belongs to an explicit waiter. + #[must_use] + pub fn contains(&self, pid: i32) -> bool { + self.0.contains_key(&pid) } } -/// Remove `pid` from the supervised-child set. Non-positive or out-of-range -/// values are silently ignored. -pub fn unregister(pid: u32) { - let Ok(pid) = i32::try_from(pid) else { - return; - }; - if pid <= 0 { - return; - } - if let Ok(mut children) = MANAGED_CHILDREN.lock() { - children.remove(&pid); +/// Lock the registry for an atomic spawn-and-register or inspect-and-reap +/// operation. +pub fn lock() -> RegistryGuard { + RegistryGuard( + MANAGED_CHILDREN + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + ) +} + +/// Register a child and return the generation-bearing removal token. +pub fn register(pid: u32) -> Option { + lock().register(pid) +} + +/// Remove exactly this supervised-child registration. A newer registration +/// for a reused PID is preserved. +pub fn unregister(child: ManagedChild) { + if let Ok(mut children) = MANAGED_CHILDREN.lock() + && children.get(&child.pid) == Some(&child.generation) + { + children.remove(&child.pid); } } /// Return `true` if `pid` is currently in the supervised-child set. #[must_use] pub fn is_managed(pid: i32) -> bool { - MANAGED_CHILDREN - .lock() - .is_ok_and(|children| children.contains(&pid)) + lock().contains(pid) +} + +/// Wait until a managed child is terminal without reaping it. +/// +/// Keeping the child as a zombie prevents PID/process-group reuse until the +/// owner publishes terminal state and performs the final wait. +pub fn wait_until_terminal(pid: u32) -> std::io::Result<()> { + use nix::sys::wait::{Id, WaitPidFlag, waitid}; + let pid = i32::try_from(pid) + .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidInput, "PID out of range"))?; + waitid( + Id::Pid(nix::unistd::Pid::from_raw(pid)), + WaitPidFlag::WEXITED | WaitPidFlag::WNOWAIT, + ) + .map(|_| ()) + .map_err(std::io::Error::other) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stale_unregister_preserves_reused_pid_registration() { + let pid = i32::MAX as u32; + let first = lock().register(pid).expect("first registration"); + let second = lock().register(pid).expect("replacement registration"); + + unregister(first); + assert!(is_managed(i32::try_from(pid).expect("test pid"))); + + unregister(second); + assert!(!is_managed(i32::try_from(pid).expect("test pid"))); + } } diff --git a/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs b/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs index aef95b6068..61e9b1d1dc 100644 --- a/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs +++ b/crates/openshell-supervisor-process/src/netns/nft_ruleset.rs @@ -24,7 +24,7 @@ pub struct NftCommand { pub required: bool, } -/// Generate nft commands for sandbox network bypass enforcement. +/// Generate the legacy nft commands for sandbox bypass detection. /// /// Creates an `inet` family table (handles both IPv4 and IPv6) with rules that: /// 1. Accept traffic to the proxy (IPv4 only) @@ -34,11 +34,35 @@ pub struct NftCommand { /// /// If `log_prefix` is provided, log rules are inserted before each reject rule /// so that bypass attempts are recorded in the kernel ring buffer before being -/// rejected. Log rules are always non-required since they need `nf_log` support. +/// rejected. Log rules are non-required since they need `nf_log` support. pub fn generate_bypass_commands( host_ip: &str, proxy_port: u16, log_prefix: Option<&str>, +) -> Vec { + generate_commands(host_ip, proxy_port, log_prefix, false) +} + +/// Generate the RFC 0012 default-deny egress ceiling. +/// +/// Only the exact proxy destination and loopback are accepted. TCP and UDP +/// rejects are optional fast-fail behavior; the base-chain drop policy covers +/// every address family and protocol. No blanket conntrack exception is +/// installed because pre-existing or related flows must not bypass mediation. +#[allow(dead_code, reason = "consumed when RFC 0012 backend activation lands")] +pub fn generate_egress_ceiling_commands( + host_ip: &str, + proxy_port: u16, + log_prefix: Option<&str>, +) -> Vec { + generate_commands(host_ip, proxy_port, log_prefix, true) +} + +fn generate_commands( + host_ip: &str, + proxy_port: u16, + log_prefix: Option<&str>, + default_deny: bool, ) -> Vec { let table = "openshell_bypass"; let mut cmds = vec![ @@ -52,7 +76,11 @@ pub fn generate_bypass_commands( "inet", table, "output", - "{ type filter hook output priority 0; policy accept; }", + if default_deny { + "{ type filter hook output priority 0; policy drop; }" + } else { + "{ type filter hook output priority 0; policy accept; }" + }, ], ), nft_cmd( @@ -78,7 +106,10 @@ pub fn generate_bypass_commands( "add", "rule", "inet", table, "output", "oifname", "lo", "accept", ], ), - nft_cmd( + ]; + + if !default_deny { + cmds.push(nft_cmd( false, &[ "add", @@ -91,8 +122,8 @@ pub fn generate_bypass_commands( "established,related", "accept", ], - ), - ]; + )); + } if let Some(prefix) = log_prefix { let quoted = nft_quote(prefix); @@ -106,7 +137,7 @@ pub fn generate_bypass_commands( } cmds.push(nft_cmd( - true, + !default_deny, &[ "add", "rule", @@ -127,7 +158,7 @@ pub fn generate_bypass_commands( ], )); cmds.push(nft_cmd( - true, + !default_deny, &[ "add", "rule", @@ -160,7 +191,7 @@ pub fn generate_bypass_commands( } cmds.push(nft_cmd( - true, + !default_deny, &[ "add", "rule", @@ -181,7 +212,7 @@ pub fn generate_bypass_commands( ], )); cmds.push(nft_cmd( - true, + !default_deny, &[ "add", "rule", @@ -598,6 +629,25 @@ mod tests { assert!(text.contains("type filter hook output priority 0; policy accept;")); } + #[test] + fn in_pod_ceiling_is_default_deny_for_all_protocols() { + let text = all_strs(&generate_egress_ceiling_commands("10.0.2.2", 3128, None)); + assert!(text.contains("policy drop")); + assert!(!text.contains("policy accept")); + assert!(!text.contains("ct state")); + } + + #[test] + fn in_pod_reject_rules_are_optional_fast_fail_over_default_drop() { + let commands = generate_egress_ceiling_commands("10.0.2.2", 3128, None); + for command in commands + .iter() + .filter(|command| command.args.iter().any(|argument| argument == "reject")) + { + assert!(!command.required); + } + } + #[test] fn proxy_accept_rule_uses_provided_ip_and_port() { let cmds = generate_bypass_commands("172.16.0.1", 9999, None); @@ -611,7 +661,7 @@ mod tests { let text = all_strs(&cmds); let proxy_pos = text.find("ip daddr").unwrap(); let lo_pos = text.find("oifname lo").unwrap(); - let ct_pos = text.find("ct state established,related").unwrap(); + let ct_pos = text.find("ct state established").unwrap(); let reject_pos = text.find("reject with icmp type").unwrap(); assert!(proxy_pos < lo_pos); diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs index 0ab1dd3187..f06d94d989 100644 --- a/crates/openshell-supervisor-process/src/process.rs +++ b/crates/openshell-supervisor-process/src/process.rs @@ -659,6 +659,8 @@ pub struct ProcessHandle { child: Child, pid: u32, io: Option, + #[cfg(target_os = "linux")] + managed_child: Option, } /// Supervisor-owned canonical-process I/O. These handles outlive individual @@ -917,7 +919,7 @@ impl ProcessHandle { .into_diagnostic() .wrap_err_with(|| format!("failed to spawn sandbox entrypoint process '{program}'"))?; let pid = child.id().unwrap_or(0); - managed_children::register(pid); + let managed_child = managed_children::register(pid); let io = if let Some(master) = pty_master { ProcessIo::Pty(master) @@ -935,6 +937,8 @@ impl ProcessHandle { child, pid, io: Some(io), + #[cfg(target_os = "linux")] + managed_child, }) } @@ -1103,7 +1107,9 @@ impl ProcessHandle { pub async fn wait(&mut self) -> std::io::Result { let status = self.child.wait().await; #[cfg(target_os = "linux")] - managed_children::unregister(self.pid); + if let Some(child) = self.managed_child.take() { + managed_children::unregister(child); + } let status = status?; Ok(ProcessStatus::from(status)) } @@ -1113,7 +1119,9 @@ impl ProcessHandle { let status = self.child.try_wait()?; if status.is_some() { #[cfg(target_os = "linux")] - managed_children::unregister(self.pid); + if let Some(child) = self.managed_child.take() { + managed_children::unregister(child); + } } Ok(status.map(ProcessStatus::from)) } @@ -1163,7 +1171,9 @@ impl ProcessHandle { impl Drop for ProcessHandle { fn drop(&mut self) { #[cfg(target_os = "linux")] - managed_children::unregister(self.pid); + if let Some(child) = self.managed_child.take() { + managed_children::unregister(child); + } } } diff --git a/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs b/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs index a9c67af95a..ddd37a502d 100644 --- a/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs +++ b/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs @@ -838,4 +838,43 @@ mod tests { "socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG) should be blocked with EPERM" ); } + + #[test] + fn behavioral_block_mode_denies_inet_and_packet_sockets() { + let filter = build_filter(false).unwrap(); + let pid = unsafe { libc::fork() }; + assert!(pid >= 0, "fork failed"); + if pid == 0 { + unsafe { + libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); + apply_filter(&filter).expect("apply block-mode filter"); + for (domain, socket_type, protocol) in [ + (libc::AF_INET, libc::SOCK_STREAM, 0), + (libc::AF_INET6, libc::SOCK_DGRAM, 0), + (libc::AF_PACKET, libc::SOCK_RAW, 0), + ] { + let fd = libc::socket(domain, socket_type, protocol); + let errno = *libc::__errno_location(); + if fd >= 0 || errno != libc::EPERM { + if fd >= 0 { + libc::close(fd); + } + libc::_exit(1); + } + } + let unix_fd = libc::socket(libc::AF_UNIX, libc::SOCK_STREAM, 0); + if unix_fd < 0 { + libc::_exit(1); + } + libc::close(unix_fd); + libc::_exit(0); + } + } + let mut status: libc::c_int = 0; + unsafe { libc::waitpid(pid, &mut status, 0) }; + assert!( + unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, + "block mode must deny IPv4, IPv6, and packet sockets while retaining Unix IPC" + ); + } } diff --git a/crates/openshell-supervisor-process/src/ssh.rs b/crates/openshell-supervisor-process/src/ssh.rs index fbd6d9275b..14bf287e0c 100644 --- a/crates/openshell-supervisor-process/src/ssh.rs +++ b/crates/openshell-supervisor-process/src/ssh.rs @@ -1192,7 +1192,7 @@ impl Default for PtyRequest { } #[allow(clippy::too_many_arguments)] -fn apply_child_env( +pub(crate) fn apply_child_env( cmd: &mut Command, session_home: &str, session_user: &str, @@ -1368,7 +1368,7 @@ fn spawn_pty_shell( #[cfg(target_os = "linux")] let child_pid = child.id(); #[cfg(target_os = "linux")] - managed_children::register(child_pid); + let managed_child = managed_children::register(child_pid); let master_file = master; let (sender, receiver) = mpsc::channel::>(); @@ -1414,7 +1414,9 @@ fn spawn_pty_shell( std::thread::spawn(move || { let status = child.wait().ok(); #[cfg(target_os = "linux")] - managed_children::unregister(child_pid); + if let Some(child) = managed_child { + managed_children::unregister(child); + } let code = status.and_then(|s| s.code()).unwrap_or(1).unsigned_abs(); // Wait for the reader thread to finish forwarding all output before // sending exit-status and closing the channel. This prevents the @@ -1517,7 +1519,7 @@ fn spawn_pipe_exec( #[cfg(target_os = "linux")] let child_pid = child.id(); #[cfg(target_os = "linux")] - managed_children::register(child_pid); + let managed_child = managed_children::register(child_pid); let child_stdin = child.stdin.take(); let child_stdout = child.stdout.take().expect("stdout must be piped"); @@ -1589,7 +1591,9 @@ fn spawn_pipe_exec( std::thread::spawn(move || { let status = child.wait().ok(); #[cfg(target_os = "linux")] - managed_children::unregister(child_pid); + if let Some(child) = managed_child { + managed_children::unregister(child); + } let code = status.and_then(|s| s.code()).unwrap_or(1).unsigned_abs(); // Wait for both reader threads. let _ = reader_done_rx.recv_timeout(Duration::from_secs(2)); @@ -1604,7 +1608,7 @@ fn spawn_pipe_exec( Ok(sender) } -mod unsafe_pty { +pub(crate) mod unsafe_pty { #[cfg(not(target_os = "linux"))] use super::sandbox; use super::{ @@ -1623,6 +1627,23 @@ mod unsafe_pty { Ok(()) } + /// Install a pre-exec hook that gives the child a dedicated process group. + /// + /// Boundary-owned pipe execs use the child's PID as the process-group ID + /// for signal delivery and tree cleanup. Keep this separate from + /// [`install_pre_exec_no_pty`] so legacy SSH exec behavior is unchanged. + #[allow(unsafe_code)] + pub fn install_dedicated_process_group(cmd: &mut Command) { + unsafe { + cmd.pre_exec(|| { + if libc::setpgid(0, 0) < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } + #[allow(unsafe_code)] // `libc::TIOCSCTTY` is `u32` on macOS/BSD and `u64` on Linux; allow the // cross-platform conversion so the same expression compiles everywhere.