diff --git a/architecture/sandbox.md b/architecture/sandbox.md index cb7b32a29..14667206e 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -27,6 +27,17 @@ TCP Service, or VM vsock channel. Independent bidirectional `Exchange` RPCs carry lifecycle, exec, TCP, and forwarding traffic, while one persistent bidirectional `Mediate` RPC carries multiplexed DNS traffic. General application UDP is unsupported; UDP DNS remains mediated by the supervisor. +The sandbox probes HTTP/2 connection liveness every five seconds and closes +connections that miss a ten-second acknowledgement deadline. Closing a +connection cancels its stream bridges before releasing the exclusive DNS +mediation lease; an authenticated replacement waits for release instead of +preempting a live supervisor. Idle healthy connections remain usable. +Unauthenticated TLS handshakes have a separate bounded asynchronous pool and +five-second deadline, never consuming authenticated control slots or threads. +The socket broker reserves the TCP control-listener port against workload +connections, including loopback aliases. Unix control listeners reject workload +descendants using kernel peer credentials and process ancestry, while ordinary +workload loopback and Unix services remain available. NetworkPolicy is an outer reachability fence, not a confidentiality boundary. Each sandbox generation receives a fresh CA and distinct server/client leaves; both endpoints bind the same workload identity and immutable driver resource diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index cbb376c31..c701279e4 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -59,6 +59,11 @@ mod linux { }; const CONTROL_IO_TIMEOUT: Duration = Duration::from_secs(30); + const CONTROL_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5); + const CONTROL_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5); + const CONTROL_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(10); + const MEDIATION_REPLACEMENT_TIMEOUT: Duration = Duration::from_secs(20); + const MAX_PENDING_HANDSHAKES: usize = 32; const MAX_CONTROL_CONNECTIONS: usize = 128; const MAX_REPLAY_LEDGER_ENTRIES: usize = 4096; const MAX_RETAINED_EXEC_PROCESSES: usize = 64; @@ -119,7 +124,11 @@ mod linux { .map_err(|error| format!("install sandbox process prelude: {error}"))?; let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() .map_err(|error| format!("start sandbox workload launcher: {error}"))?; - let network_broker = NetworkBroker::start(listener) + let protected_control_port = match &config.listener { + BoundaryListenerConfig::TlsTcp { address, .. } => Some(address.port()), + BoundaryListenerConfig::Unix { .. } | BoundaryListenerConfig::Vsock { .. } => None, + }; + let network_broker = NetworkBroker::start(listener, protected_control_port) .map_err(|error| format!("start sandbox network broker: {error}"))?; let process_runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() @@ -349,6 +358,7 @@ mod linux { let listener = ControlListener::bind(config) .map_err(|error| format!("bind boundary control listener: {error}"))?; let active_connections = Arc::new(AtomicUsize::new(0)); + let pending_handshakes = Arc::new(tokio::sync::Semaphore::new(MAX_PENDING_HANDSHAKES)); tracing::info!(?config, "Boundary control listener ready"); loop { if BOUNDARY_TERMINATION_REQUESTED.load(Ordering::Acquire) { @@ -357,41 +367,31 @@ mod linux { } match listener.accept() { Ok(stream) => { - let Some(slot) = acquire_control_connection_slot(&active_connections) else { - tracing::warn!( - limit = MAX_CONTROL_CONNECTIONS, - "Boundary control connection limit reached" - ); + // Unauthenticated sockets use only a bounded async TLS + // task, never an OS thread or an authenticated session slot. + let Ok(pending) = pending_handshakes.clone().try_acquire_owned() else { continue; }; + let active_connections = active_connections.clone(); let runtime = runtime.clone(); - std::thread::spawn(move || { - let _slot = slot; - let stream = match stream.establish(&runtime.process_runtime) { - Ok(stream) => stream, - Err(error) => { - tracing::warn!(%error, "Boundary control transport handshake failed"); - return; + runtime.process_runtime.spawn({ + let runtime = runtime.clone(); + async move { + if let Err(error) = serve_control_connection( + stream, + runtime, + pending, + active_connections, + ) + .await + { + tracing::debug!(%error, "Boundary control connection ended"); } - }; - let result = { - let stream = match stream.into_tokio() { - Ok(stream) => stream, - Err(error) => { - tracing::warn!(%error, "prepare boundary gRPC session"); - return; - } - }; - runtime - .process_runtime - .block_on(serve_grpc(stream, runtime.clone())) - }; - if let Err(error) = result { - tracing::warn!(%error, "Boundary control session failed: {error}"); } }); } Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + Err(error) if error.kind() == io::ErrorKind::PermissionDenied => {} Err(error) if error.kind() == io::ErrorKind::WouldBlock => { std::thread::sleep(Duration::from_millis(10)); } @@ -400,12 +400,39 @@ mod linux { } } + async fn serve_control_connection( + stream: ControlStream, + runtime: Arc, + pending: tokio::sync::OwnedSemaphorePermit, + active_connections: Arc, + ) -> Result<(), String> { + let stream = stream + .establish_async(&runtime.process_runtime) + .await + .map_err(|error| format!("authenticate boundary transport: {error}"))?; + drop(pending); + let Some(_slot) = acquire_control_connection_slot(&active_connections) else { + return Err("authenticated control connection limit reached".to_string()); + }; + serve_grpc(stream.into_tokio()?, runtime).await + } + async fn serve_grpc( stream: openshell_isolation_interface::contract::BoundaryDuplexStream, runtime: Arc, ) -> Result<(), String> { - let incoming = tokio_stream::iter([Ok::<_, io::Error>(GrpcServerIo(stream))]); + let (connection_alive, connection_closed) = tokio::sync::watch::channel(()); + let incoming = tokio_stream::StreamExt::chain( + tokio_stream::iter([Ok::<_, io::Error>(GrpcServerIo { + stream, + _connection_alive: connection_alive, + })]), + tokio_stream::pending(), + ); + let mut shutdown = connection_closed.clone(); tonic::transport::Server::builder() + .http2_keepalive_interval(Some(CONTROL_KEEPALIVE_INTERVAL)) + .http2_keepalive_timeout(Some(CONTROL_KEEPALIVE_TIMEOUT)) .max_concurrent_streams( u32::try_from(MAX_CONTROL_CONNECTIONS) .map_err(|error| format!("invalid control connection limit: {error}"))?, @@ -413,16 +440,26 @@ mod linux { .initial_stream_window_size(16 * 1024 * 1024) .initial_connection_window_size(16 * 1024 * 1024) .add_service( - IsolationBoundaryServer::new(GrpcBoundaryService { runtime }) - .max_decoding_message_size(64 * 1024) - .max_encoding_message_size(64 * 1024), + IsolationBoundaryServer::new(GrpcBoundaryService { + runtime, + connection_closed, + }) + .max_decoding_message_size(64 * 1024) + .max_encoding_message_size(64 * 1024), ) - .serve_with_incoming(incoming) + .serve_with_incoming_shutdown(incoming, async move { + let _ = shutdown.changed().await; + }) .await .map_err(|error| format!("serve boundary gRPC connection: {error}")) } - struct GrpcServerIo(openshell_isolation_interface::contract::BoundaryDuplexStream); + struct GrpcServerIo { + stream: openshell_isolation_interface::contract::BoundaryDuplexStream, + // Dropping the actual HTTP/2 transport stops all detached stream + // bridges, including on keepalive failure or task cancellation. + _connection_alive: tokio::sync::watch::Sender<()>, + } impl tokio::io::AsyncRead for GrpcServerIo { fn poll_read( @@ -430,7 +467,7 @@ mod linux { context: &mut Context<'_>, buffer: &mut tokio::io::ReadBuf<'_>, ) -> Poll> { - Pin::new(&mut self.0).poll_read(context, buffer) + Pin::new(&mut self.stream).poll_read(context, buffer) } } @@ -440,18 +477,18 @@ mod linux { context: &mut Context<'_>, buffer: &[u8], ) -> Poll> { - Pin::new(&mut self.0).poll_write(context, buffer) + Pin::new(&mut self.stream).poll_write(context, buffer) } fn poll_flush(mut self: Pin<&mut Self>, context: &mut Context<'_>) -> Poll> { - Pin::new(&mut self.0).poll_flush(context) + Pin::new(&mut self.stream).poll_flush(context) } fn poll_shutdown( mut self: Pin<&mut Self>, context: &mut Context<'_>, ) -> Poll> { - Pin::new(&mut self.0).poll_shutdown(context) + Pin::new(&mut self.stream).poll_shutdown(context) } } @@ -464,6 +501,7 @@ mod linux { #[derive(Clone)] struct GrpcBoundaryService { runtime: Arc, + connection_closed: tokio::sync::watch::Receiver<()>, } type GrpcResponseStream = ReceiverStream>; @@ -477,7 +515,8 @@ mod linux { &self, request: tonic::Request>, ) -> Result, tonic::Status> { - let (stream, response) = bridge_grpc_server_stream(request.into_inner()); + let (stream, response) = + bridge_grpc_server_stream(request.into_inner(), self.connection_closed.clone()); let runtime = self.runtime.clone(); tokio::task::spawn_blocking(move || { let stream = ControlStream::Grpc { @@ -495,7 +534,8 @@ mod linux { &self, request: tonic::Request>, ) -> Result, tonic::Status> { - let (stream, response) = bridge_grpc_server_stream(request.into_inner()); + let (stream, response) = + bridge_grpc_server_stream(request.into_inner(), self.connection_closed.clone()); let runtime = self.runtime.clone(); tokio::spawn(async move { if let Err(error) = serve_persistent_mediation(stream, runtime).await { @@ -508,12 +548,17 @@ mod linux { fn bridge_grpc_server_stream( mut inbound: tonic::Streaming, + connection_closed: tokio::sync::watch::Receiver<()>, ) -> (tokio::io::DuplexStream, GrpcResponseStream) { let (application, bridge) = tokio::io::duplex(256 * 1024); let (mut reader, mut writer) = tokio::io::split(bridge); let (outbound, outbound_rx) = tokio::sync::mpsc::channel::>(64); + let mut inbound_closed = connection_closed.clone(); tokio::spawn(async move { + tokio::select! { + _ = inbound_closed.changed() => {}, + () = async { loop { match inbound.message().await { Ok(Some(chunk)) => { @@ -531,8 +576,14 @@ mod linux { } } } + } => {}, + } }); + let mut outbound_closed = connection_closed; tokio::spawn(async move { + tokio::select! { + _ = outbound_closed.changed() => {}, + () = async { let mut buffer = vec![0_u8; 16 * 1024]; loop { let read = match reader.read(&mut buffer).await { @@ -555,6 +606,8 @@ mod linux { return; } } + } => {}, + } }); (application, ReceiverStream::new(outbound_rx)) } @@ -574,41 +627,38 @@ mod linux { >, >; - struct MediationLease(Arc); - - impl Drop for MediationLease { - fn drop(&mut self) { - self.0.mediation_active.store(false, Ordering::Release); - } - } - async fn serve_persistent_mediation( mut stream: tokio::io::DuplexStream, runtime: Arc, ) -> Result<(), String> { - let request: RequestEnvelope = - openshell_isolation_interface::boundary_protocol::read_frame_async(&mut stream) - .await - .map_err(|error| format!("read mediation attach: {error}"))?; + let request: RequestEnvelope = tokio::time::timeout( + CONTROL_IO_TIMEOUT, + openshell_isolation_interface::boundary_protocol::read_frame_async(&mut stream), + ) + .await + .map_err(|_| "mediation attach timed out".to_string())? + .map_err(|error| format!("read mediation attach: {error}"))?; let request_id = request.request_id.clone(); if !matches!(request.request, Request::OpenMediation) { return Err("persistent mediation stream omitted OpenMediation".to_string()); } let mut response = runtime.dispatch(request); let lease = if matches!(response, Response::MediationReady) { - if runtime - .mediation_active - .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) - .is_ok() - { - Some(MediationLease(runtime.clone())) - } else { - response = guest_error( - BoundaryErrorKind::Denied, - "a mediation session is already active", - ); - None - } + tokio::time::timeout( + MEDIATION_REPLACEMENT_TIMEOUT, + runtime.mediation_active.lock(), + ) + .await + .map_or_else( + |_| { + response = guest_error( + BoundaryErrorKind::Denied, + "a mediation session is already active", + ); + None + }, + Some, + ) } else { None }; @@ -629,7 +679,7 @@ mod linux { return Ok(()); }; let broker = runtime.network_accept_context()?; - run_boundary_mediation(stream, runtime, broker).await + run_boundary_mediation(stream, runtime.clone(), broker).await } async fn run_boundary_mediation( @@ -967,7 +1017,7 @@ mod linux { /// any launch input or start a second workload. started_agent: Mutex>, next_exec_id: AtomicU64, - mediation_active: AtomicBool, + mediation_active: tokio::sync::Mutex<()>, next_mediation_stream_id: AtomicU64, exec_handles: Mutex>, /// Never evicted within a boundary generation. Reclaiming process I/O @@ -1164,7 +1214,7 @@ mod linux { attached_policy: Mutex::new(None), started_agent: Mutex::new(None), next_exec_id: AtomicU64::new(1), - mediation_active: AtomicBool::new(false), + mediation_active: tokio::sync::Mutex::new(()), next_mediation_stream_id: AtomicU64::new(1), exec_handles: Mutex::new(std::collections::HashMap::new()), exec_requests: Mutex::new(std::collections::HashSet::new()), @@ -2480,6 +2530,7 @@ mod linux { server_config, } => { let (stream, _) = listener.accept()?; + reject_workload_unix_peer(&stream)?; Ok(ControlStream::PendingTls { stream: PlainControlStream::Unix(stream), server_config: server_config.clone(), @@ -2502,6 +2553,74 @@ mod linux { } } + fn reject_workload_unix_peer(stream: &std::os::unix::net::UnixStream) -> io::Result<()> { + let mut credentials = libc::ucred { + pid: 0, + uid: 0, + gid: 0, + }; + let mut length = + libc::socklen_t::try_from(size_of::()).map_err(io::Error::other)?; + // SAFETY: both output pointers reference initialized storage of the + // declared length, and stream owns the connected Unix descriptor. + if unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + (&raw mut credentials).cast(), + &raw mut length, + ) + } != 0 + { + return Err(io::Error::last_os_error()); + } + let peer = u32::try_from(credentials.pid) + .map_err(|_| io::Error::from_raw_os_error(libc::EACCES))?; + // Linux reports PID zero for a peer outside our PID namespace. Such a + // peer still must authenticate with the per-sandbox mTLS certificate. + if peer != 0 + && peer != std::process::id() + && is_process_descendant(peer, std::process::id()) + .map_err(|_| io::Error::from_raw_os_error(libc::EACCES))? + { + return Err(io::Error::from_raw_os_error(libc::EACCES)); + } + Ok(()) + } + + fn is_process_descendant(mut process: u32, ancestor: u32) -> io::Result { + // Drivers run the sandbox as workload PID 1, so orphaned descendants + // reparent to it and cannot escape this check by double-forking. + // Read kernel-owned ancestry, never workload-supplied paths or UIDs. + // If a peer exits during inspection, fail closed for that connection. + for _ in 0..1024 { + if process == ancestor { + return Ok(true); + } + if process == 0 { + return Ok(false); + } + let stat = std::fs::read_to_string(format!("/proc/{process}/stat"))?; + let parent = stat + .rsplit_once(')') + .and_then(|(_, fields)| fields.split_whitespace().nth(1)) + .ok_or_else(|| { + io::Error::new(io::ErrorKind::InvalidData, "missing peer process parent") + })? + .parse::() + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if parent == process { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "cyclic peer process ancestry", + )); + } + process = parent; + } + Err(io::Error::from_raw_os_error(libc::EACCES)) + } + fn remove_owned_stale_control_socket(socket_path: &Path) -> io::Result<()> { let metadata = match std::fs::symlink_metadata(socket_path) { Ok(metadata) => metadata, @@ -2639,7 +2758,12 @@ mod linux { } impl ControlStream { + #[cfg(test)] fn establish(self, runtime: &tokio::runtime::Handle) -> io::Result { + runtime.block_on(self.establish_async(runtime)) + } + + async fn establish_async(self, runtime: &tokio::runtime::Handle) -> io::Result { let Self::PendingTls { stream, server_config, @@ -2652,14 +2776,14 @@ mod linux { stream.into_tokio()? }; let acceptor = tokio_rustls::TlsAcceptor::from(server_config); - let stream = runtime.block_on(async { - tokio::time::timeout(CONTROL_IO_TIMEOUT, acceptor.accept(stream)) + let stream = { + tokio::time::timeout(CONTROL_HANDSHAKE_TIMEOUT, acceptor.accept(stream)) .await .map_err(|_| { io::Error::new(io::ErrorKind::TimedOut, "boundary TLS handshake timed out") })? .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) - })?; + }?; Ok(Self::Tls { stream: Box::new(stream), runtime: runtime.clone(), @@ -2996,7 +3120,7 @@ mod linux { } #[test] - fn control_connection_slots_bound_unauthenticated_threads() { + fn control_connection_slots_bound_authenticated_sessions() { let active = Arc::new(AtomicUsize::new(MAX_CONTROL_CONNECTIONS - 1)); let slot = acquire_control_connection_slot(&active).expect("last available slot"); assert!(acquire_control_connection_slot(&active).is_none()); @@ -3004,6 +3128,273 @@ mod linux { assert_eq!(active.load(Ordering::Acquire), MAX_CONTROL_CONNECTIONS - 1); } + #[test] + fn unix_control_rejects_workload_descendants_before_admission() { + const CHILD_SOCKET: &str = "OPENSHELL_TEST_CONTROL_PEER_SOCKET"; + if let Some(path) = std::env::var_os(CHILD_SOCKET) { + let mut stream = std::os::unix::net::UnixStream::connect(path).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + assert_eq!(stream.read(&mut [0_u8; 1]).unwrap(), 0); + return; + } + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("control.sock"); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let mut child = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "boundary_server::linux::tests::unix_control_rejects_workload_descendants_before_admission", "--nocapture"]) + .env(CHILD_SOCKET, &path).spawn().unwrap(); + let (stream, _) = listener.accept().unwrap(); + assert_eq!( + reject_workload_unix_peer(&stream) + .unwrap_err() + .raw_os_error(), + Some(libc::EACCES) + ); + drop(stream); + assert!(child.wait().unwrap().success()); + assert!(!is_process_descendant(std::process::id(), child.id()).unwrap()); + // Trusted same-process connections and external ancestors remain + // eligible for mTLS; we do not equate same UID with workload trust. + let client = std::os::unix::net::UnixStream::connect(&path).unwrap(); + let (stream, _) = listener.accept().unwrap(); + reject_workload_unix_peer(&stream).unwrap(); + drop(client); + } + + fn availability_test_runtime() -> Arc { + let (broker, launcher) = test_network_broker(); + Arc::new(BoundaryRuntime::new( + BoundaryConfig { + boundary_id: "availability".to_string(), + generation: "generation-1".to_string(), + session_epoch: "session-1".to_string(), + bootstrap_token: "a".repeat(32), + listener: BoundaryListenerConfig::TlsTcp { + address: "127.0.0.1:5500".parse().unwrap(), + tls: placeholder_server_tls(), + }, + resource_claims: std::collections::BTreeMap::new(), + resource_claim_files: std::collections::BTreeMap::new(), + workload_identity: test_workload_identity(), + driver_fence: test_driver_fence(), + child_env: std::collections::HashMap::new(), + }, + tokio::runtime::Handle::current(), + broker, + launcher, + test_runtime_qualification(), + )) + } + + #[tokio::test(flavor = "multi_thread")] + async fn idle_uds_and_tcp_handshakes_do_not_consume_authenticated_slots() { + let directory = tempfile::tempdir().unwrap(); + let (tls, _) = stage_test_tls(directory.path(), "pending"); + let server_config = Arc::new(load_tls_server_config(&tls).unwrap()); + let runtime = availability_test_runtime(); + let active = Arc::new(AtomicUsize::new(0)); + let pending = Arc::new(tokio::sync::Semaphore::new(MAX_PENDING_HANDSHAKES)); + let mut clients: Vec> = Vec::new(); + let mut tasks = tokio::task::JoinSet::new(); + for index in 0..MAX_PENDING_HANDSHAKES { + let stream = if index % 2 == 0 { + let (server, client) = std::os::unix::net::UnixStream::pair().unwrap(); + clients.push(Box::new(client)); + PlainControlStream::Unix(server) + } else { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + clients.push(Box::new( + std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(), + )); + PlainControlStream::Tcp(listener.accept().unwrap().0) + }; + let permit = pending.clone().try_acquire_owned().unwrap(); + tasks.spawn(serve_control_connection( + ControlStream::PendingTls { + stream, + server_config: server_config.clone(), + }, + runtime.clone(), + permit, + active.clone(), + )); + } + assert!(pending.clone().try_acquire_owned().is_err()); + assert_eq!(active.load(Ordering::Acquire), 0); + tokio::time::timeout(CONTROL_HANDSHAKE_TIMEOUT + Duration::from_secs(2), async { + while let Some(result) = tasks.join_next().await { + assert!(result.unwrap().unwrap_err().contains("timed out")); + } + }) + .await + .unwrap(); + assert_eq!(pending.available_permits(), MAX_PENDING_HANDSHAKES); + assert_eq!(active.load(Ordering::Acquire), 0); + drop(clients); + } + + #[tokio::test(flavor = "multi_thread")] + async fn grpc_blackhole_expires_connection_and_releases_mediation_lease() { + let runtime = availability_test_runtime(); + let server_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let server_address = server_listener.local_addr().unwrap(); + let server_runtime = runtime.clone(); + let server = tokio::spawn(async move { + let (stream, _) = server_listener.accept().await.unwrap(); + serve_grpc(Box::new(stream), server_runtime).await + }); + let proxy_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_address = proxy_listener.local_addr().unwrap(); + let (blackhole, stop_forwarding) = tokio::sync::oneshot::channel::<()>(); + let proxy = tokio::spawn(async move { + let (mut downstream, _) = proxy_listener.accept().await.unwrap(); + let mut upstream = tokio::net::TcpStream::connect(server_address) + .await + .unwrap(); + tokio::select! { + _ = stop_forwarding => {}, + _ = tokio::io::copy_bidirectional(&mut upstream, &mut downstream) => panic!("proxy closed before blackhole"), + } + // Keep both sockets open without forwarding PING or ACK: this + // models a silently dropped Kubernetes TCP path, not FIN/RST. + std::future::pending::<()>().await; + drop((upstream, downstream)); + }); + let channel = + tonic::transport::Endpoint::from_shared(format!("http://{proxy_address}")) + .unwrap() + .connect() + .await + .unwrap(); + let (sender, receiver) = tokio::sync::mpsc::channel(4); + let request = RequestEnvelope::new( + "availability".to_string(), + "a".repeat(32), + Request::OpenMediation, + ) + .unwrap(); + sender + .send(BoundaryChunk { + data: encode_frame(&request).unwrap(), + }) + .await + .unwrap(); + let mut response = IsolationBoundaryClient::new(channel) + .mediate(ReceiverStream::new(receiver)) + .await + .unwrap() + .into_inner(); + assert!(response.message().await.unwrap().is_some()); + tokio::time::sleep(CONTROL_KEEPALIVE_INTERVAL + Duration::from_secs(1)).await; + assert!( + !server.is_finished(), + "healthy idle session must survive keepalive" + ); + assert!(runtime.mediation_active.try_lock().is_err()); + blackhole.send(()).unwrap(); + tokio::time::timeout( + CONTROL_KEEPALIVE_INTERVAL + CONTROL_KEEPALIVE_TIMEOUT + Duration::from_secs(3), + server, + ) + .await + .expect("blackholed HTTP/2 connection must expire") + .unwrap() + .unwrap(); + tokio::time::timeout(Duration::from_secs(1), async { + while runtime.mediation_active.try_lock().is_err() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("connection teardown must stop all bridges and release lease"); + let (mut replacement, task) = request_test_mediation(runtime, &"a".repeat(32)).await; + let ready: ResponseEnvelope = + openshell_isolation_interface::boundary_protocol::read_frame_async( + &mut replacement, + ) + .await + .unwrap(); + assert!(matches!(ready.response, Response::MediationReady)); + drop(replacement); + task.await.unwrap().unwrap(); + drop(sender); + proxy.abort(); + } + + async fn request_test_mediation( + runtime: Arc, + token: &str, + ) -> ( + tokio::io::DuplexStream, + tokio::task::JoinHandle>, + ) { + let (mut client, server) = tokio::io::duplex(4096); + let task = tokio::spawn(serve_persistent_mediation(server, runtime)); + let envelope = RequestEnvelope::new( + "availability".to_string(), + token.to_string(), + Request::OpenMediation, + ) + .unwrap(); + client + .write_all(&encode_frame(&envelope).unwrap()) + .await + .unwrap(); + (client, task) + } + + #[tokio::test(flavor = "multi_thread")] + async fn mediation_replacement_waits_for_lease_and_rejects_bad_authentication() { + let runtime = availability_test_runtime(); + let (mut first, first_task) = + request_test_mediation(runtime.clone(), &"a".repeat(32)).await; + let ready: ResponseEnvelope = + openshell_isolation_interface::boundary_protocol::read_frame_async(&mut first) + .await + .unwrap(); + assert!(matches!(ready.response, Response::MediationReady)); + let (mut denied, denied_task) = + request_test_mediation(runtime.clone(), &"b".repeat(32)).await; + let response: ResponseEnvelope = + openshell_isolation_interface::boundary_protocol::read_frame_async(&mut denied) + .await + .unwrap(); + assert!(matches!( + response.response, + Response::Error { + kind: BoundaryErrorKind::Denied, + .. + } + )); + denied_task.await.unwrap().unwrap(); + let (mut replacement, replacement_task) = + request_test_mediation(runtime.clone(), &"a".repeat(32)).await; + assert!( + tokio::time::timeout(Duration::from_millis(50), replacement.read_u8()) + .await + .is_err(), + "a live lease cannot be preempted" + ); + drop(first); + first_task.await.unwrap().unwrap(); + let ready: ResponseEnvelope = tokio::time::timeout( + Duration::from_secs(1), + openshell_isolation_interface::boundary_protocol::read_frame_async( + &mut replacement, + ), + ) + .await + .unwrap() + .unwrap(); + assert!(matches!(ready.response, Response::MediationReady)); + assert!(runtime.mediation_active.try_lock().is_err()); + drop(replacement); + replacement_task.await.unwrap().unwrap(); + assert!(runtime.mediation_active.try_lock().is_ok()); + } + fn test_workload_identity() -> ResolvedWorkloadIdentity { let mut supplementary_gids = nix::unistd::getgroups() .unwrap() diff --git a/crates/openshell-sandbox/src/network_broker.rs b/crates/openshell-sandbox/src/network_broker.rs index f97a7c832..549cc509a 100644 --- a/crates/openshell-sandbox/src/network_broker.rs +++ b/crates/openshell-sandbox/src/network_broker.rs @@ -187,6 +187,7 @@ fn register_dns_socket( #[derive(Clone)] struct NotificationQueues { + protected_control_port: Option, accept_registrar: crate::accept_interrupt::AcceptRegistrar, identity_resolver: ProcfsIdentityResolver, pending: mpsc::Sender, @@ -207,8 +208,11 @@ pub struct NetworkBroker { } impl NetworkBroker { - pub(crate) fn start(listener: NotificationListener) -> io::Result { - Self::start_with_dns_address(listener, DNS_RELAY_ADDRESS) + pub(crate) fn start( + listener: NotificationListener, + protected_control_port: Option, + ) -> io::Result { + Self::start_with_dns_address(listener, DNS_RELAY_ADDRESS, protected_control_port) } #[cfg(any(test, feature = "perf-harness"))] @@ -216,19 +220,27 @@ impl NetworkBroker { Self::start_with_dns_address( listener, SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), + None, ) } fn start_with_dns_address( listener: NotificationListener, dns_address: SocketAddr, + protected_control_port: Option, ) -> io::Result { - Self::start_with_decision_timeout(listener, dns_address, NETWORK_DECISION_TIMEOUT) + Self::start_with_decision_timeout( + listener, + dns_address, + protected_control_port, + NETWORK_DECISION_TIMEOUT, + ) } fn start_with_decision_timeout( listener: NotificationListener, dns_address: SocketAddr, + protected_control_port: Option, decision_timeout: Duration, ) -> io::Result { let listener = Arc::new(listener); @@ -244,6 +256,7 @@ impl NetworkBroker { let dns_relay = start_dns_relay(dns_address, pending_dns_tx)?; let dns_address = dns_relay.address; let queues = NotificationQueues { + protected_control_port, accept_registrar: accept_monitor.registrar(), identity_resolver: ProcfsIdentityResolver::for_pid_namespace(), pending: pending_tx, @@ -616,6 +629,19 @@ fn create_socket( Ok(()) } +fn reject_protected_control_destination( + destination: SocketAddr, + protected_port: Option, +) -> io::Result<()> { + // Reserve the listener's port across loopback aliases, IPv4-mapped IPv6, + // and wildcard Pod listeners. A workload must never reach its control + // endpoint, including through a supervisor-authorized external relay. + if protected_port == Some(destination.port()) { + return Err(io::Error::from_raw_os_error(libc::EACCES)); + } + Ok(()) +} + fn connect_socket( registry: Arc>, listener: Arc, @@ -628,6 +654,7 @@ fn connect_socket( active_opens, identity_resolver, decision_timeout, + protected_control_port, .. } = queues; let notification_started = Instant::now(); @@ -661,6 +688,7 @@ fn connect_socket( } let destination = read_socket_addr(notification.tid, notification.args[1], notification.args[2])?; + reject_protected_control_destination(destination, protected_control_port)?; let (kind, socket_identity, nonblocking) = { let registry = lock(®istry); let entry = registry.resolve(notification.tid, fd)?; @@ -1771,6 +1799,7 @@ mod tests { let broker = NetworkBroker::start_with_decision_timeout( listener, "127.0.0.1:0".parse().unwrap(), + None, Duration::from_millis(50), ) .unwrap(); @@ -1875,6 +1904,58 @@ mod tests { } } + #[test] + fn protected_control_port_rejects_loopback_aliases_and_pod_addresses() { + for address in [ + "127.0.0.1:7443", + "127.0.0.2:7443", + "[::1]:7443", + "[::ffff:127.0.0.1]:7443", + "10.42.0.8:7443", + ] { + assert_eq!( + reject_protected_control_destination(address.parse().unwrap(), Some(7443)) + .unwrap_err() + .raw_os_error(), + Some(libc::EACCES) + ); + } + assert!( + reject_protected_control_destination("127.0.0.1:8080".parse().unwrap(), Some(7443)) + .is_ok() + ); + } + + #[test] + fn workload_cannot_fill_control_listener_with_loopback_connections() { + let control = TcpListener::bind("127.0.0.1:0").unwrap(); + control.set_nonblocking(true).unwrap(); + let address = control.local_addr().unwrap(); + let (launcher, listener) = + openshell_isolation_interface::linux::workload_launcher::start().unwrap(); + let _broker = NetworkBroker::start_with_dns_address( + listener, + "127.0.0.1:0".parse().unwrap(), + Some(address.port()), + ) + .unwrap(); + launcher + .execute(move || -> io::Result<()> { + for _ in 0..160 { + let error = + TcpStream::connect(address).expect_err("control port must be unreachable"); + assert_eq!(error.raw_os_error(), Some(libc::EACCES)); + } + Ok(()) + }) + .unwrap() + .unwrap(); + assert_eq!( + control.accept().unwrap_err().kind(), + io::ErrorKind::WouldBlock + ); + } + #[test] fn pending_external_open_slots_are_bounded_and_reusable() { let active = Arc::new(AtomicUsize::new(OPEN_QUEUE_CAPACITY - 1)); diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs index d893b828b..c18b633e7 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs @@ -434,17 +434,6 @@ pub fn enforce(prepared: PreparedRuleset) -> Result<()> { Ok(()) } -/// Legacy single-phase apply. Kept for non-Linux platforms and tests. -/// On Linux, callers should use [`prepare`] + [`enforce`] for correct -/// privilege ordering. -#[allow(dead_code)] // Retained for backward compat; live callers use prepare+enforce. -pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { - if let Some(prepared) = prepare(policy, workdir)? { - enforce(prepared)?; - } - Ok(()) -} - /// Tailor a rule's access mask to the inode referenced by its already-open FD. /// /// Landlock directory-only rights such as `ReadDir` are invalid for regular diff --git a/crates/openshell-sandbox/src/sandbox/linux/mod.rs b/crates/openshell-sandbox/src/sandbox/linux/mod.rs index 523d33bd0..3f0084450 100644 --- a/crates/openshell-sandbox/src/sandbox/linux/mod.rs +++ b/crates/openshell-sandbox/src/sandbox/linux/mod.rs @@ -102,15 +102,6 @@ pub fn apply_supervisor_prelude() -> Result<()> { seccomp::apply_supervisor_prelude() } -/// Legacy single-phase apply. Kept for backward compatibility. -/// New callers should use [`prepare`] + [`enforce`] for correct privilege ordering. -#[allow(dead_code)] // Retained for backward compat; live callers use prepare+enforce. -pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { - landlock::apply(policy, workdir)?; - seccomp::apply(policy)?; - Ok(()) -} - /// Probe Landlock availability and emit OCSF logs from the parent process. /// /// This must be called **before** `pre_exec` / `fork()` so that the OCSF events diff --git a/crates/openshell-sandbox/src/sandbox/mod.rs b/crates/openshell-sandbox/src/sandbox/mod.rs index ff44f8ba1..bcdb800f7 100644 --- a/crates/openshell-sandbox/src/sandbox/mod.rs +++ b/crates/openshell-sandbox/src/sandbox/mod.rs @@ -4,6 +4,7 @@ //! Platform sandboxing implementation. use miette::Result; +#[cfg(not(target_os = "linux"))] use openshell_core::policy::SandboxPolicy; #[cfg(target_os = "linux")] @@ -16,15 +17,9 @@ pub mod linux; /// Returns an error if the sandbox cannot be applied. // On Linux the spawn path uses `prepare`+`enforce` directly; this single-phase // apply is only invoked from the non-Linux spawn_impl. -#[cfg_attr(target_os = "linux", allow(dead_code))] -#[cfg_attr(not(target_os = "linux"), allow(clippy::unnecessary_wraps))] +#[cfg(not(target_os = "linux"))] +#[allow(clippy::unnecessary_wraps)] pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { - #[cfg(target_os = "linux")] - { - linux::apply(policy, workdir) - } - - #[cfg(not(target_os = "linux"))] { let _ = (policy, workdir); openshell_ocsf::ocsf_emit!( diff --git a/crates/openshell-supervisor-process/src/child_env.rs b/crates/openshell-supervisor-process/src/child_env.rs deleted file mode 100644 index 32eecbee3..000000000 --- a/crates/openshell-supervisor-process/src/child_env.rs +++ /dev/null @@ -1,91 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::path::Path; - -const LOCAL_NO_PROXY: &str = "127.0.0.1,localhost,::1"; - -pub fn proxy_env_vars(proxy_url: &str) -> [(&'static str, String); 9] { - [ - ("ALL_PROXY", proxy_url.to_owned()), - ("HTTP_PROXY", proxy_url.to_owned()), - ("HTTPS_PROXY", proxy_url.to_owned()), - ("NO_PROXY", LOCAL_NO_PROXY.to_owned()), - ("http_proxy", proxy_url.to_owned()), - ("https_proxy", proxy_url.to_owned()), - ("no_proxy", LOCAL_NO_PROXY.to_owned()), - ("grpc_proxy", proxy_url.to_owned()), - // Node.js only honors HTTP(S)_PROXY for built-in fetch/http clients when - // proxy support is explicitly enabled at process startup. - ("NODE_USE_ENV_PROXY", "1".to_owned()), - ] -} - -pub fn tls_env_vars( - ca_cert_path: &Path, - combined_bundle_path: &Path, -) -> [(&'static str, String); 6] { - let ca_cert_path = ca_cert_path.display().to_string(); - let combined_bundle_path = combined_bundle_path.display().to_string(); - [ - ("NODE_EXTRA_CA_CERTS", ca_cert_path.clone()), - ("DENO_CERT", ca_cert_path), - ("SSL_CERT_FILE", combined_bundle_path.clone()), - ("REQUESTS_CA_BUNDLE", combined_bundle_path.clone()), - ("CURL_CA_BUNDLE", combined_bundle_path.clone()), - // Ubuntu Noble's git links against libcurl-gnutls, which ignores SSL_CERT_FILE. - // git reads GIT_SSL_CAINFO (or http.sslCAInfo) to locate the CA bundle. - ("GIT_SSL_CAINFO", combined_bundle_path), - ] -} - -#[cfg(test)] -mod tests { - use super::*; - use std::process::Command; - use std::process::Stdio; - - #[test] - fn apply_proxy_env_includes_node_proxy_opt_in_and_local_bypass() { - let mut cmd = Command::new("/usr/bin/env"); - cmd.stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - - for (key, value) in proxy_env_vars("http://10.200.0.1:3128") { - cmd.env(key, value); - } - - let output = cmd.output().expect("spawn env"); - let stdout = String::from_utf8(output.stdout).expect("utf8"); - - assert!(stdout.contains("HTTP_PROXY=http://10.200.0.1:3128")); - assert!(stdout.contains("NO_PROXY=127.0.0.1,localhost,::1")); - assert!(stdout.contains("NODE_USE_ENV_PROXY=1")); - assert!(stdout.contains("no_proxy=127.0.0.1,localhost,::1")); - } - - #[test] - fn apply_tls_env_sets_node_and_bundle_paths() { - let mut cmd = Command::new("/usr/bin/env"); - cmd.stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - - let ca_cert_path = Path::new("/etc/openshell-tls/openshell-ca.pem"); - let combined_bundle_path = Path::new("/etc/openshell-tls/ca-bundle.pem"); - for (key, value) in tls_env_vars(ca_cert_path, combined_bundle_path) { - cmd.env(key, value); - } - - let output = cmd.output().expect("spawn env"); - let stdout = String::from_utf8(output.stdout).expect("utf8"); - - assert!(stdout.contains("NODE_EXTRA_CA_CERTS=/etc/openshell-tls/openshell-ca.pem")); - assert!(stdout.contains("DENO_CERT=/etc/openshell-tls/openshell-ca.pem")); - assert!(stdout.contains("SSL_CERT_FILE=/etc/openshell-tls/ca-bundle.pem")); - assert!(stdout.contains("REQUESTS_CA_BUNDLE=/etc/openshell-tls/ca-bundle.pem")); - assert!(stdout.contains("CURL_CA_BUNDLE=/etc/openshell-tls/ca-bundle.pem")); - assert!(stdout.contains("GIT_SSL_CAINFO=/etc/openshell-tls/ca-bundle.pem")); - } -} diff --git a/crates/openshell-supervisor-process/src/identity.rs b/crates/openshell-supervisor-process/src/identity.rs deleted file mode 100644 index df79a4137..000000000 --- a/crates/openshell-supervisor-process/src/identity.rs +++ /dev/null @@ -1,833 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Driver identity normalization and OCI `USER` resolution. - -use crate::process::ResolvedProcessIdentity; -use miette::{IntoDiagnostic, Result}; -use openshell_core::policy::SandboxPolicy; -use std::fs::{File, OpenOptions}; -use std::io::Read; -use std::os::unix::fs::OpenOptionsExt; -use std::path::Path; - -const PASSWD_PATH: &str = "/etc/passwd"; -const GROUP_PATH: &str = "/etc/group"; -const MAX_ACCOUNT_FILE_SIZE: u64 = 1024 * 1024; -const MAX_ACCOUNT_LINE_SIZE: usize = 8 * 1024; -const MAX_ACCOUNT_FIELD_SIZE: usize = 1024; - -/// Identity input selected by the active compute driver. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum DriverIdentity { - /// Platform-selected identity used by Kubernetes and `OpenShift`. - Resolved { uid: u32, gid: u32 }, - /// Raw OCI `Config.User` selected by Docker and Podman. - OciUser { declaration: String }, - /// Drivers with no authoritative identity metadata. - None, -} - -impl DriverIdentity { - /// Normalize the protected driver environment into one identity variant. - pub fn from_env() -> Result { - let oci_user = optional_utf8_env(openshell_core::sandbox_env::OCI_IMAGE_USER)?; - let uid = optional_nonempty_utf8_env(openshell_core::sandbox_env::SANDBOX_UID)?; - let gid = optional_nonempty_utf8_env(openshell_core::sandbox_env::SANDBOX_GID)?; - Self::from_values(oci_user, uid, gid) - } - - fn from_values( - oci_user: Option, - uid: Option, - gid: Option, - ) -> Result { - // Resolved-identity drivers explicitly clear the OCI declaration so - // an image-baked or user-supplied value cannot select the OCI path. - // Preserve an empty declaration when no resolved pair is present: - // Docker and Podman use that state to reject images without USER. - let oci_user = if oci_user.as_deref() == Some("") && (uid.is_some() || gid.is_some()) { - None - } else { - oci_user - }; - - match (oci_user, uid, gid) { - (Some(declaration), None, None) => Ok(Self::OciUser { declaration }), - (None, Some(uid), Some(gid)) => { - let uid = uid.parse::().ok().filter(|uid| { - (openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID) - .contains(uid) - }); - let gid = gid.parse::().ok().filter(|gid| { - (openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID) - .contains(gid) - }); - let (Some(uid), Some(gid)) = (uid, gid) else { - return Err(miette::miette!( - "driver UID/GID must be numeric identities in range [{}, {}]", - openshell_policy::MIN_SANDBOX_UID, - openshell_policy::MAX_SANDBOX_UID - )); - }; - Ok(Self::Resolved { uid, gid }) - } - (None, None, None) => Ok(Self::None), - (Some(_), _, _) => Err(miette::miette!( - "{} conflicts with non-empty {}/{} driver identity", - openshell_core::sandbox_env::OCI_IMAGE_USER, - openshell_core::sandbox_env::SANDBOX_UID, - openshell_core::sandbox_env::SANDBOX_GID - )), - (None, _, _) => Err(miette::miette!( - "{} and {} must be supplied together", - openshell_core::sandbox_env::SANDBOX_UID, - openshell_core::sandbox_env::SANDBOX_GID - )), - } - } -} - -/// Apply a driver identity before any workload child becomes reachable. -pub fn resolve_process_identity( - policy: &mut SandboxPolicy, - driver_identity: &DriverIdentity, -) -> Result { - match driver_identity { - DriverIdentity::Resolved { uid, gid } => { - policy.process.run_as_user = Some(uid.to_string()); - policy.process.run_as_group = Some(gid.to_string()); - // Kubernetes/OpenShift already supply numeric policy values and - // retain their existing privilege-drop path. - Ok(ResolvedProcessIdentity::default()) - } - DriverIdentity::OciUser { declaration } => resolve_oci_process_identity_at( - policy, - declaration, - Path::new(PASSWD_PATH), - Path::new(GROUP_PATH), - ), - DriverIdentity::None => { - // VM/offline drivers retain the pre-OCI per-field fallback. A - // partial policy must never leave the omitted component at the - // root supervisor identity. - if policy - .process - .run_as_user - .as_deref() - .is_none_or(str::is_empty) - { - policy.process.run_as_user = Some("sandbox".into()); - } - if policy - .process - .run_as_group - .as_deref() - .is_none_or(str::is_empty) - { - policy.process.run_as_group = Some("sandbox".into()); - } - Ok(ResolvedProcessIdentity::default()) - } - } -} - -#[allow(clippy::similar_names)] -fn resolve_oci_process_identity_at( - policy: &mut SandboxPolicy, - declaration: &str, - passwd_path: &Path, - group_path: &Path, -) -> Result { - let explicit_user = policy - .process - .run_as_user - .as_deref() - .is_some_and(|value| !value.is_empty()); - let explicit_group = policy - .process - .run_as_group - .as_deref() - .is_some_and(|value| !value.is_empty()); - - if explicit_user && explicit_group { - return Ok(ResolvedProcessIdentity::default()); - } - - let (oci_user, oci_group) = split_oci_declaration(declaration); - let needs_primary_gid = !explicit_group && oci_group.is_none(); - let resolved_user = if !explicit_user || needs_primary_gid { - Some(resolve_required_oci_user( - oci_user, - passwd_path, - declaration, - needs_primary_gid, - )?) - } else { - None - }; - - let oci_uid = if explicit_user { - None - } else { - Some( - resolved_user - .as_ref() - .expect("omitted OCI user must have been resolved") - .0, - ) - }; - - if !explicit_user { - policy.process.run_as_user = Some(oci_user.to_string()); - } - - let oci_gid = if explicit_group { - None - } else { - let (group_value, gid) = match oci_group { - Some(group) if !group.is_empty() => { - let gid = validate_oci_group(group, group_path, declaration)?; - (group.to_string(), gid) - } - Some(_) => { - return Err(miette::miette!( - "OCI USER '{declaration}' has an empty group component" - )); - } - None => { - let gid = resolved_user - .and_then(|(_, primary_gid)| primary_gid) - .ok_or_else(|| { - miette::miette!( - "OCI USER '{declaration}' uses a numeric UID without an explicit group, \ - but /etc/passwd has no matching primary GID" - ) - })?; - (gid.to_string(), gid) - } - }; - policy.process.run_as_group = Some(group_value); - Some(gid) - }; - - Ok(ResolvedProcessIdentity::new(oci_uid, oci_gid)) -} - -fn split_oci_declaration(declaration: &str) -> (&str, Option<&str>) { - declaration - .split_once(':') - .map_or((declaration, None), |(user, group)| (user, Some(group))) -} - -fn resolve_required_oci_user( - user: &str, - passwd_path: &Path, - declaration: &str, - require_primary_gid: bool, -) -> Result<(u32, Option)> { - if user.is_empty() { - return Err(miette::miette!( - "OCI USER is required because run_as_user is omitted" - )); - } - validate_component(user, "OCI user")?; - if user == "root" { - return Err(miette::miette!("OCI USER '{declaration}' selects root")); - } - if let Ok(uid) = user.parse::() { - if uid == 0 { - return Err(miette::miette!("OCI USER '{declaration}' selects UID 0")); - } - let primary_gid = if require_primary_gid { - find_passwd_by_uid(passwd_path, uid)?.map(|entry| entry.gid) - } else { - None - }; - if primary_gid == Some(0) { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited primary GID 0" - )); - } - return Ok((uid, primary_gid)); - } - let entry = find_passwd_by_name(passwd_path, user)? - .ok_or_else(|| miette::miette!("OCI USER name '{user}' was not found in /etc/passwd"))?; - if entry.uid == 0 { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited UID 0" - )); - } - if require_primary_gid && entry.gid == 0 { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited primary GID 0" - )); - } - Ok((entry.uid, require_primary_gid.then_some(entry.gid))) -} - -fn validate_oci_group(value: &str, group_path: &Path, declaration: &str) -> Result { - validate_component(value, "OCI group")?; - if value == "root" { - return Err(miette::miette!( - "OCI USER '{declaration}' selects root group" - )); - } - let gid = if let Ok(gid) = value.parse::() { - gid - } else { - find_group_by_name(group_path, value)? - .ok_or_else(|| miette::miette!("OCI group '{value}' was not found in /etc/group"))? - .gid - }; - if gid == 0 { - return Err(miette::miette!( - "OCI USER '{declaration}' resolves to prohibited GID 0" - )); - } - Ok(gid) -} - -fn validate_component(value: &str, kind: &str) -> Result<()> { - if value.is_empty() - || value.len() > MAX_ACCOUNT_FIELD_SIZE - || value.trim() != value - || value.chars().any(|ch| ch.is_control() || ch == ':') - { - return Err(miette::miette!("{kind} component '{value}' is malformed")); - } - Ok(()) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct PasswdEntry { - uid: u32, - gid: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct GroupEntry { - gid: u32, -} - -fn find_passwd_by_name(path: &Path, name: &str) -> Result> { - find_unique(path, |fields| { - (fields.first().copied() == Some(name)).then(|| parse_passwd(fields)) - }) -} - -fn find_passwd_by_uid(path: &Path, uid: u32) -> Result> { - find_unique(path, |fields| { - fields - .get(2) - .and_then(|value| value.parse::().ok()) - .filter(|candidate| *candidate == uid) - .map(|_| parse_passwd(fields)) - }) -} - -fn find_group_by_name(path: &Path, name: &str) -> Result> { - find_unique(path, |fields| { - (fields.first().copied() == Some(name)).then(|| parse_group(fields)) - }) -} - -/// Resolve supplementary groups declared for an OCI named user without -/// consulting NSS. Numeric OCI users have no trustworthy group-membership -/// name and therefore receive no supplementary groups. -pub fn resolve_oci_supplementary_gids(declaration: &str, primary_gid: u32) -> Result> { - resolve_oci_supplementary_gids_at(declaration, primary_gid, Path::new(GROUP_PATH)) -} - -fn resolve_oci_supplementary_gids_at( - declaration: &str, - primary_gid: u32, - group_path: &Path, -) -> Result> { - let (user, _) = split_oci_declaration(declaration); - validate_component(user, "OCI user")?; - if user.parse::().is_ok() { - return Ok(Vec::new()); - } - - let content = read_account_file(group_path)?; - let mut gids = vec![primary_gid]; - for line in content.lines() { - if line.is_empty() || line.starts_with('#') { - continue; - } - if line.len() > MAX_ACCOUNT_LINE_SIZE { - return Err(miette::miette!( - "account file '{}' contains an oversized line", - group_path.display() - )); - } - let fields = line.split(':').collect::>(); - if fields.len() != 4 - || fields - .iter() - .any(|field| field.len() > MAX_ACCOUNT_FIELD_SIZE) - { - return Err(miette::miette!( - "group membership entry in '{}' is malformed", - group_path.display() - )); - } - if !fields[3].split(',').any(|member| member == user) { - continue; - } - let gid = fields[2].parse::().map_err(|_| { - miette::miette!( - "group membership GID in '{}' is malformed", - group_path.display() - ) - })?; - if gid == 0 { - return Err(miette::miette!( - "OCI user '{user}' is a member of prohibited GID 0" - )); - } - gids.push(gid); - } - gids.sort_unstable(); - gids.dedup(); - Ok(gids) -} - -fn find_unique( - path: &Path, - mut select: impl FnMut(&[&str]) -> Option>, -) -> Result> { - let content = read_account_file(path)?; - let mut found = None; - for line in content.lines() { - if line.is_empty() || line.starts_with('#') { - continue; - } - if line.len() > MAX_ACCOUNT_LINE_SIZE { - return Err(miette::miette!( - "account file '{}' contains an oversized line", - path.display() - )); - } - let fields = line.split(':').collect::>(); - if fields - .iter() - .any(|field| field.len() > MAX_ACCOUNT_FIELD_SIZE) - { - return Err(miette::miette!( - "account file '{}' contains an oversized field", - path.display() - )); - } - let Some(candidate) = select(&fields) else { - continue; - }; - let candidate = candidate?; - if found.replace(candidate).is_some() { - return Err(miette::miette!( - "account identity is ambiguous in '{}'", - path.display() - )); - } - } - Ok(found) -} - -fn parse_passwd(fields: &[&str]) -> Result { - if fields.len() != 7 { - return Err(miette::miette!("matching /etc/passwd entry is malformed")); - } - Ok(PasswdEntry { - uid: fields[2] - .parse() - .map_err(|_| miette::miette!("matching /etc/passwd UID is malformed"))?, - gid: fields[3] - .parse() - .map_err(|_| miette::miette!("matching /etc/passwd GID is malformed"))?, - }) -} - -fn parse_group(fields: &[&str]) -> Result { - if fields.len() != 4 { - return Err(miette::miette!("matching /etc/group entry is malformed")); - } - Ok(GroupEntry { - gid: fields[2] - .parse() - .map_err(|_| miette::miette!("matching /etc/group GID is malformed"))?, - }) -} - -fn read_account_file(path: &Path) -> Result { - let mut options = OpenOptions::new(); - options - .read(true) - .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW); - let mut file = options - .open(path) - .into_diagnostic() - .map_err(|error| miette::miette!("failed to open '{}': {error}", path.display()))?; - validate_account_file(&file, path)?; - - let mut bytes = Vec::new(); - file.by_ref() - .take(MAX_ACCOUNT_FILE_SIZE + 1) - .read_to_end(&mut bytes) - .into_diagnostic()?; - if bytes.len() as u64 > MAX_ACCOUNT_FILE_SIZE { - return Err(miette::miette!( - "account file '{}' exceeds {MAX_ACCOUNT_FILE_SIZE} bytes", - path.display() - )); - } - String::from_utf8(bytes) - .map_err(|_| miette::miette!("account file '{}' is not valid UTF-8", path.display())) -} - -fn validate_account_file(file: &File, path: &Path) -> Result<()> { - let metadata = file.metadata().into_diagnostic()?; - if !metadata.is_file() { - return Err(miette::miette!( - "account path '{}' is not a regular file", - path.display() - )); - } - if metadata.len() > MAX_ACCOUNT_FILE_SIZE { - return Err(miette::miette!( - "account file '{}' exceeds {MAX_ACCOUNT_FILE_SIZE} bytes", - path.display() - )); - } - Ok(()) -} - -fn optional_utf8_env(name: &str) -> Result> { - std::env::var_os(name) - .map(|value| { - value - .into_string() - .map_err(|_| miette::miette!("{name} is not valid UTF-8")) - }) - .transpose() -} - -fn optional_nonempty_utf8_env(name: &str) -> Result> { - Ok(optional_utf8_env(name)?.filter(|value| !value.is_empty())) -} - -#[cfg(test)] -mod tests { - use super::*; - use openshell_core::policy::SandboxPolicy; - use std::fs; - use tempfile::tempdir; - - fn account_files( - passwd: &str, - group: &str, - ) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { - let dir = tempdir().unwrap(); - let passwd_path = dir.path().join("passwd"); - let group_path = dir.path().join("group"); - fs::write(&passwd_path, passwd).unwrap(); - fs::write(&group_path, group).unwrap(); - (dir, passwd_path, group_path) - } - - fn policy(user: Option<&str>, group: Option<&str>) -> SandboxPolicy { - let mut policy = 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(), - }; - policy.process.run_as_user = user.map(str::to_string); - policy.process.run_as_group = group.map(str::to_string); - policy - } - - #[test] - fn per_field_policy_precedence_resolves_complete_pair() { - let (_dir, passwd, group) = account_files( - "app:x:1234:1235::/home/app:/bin/sh\nsandbox:x:2000:2001::/sandbox:/bin/sh\n", - "staff:x:1235:\nsandbox:x:2001:\n", - ); - let cases = [ - ( - Some("2000"), - Some("2001"), - "root", - "2000", - "2001", - None, - None, - ), - ( - Some("2000"), - None, - "app:staff", - "2000", - "staff", - None, - Some(1235), - ), - ( - None, - Some("2001"), - "app:root", - "app", - "2001", - Some(1234), - None, - ), - ( - None, - None, - "app:staff", - "app", - "staff", - Some(1234), - Some(1235), - ), - (None, None, "app", "app", "1235", Some(1234), Some(1235)), - ]; - for ( - user, - group_name, - declaration, - expected_user, - expected_group, - resolved_uid, - resolved_gid, - ) in cases - { - let mut policy = policy(user, group_name); - let resolved = - resolve_oci_process_identity_at(&mut policy, declaration, &passwd, &group).unwrap(); - assert_eq!(policy.process.run_as_user.as_deref(), Some(expected_user)); - assert_eq!(policy.process.run_as_group.as_deref(), Some(expected_group)); - assert_eq!(resolved.uid(), resolved_uid); - assert_eq!(resolved.gid(), resolved_gid); - } - } - - #[test] - fn numeric_pair_does_not_require_account_entries() { - let dir = tempdir().unwrap(); - let passwd = dir.path().join("missing-passwd"); - let group = dir.path().join("missing-group"); - let mut policy = policy(None, None); - let resolved = - resolve_oci_process_identity_at(&mut policy, "1234:1235", &passwd, &group).unwrap(); - assert_eq!(policy.process.run_as_user.as_deref(), Some("1234")); - assert_eq!(policy.process.run_as_group.as_deref(), Some("1235")); - assert_eq!( - resolved, - ResolvedProcessIdentity::new(Some(1234), Some(1235)) - ); - } - - #[test] - fn explicit_identity_is_preserved_without_inspecting_oci_or_accounts() { - let dir = tempdir().unwrap(); - let mut policy = policy(Some("sandbox"), Some("sandbox")); - - let resolved = resolve_oci_process_identity_at( - &mut policy, - "root:root", - &dir.path().join("missing-passwd"), - &dir.path().join("missing-group"), - ) - .unwrap(); - - assert_eq!(policy.process.run_as_user.as_deref(), Some("sandbox")); - assert_eq!(policy.process.run_as_group.as_deref(), Some("sandbox")); - assert_eq!(resolved, ResolvedProcessIdentity::default()); - } - - #[test] - fn driver_identity_inputs_are_mutually_exclusive_and_complete() { - assert_eq!( - DriverIdentity::from_values(Some("app".into()), None, None).unwrap(), - DriverIdentity::OciUser { - declaration: "app".into() - } - ); - assert_eq!( - DriverIdentity::from_values(None, Some("1234".into()), Some("1235".into())).unwrap(), - DriverIdentity::Resolved { - uid: 1234, - gid: 1235 - } - ); - assert_eq!( - DriverIdentity::from_values(None, Some("500".into()), Some("30".into())).unwrap(), - DriverIdentity::Resolved { uid: 500, gid: 30 } - ); - assert_eq!( - DriverIdentity::from_values( - Some(String::new()), - Some("1234".into()), - Some("1235".into()) - ) - .unwrap(), - DriverIdentity::Resolved { - uid: 1234, - gid: 1235 - } - ); - assert_eq!( - DriverIdentity::from_values(Some(String::new()), None, None).unwrap(), - DriverIdentity::OciUser { - declaration: String::new() - } - ); - assert_eq!( - DriverIdentity::from_values(None, None, None).unwrap(), - DriverIdentity::None - ); - assert!( - DriverIdentity::from_values( - Some("app".into()), - Some("1234".into()), - Some("1235".into()) - ) - .is_err() - ); - assert!(DriverIdentity::from_values(None, Some("1234".into()), None).is_err()); - } - - #[test] - fn no_driver_identity_completes_partial_policy_with_sandbox() { - let cases = [ - (None, Some("staff"), "sandbox", "staff"), - (Some("app"), None, "app", "sandbox"), - (None, None, "sandbox", "sandbox"), - (Some("app"), Some("staff"), "app", "staff"), - ]; - - for (user, group, expected_user, expected_group) in cases { - let mut policy = policy(user, group); - let resolved = resolve_process_identity(&mut policy, &DriverIdentity::None).unwrap(); - - assert_eq!(policy.process.run_as_user.as_deref(), Some(expected_user)); - assert_eq!(policy.process.run_as_group.as_deref(), Some(expected_group)); - assert_eq!(resolved, ResolvedProcessIdentity::default()); - } - } - - #[test] - fn numeric_uid_uses_passwd_primary_gid() { - let (_dir, passwd, group) = account_files("app:x:1234:4321::/home/app:/bin/sh\n", ""); - let mut policy = policy(None, None); - let resolved = - resolve_oci_process_identity_at(&mut policy, "1234", &passwd, &group).unwrap(); - assert_eq!(policy.process.run_as_group.as_deref(), Some("4321")); - assert_eq!( - resolved, - ResolvedProcessIdentity::new(Some(1234), Some(4321)) - ); - } - - #[test] - fn named_oci_user_resolves_bounded_supplementary_groups() { - let (_dir, _passwd, group) = account_files( - "", - "primary:x:1235:\nvideo:x:44:app,other\naudio:x:63:other\nrender:x:107:app\n", - ); - - let gids = resolve_oci_supplementary_gids_at("app:primary", 1235, &group).unwrap(); - assert_eq!(gids, vec![44, 107, 1235]); - } - - #[test] - fn numeric_oci_user_has_no_named_supplementary_groups() { - let dir = tempdir().unwrap(); - let missing_group = dir.path().join("missing-group"); - - let gids = resolve_oci_supplementary_gids_at("1234:1235", 1235, &missing_group).unwrap(); - assert!(gids.is_empty()); - } - - #[test] - fn oci_supplementary_membership_rejects_root_group() { - let (_dir, _passwd, group) = account_files("", "root:x:0:app\n"); - - let error = - resolve_oci_supplementary_gids_at("app", 1235, &group).expect_err("GID 0 must fail"); - assert!(error.to_string().contains("prohibited GID 0")); - } - - #[test] - fn missing_unknown_ambiguous_and_root_identities_fail() { - let (_dir, passwd, group) = account_files( - "app:x:1234:1235::/home/app:/bin/sh\napp:x:2234:2235::/home/app2:/bin/sh\n", - "staff:x:1235:\nstaff:x:2235:\n", - ); - for declaration in ["", "unknown", "app", "9999", "0:1235", "1234:0"] { - let mut policy = policy(None, None); - assert!( - resolve_oci_process_identity_at(&mut policy, declaration, &passwd, &group).is_err(), - "{declaration:?} unexpectedly resolved" - ); - } - } - - #[test] - fn selected_component_is_validated_independently() { - let (_dir, passwd, group) = - account_files("app:x:1234:1235::/home/app:/bin/sh\n", "staff:x:1235:\n"); - - let mut explicit_user = policy(Some("1234"), None); - let resolved = - resolve_oci_process_identity_at(&mut explicit_user, "root:staff", &passwd, &group) - .unwrap(); - assert_eq!(explicit_user.process.run_as_user.as_deref(), Some("1234")); - assert_eq!(explicit_user.process.run_as_group.as_deref(), Some("staff")); - assert_eq!(resolved, ResolvedProcessIdentity::new(None, Some(1235))); - - let mut explicit_group = policy(None, Some("1235")); - let resolved = - resolve_oci_process_identity_at(&mut explicit_group, "app:root", &passwd, &group) - .unwrap(); - assert_eq!(explicit_group.process.run_as_user.as_deref(), Some("app")); - assert_eq!(explicit_group.process.run_as_group.as_deref(), Some("1235")); - assert_eq!(resolved, ResolvedProcessIdentity::new(Some(1234), None)); - } - - #[test] - fn named_oci_components_mapping_to_root_are_rejected() { - let (_dir, passwd, group) = account_files( - "root_alias:x:0:1235::/root:/bin/sh\napp:x:1234:1235::/home/app:/bin/sh\n", - "root_alias:x:0:\nstaff:x:1235:\n", - ); - - let mut root_user = policy(None, None); - assert!( - resolve_oci_process_identity_at(&mut root_user, "root_alias:staff", &passwd, &group) - .is_err() - ); - - let mut root_group = policy(None, None); - assert!( - resolve_oci_process_identity_at(&mut root_group, "app:root_alias", &passwd, &group) - .is_err() - ); - } - - #[cfg(unix)] - #[test] - fn account_file_symlinks_are_rejected() { - use std::os::unix::fs::symlink; - - let (_dir, passwd, group) = - account_files("app:x:1234:1235::/home/app:/bin/sh\n", "staff:x:1235:\n"); - let link = passwd.with_file_name("passwd-link"); - symlink(&passwd, &link).unwrap(); - - let mut policy = policy(None, None); - assert!(resolve_oci_process_identity_at(&mut policy, "app:staff", &link, &group).is_err()); - } -} diff --git a/crates/openshell-supervisor-process/src/process.rs b/crates/openshell-supervisor-process/src/process.rs deleted file mode 100644 index 0ab1dd318..000000000 --- a/crates/openshell-supervisor-process/src/process.rs +++ /dev/null @@ -1,4032 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Process management and signal handling. - -use crate::child_env; -#[cfg(target_os = "linux")] -use crate::managed_children; -#[cfg(target_os = "linux")] -use crate::netns::NetworkNamespace; -use crate::sandbox; -#[cfg(target_os = "linux")] -use miette::WrapErr; -use miette::{IntoDiagnostic, Result}; -use nix::sys::signal::{self, Signal}; -use nix::unistd::{Gid, Group, Pid, Uid, User}; -use openshell_core::policy::{NetworkMode, SandboxPolicy}; -use std::collections::HashMap; -use std::ffi::CString; -#[cfg(unix)] -use std::os::fd::AsRawFd; -#[cfg(target_os = "linux")] -use std::os::fd::RawFd; -#[cfg(target_os = "linux")] -use std::os::unix::ffi::OsStrExt; -#[cfg(unix)] -use std::os::unix::fs::{MetadataExt, PermissionsExt}; -#[cfg(any(test, unix))] -use std::path::Path; -use std::path::PathBuf; -use std::process::Stdio; -#[cfg(target_os = "linux")] -use std::sync::OnceLock; -#[cfg(target_os = "linux")] -use std::sync::mpsc; -use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; -use tracing::{debug, info}; - -// `libc::TIOCSCTTY` and the request parameter accepted by `ioctl` vary across -// glibc, musl, and BSD targets. The conversion is a no-op on some targets but -// is required on others. -#[cfg(unix)] -#[allow(unsafe_code, clippy::useless_conversion)] -fn set_controlling_tty(fd: libc::c_int) -> std::io::Result<()> { - if unsafe { libc::ioctl(fd, libc::TIOCSCTTY.into(), 0) } < 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) -} - -/// Process/filesystem enforcement performed by the process supervisor. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProcessEnforcementMode { - /// Preserve the existing supervisor behavior: prepare filesystem policy, - /// drop privileges, and apply Landlock/seccomp to workload processes. - Full, - /// Preserve process launch and SSH/session behavior, but skip controls - /// that require root or extra Linux capabilities. Kubernetes sidecar mode - /// uses this when network policy is enforced by the network sidecar. - NetworkOnly, -} - -/// Numeric identity components resolved once from driver-owned metadata. -/// -/// A component is `None` when the corresponding policy field was explicit and -/// must continue through the existing policy identity path. OCI-derived -/// components are carried numerically so later filesystem setup and direct/SSH -/// privilege drops cannot resolve them differently through NSS. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct ResolvedProcessIdentity { - uid: Option, - gid: Option, -} - -impl ResolvedProcessIdentity { - #[must_use] - pub const fn new(uid: Option, gid: Option) -> Self { - Self { uid, gid } - } - - #[must_use] - pub const fn uid(self) -> Option { - self.uid - } - - #[must_use] - pub const fn gid(self) -> Option { - self.gid - } - - /// Whether at least one process identity component came from OCI `USER`. - /// - /// Platform-resolved identities are written directly into the policy and - /// return the default value, so this is specific to Docker/Podman OCI - /// fallback without adding another driver contract. - #[must_use] - pub const fn uses_oci_user_fallback(self) -> bool { - self.uid.is_some() || self.gid.is_some() - } -} - -/// Resolved process workspace and its child-environment semantics. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct ResolvedWorkspace { - root: Option, - use_as_home: bool, -} - -impl ResolvedWorkspace { - #[must_use] - pub fn new(root: Option, use_as_home: bool) -> Self { - Self { root, use_as_home } - } - - #[must_use] - pub fn root(&self) -> Option<&str> { - self.root.as_deref() - } - - #[must_use] - pub fn owned_root(&self) -> Option { - self.root.clone() - } - - #[must_use] - pub fn home(&self) -> Option<&str> { - self.use_as_home.then(|| self.root()).flatten() - } -} - -impl ProcessEnforcementMode { - #[must_use] - pub const fn uses_privileged_process_setup(self) -> bool { - matches!(self, Self::Full) - } - - #[must_use] - pub const fn enforces_child_sandbox(self) -> bool { - matches!(self, Self::Full | Self::NetworkOnly) - } -} - -#[cfg(target_os = "linux")] -pub(crate) fn prepare_child_sandbox( - policy: &SandboxPolicy, - workdir: Option<&str>, - enforcement_mode: ProcessEnforcementMode, -) -> Result> { - if !enforcement_mode.enforces_child_sandbox() { - return Ok(None); - } - - let prepared = if enforcement_mode.uses_privileged_process_setup() { - sandbox::linux::prepare(policy, workdir) - } else { - sandbox::linux::prepare_current_user(policy, workdir) - }?; - Ok(Some(prepared)) -} - -const SUPERVISOR_ONLY_ENV_VARS: &[&str] = &[ - openshell_core::sandbox_env::OCI_IMAGE_USER, - openshell_core::sandbox_env::SANDBOX_UID, - openshell_core::sandbox_env::SANDBOX_GID, - openshell_core::sandbox_env::SANDBOX_TOKEN, - openshell_core::sandbox_env::SANDBOX_TOKEN_FILE, - openshell_core::sandbox_env::K8S_SA_TOKEN_FILE, - openshell_core::sandbox_env::TLS_CA, - openshell_core::sandbox_env::TLS_CERT, - openshell_core::sandbox_env::TLS_KEY, - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET, - openshell_core::sandbox_env::NETWORK_RUNTIME_CAPABILITIES, -]; - -pub fn is_supervisor_only_env_var(key: &str) -> bool { - SUPERVISOR_ONLY_ENV_VARS.contains(&key) -} - -fn strip_supervisor_only_env(cmd: &mut Command) { - for key in SUPERVISOR_ONLY_ENV_VARS { - cmd.env_remove(key); - } -} - -fn inject_provider_env(cmd: &mut Command, provider_env: &HashMap) { - for (key, value) in provider_env { - if is_supervisor_only_env_var(key) { - continue; - } - cmd.env(key, value); - } -} - -/// Derive the child USER and HOME from the policy's sandbox identity. -/// -/// Name-based identities use their passwd entry. Numeric identities have no -/// reliable passwd entry, so their workspace remains the portable fallback. -pub(crate) fn session_user_and_home( - policy: &SandboxPolicy, - workdir_home: Option<&str>, -) -> (String, String) { - let (user, default_home) = match policy.process.run_as_user.as_deref() { - Some(user) if !user.is_empty() => { - if user.parse::().is_ok() { - (user.to_string(), "/sandbox".to_string()) - } else { - let home = User::from_name(user).ok().flatten().map_or_else( - || format!("/home/{user}"), - |entry| entry.dir.to_string_lossy().into_owned(), - ); - (user.to_string(), home) - } - } - _ => ("sandbox".to_string(), "/sandbox".to_string()), - }; - let home = workdir_home.map_or(default_home, str::to_string); - (user, home) -} - -fn apply_canonical_process_environment( - cmd: &mut Command, - policy: &SandboxPolicy, - workspace: &ResolvedWorkspace, - interactive: bool, - user_environment: &HashMap, -) { - let (session_user, session_home) = session_user_and_home(policy, workspace.home()); - // Resolve a shell present in the sandbox image (minimal images such as - // Alpine ship only `/bin/sh`, not bash). Runs in the supervisor. - let shell = openshell_core::shell::detect_login_shell(); - - for (key, value) in [ - ("HOME", session_home.as_str()), - ("USER", session_user.as_str()), - ("SHELL", shell.as_str()), - ( - "TERM", - if interactive { - "xterm-256color" - } else { - "dumb" - }, - ), - ] { - if !user_environment.contains_key(key) { - cmd.env(key, value); - } - } -} - -fn configured_user_environment() -> HashMap { - std::env::var(openshell_core::sandbox_env::USER_ENVIRONMENT) - .ok() - .and_then(|json| serde_json::from_str(&json).ok()) - .unwrap_or_default() -} - -#[cfg(unix)] -pub fn harden_child_process() -> Result<()> { - use rustix::process::{Resource, Rlimit, setrlimit}; - - setrlimit( - Resource::Core, - Rlimit { - current: Some(0), - maximum: Some(0), - }, - ) - .map_err(|e| miette::miette!("Failed to disable core dumps: {e}"))?; - - #[cfg(target_os = "linux")] - { - use rustix::process::{DumpableBehavior, set_dumpable_behavior}; - set_dumpable_behavior(DumpableBehavior::NotDumpable) - .map_err(|e| miette::miette!("Failed to set PR_SET_DUMPABLE=0: {e}"))?; - } - - Ok(()) -} - -#[cfg(target_os = "linux")] -const CGROUP_PIDS_MAX_PATH: &str = "/sys/fs/cgroup/pids.max"; - -#[cfg(target_os = "linux")] -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RuntimePidLimitStatus { - Limited(u64), - Unlimited, - Unavailable(String), -} - -#[cfg(target_os = "linux")] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuntimePidLimitMode { - Warn, - Require, -} - -#[cfg(target_os = "linux")] -pub fn check_runtime_pid_limit(mode: RuntimePidLimitMode) -> Result<()> { - check_runtime_pid_limit_status(runtime_pid_limit_status(), mode) -} - -#[cfg(target_os = "linux")] -fn check_runtime_pid_limit_status( - status: RuntimePidLimitStatus, - mode: RuntimePidLimitMode, -) -> Result<()> { - match status { - RuntimePidLimitStatus::Limited(limit) => { - debug!(pids_max = limit, "runtime PID limit detected"); - Ok(()) - } - RuntimePidLimitStatus::Unlimited => { - let message = "runtime cgroup pids.max is unlimited; configure the compute driver or container runtime to enforce a PID limit"; - if matches!(mode, RuntimePidLimitMode::Require) { - Err(miette::miette!(message)) - } else { - tracing::warn!("{message}"); - Ok(()) - } - } - RuntimePidLimitStatus::Unavailable(reason) => { - let message = format!( - "runtime cgroup pids.max is unavailable ({reason}); configure the compute driver or container runtime to enforce a PID limit" - ); - if matches!(mode, RuntimePidLimitMode::Require) { - Err(miette::miette!(message)) - } else { - tracing::warn!("{message}"); - Ok(()) - } - } - } -} - -#[cfg(target_os = "linux")] -fn runtime_pid_limit_status() -> RuntimePidLimitStatus { - match std::fs::read_to_string(CGROUP_PIDS_MAX_PATH) { - Ok(contents) => parse_pids_max(&contents), - Err(err) => RuntimePidLimitStatus::Unavailable(err.to_string()), - } -} - -#[cfg(target_os = "linux")] -fn parse_pids_max(contents: &str) -> RuntimePidLimitStatus { - let raw = contents.trim(); - if raw.eq_ignore_ascii_case("max") { - return RuntimePidLimitStatus::Unlimited; - } - match raw.parse::() { - Ok(limit) => RuntimePidLimitStatus::Limited(limit), - Err(err) => { - RuntimePidLimitStatus::Unavailable(format!("invalid pids.max value {raw:?}: {err}")) - } - } -} - -#[cfg(target_os = "linux")] -fn drop_capability_bounding_set() -> Result<()> { - let clear_result = capctl::caps::bounding::clear(); - let remaining = capctl::caps::bounding::probe(); - - validate_capability_bounding_set_clear( - clear_result, - remaining, - capctl::caps::bounding::clear_unknown, - ) -} - -#[cfg(target_os = "linux")] -fn validate_capability_bounding_set_clear( - clear_result: capctl::Result<()>, - remaining: capctl::caps::CapSet, - clear_unknown: impl FnOnce() -> capctl::Result<()>, -) -> Result<()> { - match clear_result { - Ok(()) if remaining.is_empty() => Ok(()), - Ok(()) => Err(miette::miette!( - "Failed to clear child capability bounding set: capabilities remain raised: {remaining:?}" - )), - Err(err) if err.code() == libc::EPERM && remaining.is_empty() => match clear_unknown() { - Ok(()) => { - debug!( - "CAP_SETPCAP is unavailable, but the child capability bounding set is already empty" - ); - Ok(()) - } - Err(unknown_err) => Err(miette::miette!( - "Failed to clear unknown child capability bounding set entries: {unknown_err}" - )), - }, - Err(err) => Err(miette::miette!( - "Failed to clear child capability bounding set: {err}" - )), - } -} - -// Pins the pre-seccomp child mount namespace where supervisor identity sockets -// are shadowed. Children enter it with setns before dropping privileges. -#[cfg(target_os = "linux")] -static SUPERVISOR_IDENTITY_MOUNT_NS: OnceLock> = - OnceLock::new(); - -#[cfg(target_os = "linux")] -pub struct SupervisorIdentityMountNamespace { - spawn_tx: mpsc::Sender, -} - -#[cfg(target_os = "linux")] -type SupervisorIdentityNsRef = &'static SupervisorIdentityMountNamespace; -#[cfg(target_os = "linux")] -type SupervisorIdentitySpawnJob = Box; - -#[cfg(target_os = "linux")] -impl SupervisorIdentityMountNamespace { - fn from_socket_path(socket_path: &str) -> Result> { - let Some(target) = supervisor_identity_mount_target(socket_path)? else { - return Ok(None); - }; - Ok(Some(Self { - spawn_tx: start_supervisor_identity_spawn_worker(target)?, - })) - } -} - -#[cfg(target_os = "linux")] -pub fn prepare_supervisor_identity_mount_namespace_from_env() -> Result<()> { - if SUPERVISOR_IDENTITY_MOUNT_NS.get().is_some() { - return Ok(()); - } - - let Some((_env_name, socket_path)) = supervisor_identity_socket_path_from_env() else { - let _ = SUPERVISOR_IDENTITY_MOUNT_NS.set(None); - return Ok(()); - }; - let namespace = SupervisorIdentityMountNamespace::from_socket_path(&socket_path)?; - let _ = SUPERVISOR_IDENTITY_MOUNT_NS.set(namespace); - Ok(()) -} - -#[cfg(target_os = "linux")] -pub fn supervisor_identity_mount_from_env() -> Result> { - let Some(namespace) = SUPERVISOR_IDENTITY_MOUNT_NS.get() else { - if supervisor_identity_socket_path_from_env().is_some() { - return Err(miette::miette!( - "supervisor identity mount namespace was not prepared before startup hardening" - )); - } - return Ok(None); - }; - Ok(namespace.as_ref()) -} - -#[cfg(target_os = "linux")] -pub fn spawn_command_with_supervisor_identity_namespace( - mut cmd: Command, -) -> std::io::Result { - let namespace = supervisor_identity_mount_from_env() - .map_err(|err| std::io::Error::other(err.to_string()))?; - let Some(namespace) = namespace else { - return cmd.spawn(); - }; - namespace.spawn_tokio_command(cmd) -} - -#[cfg(target_os = "linux")] -pub fn spawn_std_command_with_supervisor_identity_namespace( - mut cmd: std::process::Command, -) -> std::io::Result { - let namespace = supervisor_identity_mount_from_env() - .map_err(|err| std::io::Error::other(err.to_string()))?; - let Some(namespace) = namespace else { - return cmd.spawn(); - }; - namespace.spawn_std_command(cmd) -} - -#[cfg(target_os = "linux")] -impl SupervisorIdentityMountNamespace { - fn spawn_tokio_command(&self, mut cmd: Command) -> std::io::Result { - let (result_tx, result_rx) = mpsc::channel(); - let handle = tokio::runtime::Handle::current(); - self.spawn_tx - .send(Box::new(move || { - let _guard = handle.enter(); - let _ = result_tx.send(cmd.spawn()); - })) - .map_err(|_| std::io::Error::other("supervisor identity spawn worker stopped"))?; - result_rx - .recv() - .map_err(|_| std::io::Error::other("supervisor identity spawn worker dropped result"))? - } - - fn spawn_std_command( - &self, - mut cmd: std::process::Command, - ) -> std::io::Result { - let (result_tx, result_rx) = mpsc::channel(); - self.spawn_tx - .send(Box::new(move || { - let _ = result_tx.send(cmd.spawn()); - })) - .map_err(|_| std::io::Error::other("supervisor identity spawn worker stopped"))?; - result_rx - .recv() - .map_err(|_| std::io::Error::other("supervisor identity spawn worker dropped result"))? - } -} - -#[cfg(target_os = "linux")] -fn start_supervisor_identity_spawn_worker( - target: PathBuf, -) -> Result> { - let (spawn_tx, spawn_rx) = mpsc::channel::(); - let (ready_tx, ready_rx) = mpsc::channel::>(); - std::thread::Builder::new() - .name("openshell-identity-spawn".into()) - .spawn(move || { - let setup = (|| -> std::io::Result<()> { - private_mount_namespace()?; - let target = - cstring_path(&target).map_err(|err| std::io::Error::other(err.to_string()))?; - mount_empty_tmpfs(&target) - })(); - let ready = match &setup { - Ok(()) => Ok(()), - Err(err) => Err(std::io::Error::new( - err.kind(), - format!("supervisor identity setup failed: {err}"), - )), - }; - let _ = ready_tx.send(ready); - if setup.is_err() { - return; - } - while let Ok(job) = spawn_rx.recv() { - job(); - } - }) - .map_err(|err| miette::miette!("failed to spawn supervisor identity worker: {err}"))?; - ready_rx - .recv() - .map_err(|err| miette::miette!("supervisor identity worker did not start: {err}"))? - .map_err(|err| miette::miette!("{err}"))?; - Ok(spawn_tx) -} - -#[cfg(target_os = "linux")] -fn supervisor_identity_socket_path_from_env() -> Option<(&'static str, String)> { - std::env::var(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET) - .ok() - .filter(|socket_path| !socket_path.trim().is_empty()) - .map(|socket_path| { - ( - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET, - socket_path, - ) - }) -} - -#[cfg(any(test, target_os = "linux"))] -fn supervisor_identity_mount_target(socket_path: &str) -> Result> { - let trimmed = socket_path.trim(); - if trimmed.is_empty() { - return Ok(None); - } - if trimmed.starts_with("tcp:") { - return Ok(None); - } - let path = trimmed.strip_prefix("unix:").unwrap_or(trimmed); - let path = Path::new(path); - if !path.is_absolute() { - return Err(miette::miette!( - "{} must be an absolute UNIX socket path", - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET - )); - } - let Some(parent) = path.parent() else { - return Err(miette::miette!( - "{} has no parent directory", - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET - )); - }; - if parent == Path::new("/") { - return Err(miette::miette!( - "{} must live below a dedicated directory, not directly under /", - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET - )); - } - if is_shared_root_mount_shadow(parent) { - return Err(miette::miette!( - "{} must live below a dedicated subdirectory; refusing to hide shared directory {}", - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET, - parent.display() - )); - } - Ok(Some(parent.to_path_buf())) -} - -#[cfg(any(test, target_os = "linux"))] -fn is_shared_root_mount_shadow(parent: &Path) -> bool { - matches!(parent.to_str(), Some("/run" | "/var" | "/tmp" | "/etc")) -} - -#[cfg(target_os = "linux")] -fn cstring_path(path: &Path) -> Result { - CString::new(path.as_os_str().as_bytes()) - .map_err(|_| miette::miette!("path contains an interior NUL byte: {}", path.display())) -} - -#[cfg(target_os = "linux")] -fn private_mount_namespace() -> std::io::Result<()> { - #[allow(unsafe_code)] - let rc = unsafe { libc::unshare(libc::CLONE_NEWNS) }; - if rc != 0 { - return Err(std::io::Error::last_os_error()); - } - - #[allow(unsafe_code)] - let rc = unsafe { - let flags: libc::c_ulong = libc::MS_REC | libc::MS_PRIVATE; - libc::mount( - std::ptr::null(), - c"/".as_ptr(), - std::ptr::null(), - flags, - std::ptr::null(), - ) - }; - if rc != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn mount_empty_tmpfs(target: &CString) -> std::io::Result<()> { - #[allow(unsafe_code)] - let rc = unsafe { - let flags: libc::c_ulong = - libc::MS_NOSUID | libc::MS_NODEV | libc::MS_NOEXEC | libc::MS_RDONLY; - libc::mount( - c"tmpfs".as_ptr(), - target.as_ptr(), - c"tmpfs".as_ptr(), - flags, - c"mode=0555,size=4k".as_ptr().cast(), - ) - }; - if rc != 0 { - return Err(std::io::Error::last_os_error()); - } - Ok(()) -} - -/// Handle to a running process. -pub struct ProcessHandle { - child: Child, - pid: u32, - io: Option, -} - -/// Supervisor-owned canonical-process I/O. These handles outlive individual -/// SSH attachments and are consumed by the main-session multiplexer. -pub enum ProcessIo { - Pty(std::fs::File), - Pipes { - stdin: ChildStdin, - stdout: ChildStdout, - stderr: ChildStderr, - }, -} - -impl ProcessHandle { - /// Spawn a new process. - /// - /// # Errors - /// - /// Returns an error if the process fails to start. - #[cfg(target_os = "linux")] - #[allow(clippy::too_many_arguments)] - pub fn spawn( - program: &str, - args: &[String], - workspace: &ResolvedWorkspace, - interactive: bool, - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, - enforcement_mode: ProcessEnforcementMode, - netns: Option<&NetworkNamespace>, - ca_paths: Option<&(PathBuf, PathBuf)>, - provider_env: &HashMap, - ) -> Result { - Self::spawn_impl( - program, - args, - workspace, - interactive, - policy, - resolved_identity, - enforcement_mode, - netns.and_then(NetworkNamespace::ns_fd), - ca_paths, - provider_env, - ) - } - - /// Spawn a new process (non-Linux platforms). - /// - /// # Errors - /// - /// Returns an error if the process fails to start. - #[cfg(not(target_os = "linux"))] - #[allow(clippy::too_many_arguments)] - pub fn spawn( - program: &str, - args: &[String], - workspace: &ResolvedWorkspace, - interactive: bool, - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, - enforcement_mode: ProcessEnforcementMode, - ca_paths: Option<&(PathBuf, PathBuf)>, - provider_env: &HashMap, - ) -> Result { - Self::spawn_impl( - program, - args, - workspace, - interactive, - policy, - resolved_identity, - enforcement_mode, - ca_paths, - provider_env, - ) - } - - #[cfg(target_os = "linux")] - #[allow(clippy::too_many_arguments)] - fn spawn_impl( - program: &str, - args: &[String], - workspace: &ResolvedWorkspace, - interactive: bool, - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, - enforcement_mode: ProcessEnforcementMode, - netns_fd: Option, - ca_paths: Option<&(PathBuf, PathBuf)>, - provider_env: &HashMap, - ) -> Result { - let mut cmd = Command::new(program); - cmd.args(args) - .kill_on_drop(true) - .env(openshell_core::sandbox_env::SANDBOX, "1"); - - let mut pty_master = None; - let mut terminal_slave_fd = None; - if interactive { - let winsize = nix::pty::Winsize { - ws_row: 24, - ws_col: 80, - ws_xpixel: 0, - ws_ypixel: 0, - }; - let pty = nix::pty::openpty(Some(&winsize), None).into_diagnostic()?; - let master = std::fs::File::from(pty.master); - let slave = std::fs::File::from(pty.slave); - terminal_slave_fd = Some(slave.as_raw_fd()); - cmd.stdin(slave.try_clone().into_diagnostic()?) - .stdout(slave.try_clone().into_diagnostic()?) - .stderr(slave); - pty_master = Some(master); - } else { - cmd.stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - } - - // Strip supervisor-only identity material from the entrypoint's - // inherited environment. The entrypoint drops to the sandbox user - // before `exec`; without this strip, sandbox code could recover - // supervisor credentials from its inherited environment. - strip_supervisor_only_env(&mut cmd); - - inject_provider_env(&mut cmd, provider_env); - apply_canonical_process_environment( - &mut cmd, - policy, - workspace, - interactive, - &configured_user_environment(), - ); - - if let Some(dir) = workspace.root() { - cmd.current_dir(dir); - } - - if matches!(policy.network.mode, NetworkMode::Proxy) { - let proxy = policy.network.proxy.as_ref().ok_or_else(|| { - miette::miette!( - "Network mode is set to proxy but no proxy configuration was provided" - ) - })?; - // When using network namespace, set proxy URL to the veth host IP - if netns_fd.is_some() { - // The proxy is on 10.200.0.1:3128 (or configured port) - let port = proxy.http_addr.map_or(3128, |addr| addr.port()); - let proxy_url = format!("http://10.200.0.1:{port}"); - // Both uppercase and lowercase variants: curl/wget use uppercase, - // gRPC C-core (libgrpc) checks lowercase http_proxy/https_proxy. - for (key, value) in child_env::proxy_env_vars(&proxy_url) { - cmd.env(key, value); - } - } else if let Some(http_addr) = proxy.http_addr { - let proxy_url = format!("http://{http_addr}"); - for (key, value) in child_env::proxy_env_vars(&proxy_url) { - cmd.env(key, value); - } - } - } - - // Set TLS trust store env vars so sandbox processes trust the ephemeral CA - if let Some((ca_cert_path, combined_bundle_path)) = ca_paths { - for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) { - cmd.env(key, value); - } - } - - // Probe Landlock availability and emit OCSF logs from the parent - // process where the tracing subscriber is functional. The child's - // pre_exec context cannot reliably emit structured logs. - #[cfg(target_os = "linux")] - if enforcement_mode.enforces_child_sandbox() { - sandbox::linux::log_sandbox_readiness(policy, workspace.root()); - } - - // Phase 1: Prepare Landlock ruleset by opening PathFds. - // In full mode this runs before drop_privileges() so root-only paths - // can be opened. In sidecar network-only mode the container already - // runs as the sandbox UID, so inaccessible paths are unavailable to - // the workload and best-effort compatibility skips them. - #[cfg(target_os = "linux")] - let prepared_sandbox = prepare_child_sandbox(policy, workspace.root(), enforcement_mode) - .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?; - // Set up process group for signal handling (non-interactive mode only). - // In interactive mode, we inherit the parent's process group to maintain - // proper terminal control for shells and interactive programs. - // SAFETY: pre_exec runs after fork but before exec in the child process. - // setpgid and setns are async-signal-safe and safe to call in this context. - { - let policy = policy.clone(); - // Wrap in Option so we can .take() it out of the FnMut closure. - // pre_exec is only called once (after fork, before exec). - #[cfg(target_os = "linux")] - let mut prepared_sandbox = prepared_sandbox; - #[allow(unsafe_code)] - unsafe { - cmd.pre_exec(move || { - if let Some(slave_fd) = terminal_slave_fd { - if libc::setsid() < 0 { - return Err(std::io::Error::last_os_error()); - } - set_controlling_tty(slave_fd)?; - } else if libc::setpgid(0, 0) < 0 { - return Err(std::io::Error::last_os_error()); - } - - // Enter network namespace before applying other restrictions. - if let Some(fd) = netns_fd { - let result = libc::setns(fd, libc::CLONE_NEWNET); - if result != 0 { - return Err(std::io::Error::other(format!( - "failed to enter network namespace: {}", - std::io::Error::last_os_error() - ))); - } - } - - // Drop privileges. initgroups/setgid/setuid need access to - // /etc/group and /etc/passwd which would be blocked if - // Landlock were already enforced. - if enforcement_mode.uses_privileged_process_setup() { - drop_privileges_with_identity(&policy, resolved_identity) - .map_err(|err| std::io::Error::other(err.to_string()))?; - } - - harden_child_process().map_err(|err| std::io::Error::other(err.to_string()))?; - - // Phase 2 (as unprivileged user): Enforce the prepared - // Landlock ruleset via restrict_self() + apply seccomp. - // restrict_self() does not require root. - #[cfg(target_os = "linux")] - if let Some(prepared) = prepared_sandbox.take() { - sandbox::linux::enforce(prepared) - .map_err(|err| std::io::Error::other(err.to_string()))?; - } - - Ok(()) - }); - } - } - - // Name the program in the error: a bare "No such file or directory" - // here is otherwise indistinguishable from a missing working directory - // or interpreter, and is a common failure on images that lack the - // requested shell/binary (e.g. bash on Alpine). - #[cfg(target_os = "linux")] - let mut child = spawn_command_with_supervisor_identity_namespace(cmd) - .into_diagnostic() - .wrap_err_with(|| format!("failed to spawn sandbox entrypoint process '{program}'"))?; - #[cfg(not(target_os = "linux"))] - let mut child = cmd - .spawn() - .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 io = if let Some(master) = pty_master { - ProcessIo::Pty(master) - } else { - ProcessIo::Pipes { - stdin: child.stdin.take().expect("canonical stdin must be piped"), - stdout: child.stdout.take().expect("canonical stdout must be piped"), - stderr: child.stderr.take().expect("canonical stderr must be piped"), - } - }; - - debug!(pid, program, "Process spawned"); - - Ok(Self { - child, - pid, - io: Some(io), - }) - } - - #[cfg(not(target_os = "linux"))] - #[allow(clippy::too_many_arguments)] - fn spawn_impl( - program: &str, - args: &[String], - workspace: &ResolvedWorkspace, - interactive: bool, - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, - enforcement_mode: ProcessEnforcementMode, - ca_paths: Option<&(PathBuf, PathBuf)>, - provider_env: &HashMap, - ) -> Result { - let mut cmd = Command::new(program); - cmd.args(args) - .kill_on_drop(true) - .env(openshell_core::sandbox_env::SANDBOX, "1"); - - let mut pty_master = None; - let mut terminal_slave_fd = None; - #[cfg(unix)] - if interactive { - let winsize = nix::pty::Winsize { - ws_row: 24, - ws_col: 80, - ws_xpixel: 0, - ws_ypixel: 0, - }; - let pty = nix::pty::openpty(Some(&winsize), None).into_diagnostic()?; - let master = std::fs::File::from(pty.master); - let slave = std::fs::File::from(pty.slave); - terminal_slave_fd = Some(slave.as_raw_fd()); - cmd.stdin(slave.try_clone().into_diagnostic()?) - .stdout(slave.try_clone().into_diagnostic()?) - .stderr(slave); - pty_master = Some(master); - } - if !interactive { - cmd.stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - } - - // Strip supervisor-only identity material from the entrypoint's - // inherited environment. - strip_supervisor_only_env(&mut cmd); - - inject_provider_env(&mut cmd, provider_env); - apply_canonical_process_environment( - &mut cmd, - policy, - workspace, - interactive, - &configured_user_environment(), - ); - - if let Some(dir) = workspace.root() { - cmd.current_dir(dir); - } - - if matches!(policy.network.mode, NetworkMode::Proxy) { - let proxy = policy.network.proxy.as_ref().ok_or_else(|| { - miette::miette!( - "Network mode is set to proxy but no proxy configuration was provided" - ) - })?; - if let Some(http_addr) = proxy.http_addr { - let proxy_url = format!("http://{http_addr}"); - for (key, value) in child_env::proxy_env_vars(&proxy_url) { - cmd.env(key, value); - } - } - } - - // Set TLS trust store env vars so sandbox processes trust the ephemeral CA - if let Some((ca_cert_path, combined_bundle_path)) = ca_paths { - for (key, value) in child_env::tls_env_vars(ca_cert_path, combined_bundle_path) { - cmd.env(key, value); - } - } - - // Create a dedicated session for PTY children and a dedicated process - // group for pipe children so attachment signals target only the - // canonical workload tree. - // SAFETY: pre_exec runs after fork but before exec in the child process. - // setpgid is async-signal-safe and safe to call in this context. - #[cfg(unix)] - { - let policy = policy.clone(); - let workdir = workspace.owned_root(); - #[allow(unsafe_code)] - unsafe { - cmd.pre_exec(move || { - if let Some(slave_fd) = terminal_slave_fd { - if libc::setsid() < 0 { - return Err(std::io::Error::last_os_error()); - } - set_controlling_tty(slave_fd)?; - } else if libc::setpgid(0, 0) < 0 { - return Err(std::io::Error::last_os_error()); - } - - // Drop privileges before applying sandbox restrictions. - // initgroups/setgid/setuid need access to /etc/group and /etc/passwd - // which may be blocked by Landlock. - if enforcement_mode.uses_privileged_process_setup() { - drop_privileges_with_identity(&policy, resolved_identity) - .map_err(|err| std::io::Error::other(err.to_string()))?; - } - - harden_child_process().map_err(|err| std::io::Error::other(err.to_string()))?; - - if enforcement_mode.enforces_child_sandbox() { - sandbox::apply(&policy, workdir.as_deref()) - .map_err(|err| std::io::Error::other(err.to_string()))?; - } - - Ok(()) - }); - } - } - - let mut child = cmd.spawn().into_diagnostic()?; - let pid = child.id().unwrap_or(0); - #[cfg(target_os = "linux")] - managed_children::register(pid); - - debug!(pid, program, "Process spawned"); - - let io = if let Some(master) = pty_master { - ProcessIo::Pty(master) - } else { - ProcessIo::Pipes { - stdin: child.stdin.take().expect("canonical stdin must be piped"), - stdout: child.stdout.take().expect("canonical stdout must be piped"), - stderr: child.stderr.take().expect("canonical stderr must be piped"), - } - }; - - Ok(Self { - child, - pid, - io: Some(io), - }) - } - - /// Get the process ID. - #[must_use] - pub const fn pid(&self) -> u32 { - self.pid - } - - /// Transfer retained stdio to the main-session multiplexer. - pub fn take_io(&mut self) -> ProcessIo { - self.io.take().expect("canonical process I/O already taken") - } - - /// Wait for the process to exit. - /// - /// # Errors - /// - /// Returns an error if waiting fails. - pub async fn wait(&mut self) -> std::io::Result { - let status = self.child.wait().await; - #[cfg(target_os = "linux")] - managed_children::unregister(self.pid); - let status = status?; - Ok(ProcessStatus::from(status)) - } - - /// Observe an already-terminated child without blocking. - pub fn try_wait(&mut self) -> std::io::Result> { - let status = self.child.try_wait()?; - if status.is_some() { - #[cfg(target_os = "linux")] - managed_children::unregister(self.pid); - } - Ok(status.map(ProcessStatus::from)) - } - - /// Send a signal to the process. - /// - /// # Errors - /// - /// Returns an error if the signal cannot be sent. - pub fn signal(&self, sig: Signal) -> Result<()> { - let pid = i32::try_from(self.pid).unwrap_or(i32::MAX); - signal::kill(Pid::from_raw(pid), sig).into_diagnostic() - } - - /// Kill the process. - /// - /// # Errors - /// - /// Returns an error if the process cannot be killed. - pub fn kill(&mut self) -> Result<()> { - // First try SIGTERM - if let Err(e) = self.signal(Signal::SIGTERM) { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ProcessActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Close) - .severity(openshell_ocsf::SeverityId::Medium) - .status(openshell_ocsf::StatusId::Failure) - .message(format!("Failed to send SIGTERM: {e}")) - .build() - ); - } - - // Give the process a moment to terminate gracefully - std::thread::sleep(std::time::Duration::from_millis(100)); - - // Force kill if still running - if let Some(id) = self.child.id() { - debug!(pid = id, "Sending SIGKILL"); - let pid = i32::try_from(id).unwrap_or(i32::MAX); - let _ = signal::kill(Pid::from_raw(pid), Signal::SIGKILL); - } - - Ok(()) - } -} - -impl Drop for ProcessHandle { - fn drop(&mut self) { - #[cfg(target_os = "linux")] - managed_children::unregister(self.pid); - } -} - -/// Validate the configured process user. -/// -/// Numeric identities do not require a passwd entry. The legacy explicit -/// `"sandbox"` identity and other names must resolve in `/etc/passwd`. -#[cfg(unix)] -pub fn validate_sandbox_user(policy: &SandboxPolicy) -> Result<()> { - let identity = policy.process.run_as_user.as_deref().unwrap_or("sandbox"); - - if let Ok(uid) = identity.parse::() { - if !(MIN_SANDBOX_UID..=MAX_SANDBOX_UID).contains(&uid) { - return Err(miette::miette!( - "process user UID must be in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message(format!( - "Accepted numeric UID {identity} (no passwd entry required)" - )) - .build() - ); - return Ok(()); - } - - // Legacy explicit "sandbox" name — must exist in /etc/passwd. - if identity == "sandbox" { - match User::from_name("sandbox") { - Ok(Some(_)) => { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message("Validated 'sandbox' user exists in image") - .build() - ); - } - Ok(None) => { - return Err(miette::miette!( - "explicit process user 'sandbox' was not found in the image" - )); - } - Err(e) => { - return Err(miette::miette!("failed to look up 'sandbox' user: {e}")); - } - } - } else if !identity.is_empty() { - // Other names are supported by local/offline policy paths and must - // resolve before privilege dropping. - match User::from_name(identity) { - Ok(Some(_)) => { - tracing::warn!(identity, "named process user accepted via passwd entry"); - } - Ok(None) => { - return Err(miette::miette!( - "unrecognized sandbox identity '{identity}'; \ - expected 'sandbox' or a numeric UID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - Err(e) => { - return Err(miette::miette!( - "failed to look up identity '{identity}': {e}" - )); - } - } - } - - Ok(()) -} - -/// Validate that the configured sandbox group identity is acceptable. -/// -/// Mirrors [`validate_sandbox_user`] for the group dimension. -#[cfg(unix)] -pub fn validate_sandbox_group(policy: &SandboxPolicy) -> Result<()> { - let identity = policy.process.run_as_group.as_deref().unwrap_or("sandbox"); - - if let Ok(gid) = identity.parse::() { - if !(MIN_SANDBOX_UID..=MAX_SANDBOX_UID).contains(&gid) { - return Err(miette::miette!( - "process group GID must be in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message(format!( - "Accepted numeric GID {identity} (no group entry required)" - )) - .build() - ); - return Ok(()); - } - - if identity == "sandbox" { - match Group::from_name("sandbox") { - Ok(Some(_)) => { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "validated") - .message("Validated 'sandbox' group exists in image") - .build() - ); - } - Ok(None) => { - return Err(miette::miette!( - "explicit process group 'sandbox' was not found in the image" - )); - } - Err(e) => { - return Err(miette::miette!("failed to look up 'sandbox' group: {e}")); - } - } - } else if !identity.is_empty() { - match Group::from_name(identity) { - Ok(Some(_)) => { - tracing::warn!(identity, "named process group accepted via group entry"); - } - Ok(None) => { - return Err(miette::miette!( - "unrecognized sandbox group identity '{identity}'; \ - expected 'sandbox' or a numeric GID in range [{MIN_SANDBOX_UID}, {MAX_SANDBOX_UID}]" - )); - } - Err(e) => { - return Err(miette::miette!( - "failed to look up group identity '{identity}': {e}" - )); - } - } - } - - Ok(()) -} - -#[cfg(unix)] -pub fn validate_sandbox_user_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<()> { - let Some(uid) = resolved_identity.uid() else { - return validate_sandbox_user(policy); - }; - if uid == 0 { - return Err(miette::miette!("process user must not select UID 0")); - } - Ok(()) -} - -#[cfg(unix)] -pub fn validate_sandbox_group_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<()> { - let Some(gid) = resolved_identity.gid() else { - return validate_sandbox_group(policy); - }; - if gid == 0 { - return Err(miette::miette!("process group must not select GID 0")); - } - Ok(()) -} - -pub use openshell_policy::{MAX_SANDBOX_UID, MIN_SANDBOX_UID}; - -/// Prepare a `read_write` path for the sandboxed process. -/// -/// Returns `true` when the path was created by the supervisor and therefore -/// still needs to be chowned to the sandbox user/group. Existing paths keep -/// their image-defined ownership. -#[cfg(unix)] -fn prepare_read_write_path(path: &Path) -> Result { - // SECURITY: use symlink_metadata (lstat) to inspect each path *before* - // calling chown. chown follows symlinks, so a malicious container image - // could place a symlink (e.g. /sandbox -> /etc/shadow) to trick the - // root supervisor into transferring ownership of arbitrary files. - // The TOCTOU window between lstat and chown is not exploitable because - // no untrusted process is running yet (the child has not been forked). - if let Ok(meta) = std::fs::symlink_metadata(path) { - if meta.file_type().is_symlink() { - return Err(miette::miette!( - "read_write path '{}' is a symlink — refusing to chown (potential privilege escalation)", - path.display() - )); - } - - debug!( - path = %path.display(), - "Preserving ownership for existing read_write path" - ); - Ok(false) - } else { - debug!(path = %path.display(), "Creating read_write directory"); - std::fs::create_dir_all(path).into_diagnostic()?; - Ok(true) - } -} - -/// Update `/etc/passwd` and `/etc/group` so the "sandbox" user/group entries -/// match the driver-injected UID/GID from environment variables. -/// -/// When `OPENSHELL_SANDBOX_UID` is set, the image-baked "sandbox" entry may -/// have a different UID. Updating the files ensures `whoami`, `id`, `ls -l`, -/// SSH sessions, and `initgroups` resolve the sandbox identity correctly. -/// If no "sandbox" entry exists, one is appended. -#[cfg(unix)] -pub fn update_sandbox_passwd_entries() -> Result<()> { - let uid_str = match std::env::var(openshell_core::sandbox_env::SANDBOX_UID) { - Ok(v) if !v.is_empty() => v, - _ => return Ok(()), - }; - let gid_str = match std::env::var(openshell_core::sandbox_env::SANDBOX_GID) { - Ok(v) if !v.is_empty() => v, - _ => uid_str.clone(), - }; - - let _: u32 = uid_str - .parse() - .map_err(|e| miette::miette!("invalid OPENSHELL_SANDBOX_UID '{uid_str}': {e}"))?; - let _: u32 = gid_str - .parse() - .map_err(|e| miette::miette!("invalid OPENSHELL_SANDBOX_GID '{gid_str}': {e}"))?; - - update_passwd_file(&uid_str, &gid_str)?; - update_group_file(&gid_str)?; - - info!( - uid = %uid_str, - gid = %gid_str, - "Updated /etc/passwd and /etc/group for sandbox identity" - ); - Ok(()) -} - -/// Rewrite the `sandbox` line in `/etc/passwd` with the given UID/GID, -/// or append a new entry if none exists. -#[cfg(unix)] -fn update_passwd_file(uid: &str, gid: &str) -> Result<()> { - rewrite_passwd_at(Path::new("/etc/passwd"), uid, gid) -} - -/// Rewrite the `sandbox` line in `/etc/group` with the given GID, -/// or append a new entry if none exists. -#[cfg(unix)] -fn update_group_file(gid: &str) -> Result<()> { - rewrite_group_at(Path::new("/etc/group"), gid) -} - -#[cfg(unix)] -fn rewrite_passwd_at(path: &Path, uid: &str, gid: &str) -> Result<()> { - let content = std::fs::read_to_string(path).into_diagnostic()?; - - let mut found = false; - let mut lines: Vec = content - .lines() - .map(|line| { - if line.starts_with("sandbox:") { - found = true; - let fields: Vec<&str> = line.split(':').collect(); - if let [name, pass, _, _, gecos, home, shell, ..] = fields.as_slice() { - format!("{name}:{pass}:{uid}:{gid}:{gecos}:{home}:{shell}") - } else { - line.to_string() - } - } else { - line.to_string() - } - }) - .collect(); - - if !found { - lines.push(format!("sandbox:x:{uid}:{gid}::/sandbox:/bin/sh")); - } - - let mut output = lines.join("\n"); - if content.ends_with('\n') || !found { - output.push('\n'); - } - - std::fs::write(path, output).into_diagnostic()?; - Ok(()) -} - -#[cfg(unix)] -fn rewrite_group_at(path: &Path, gid: &str) -> Result<()> { - let content = std::fs::read_to_string(path).into_diagnostic()?; - - let mut found = false; - let mut lines: Vec = content - .lines() - .map(|line| { - if line.starts_with("sandbox:") { - found = true; - let fields: Vec<&str> = line.split(':').collect(); - if let [name, pass, _, members, ..] = fields.as_slice() { - format!("{name}:{pass}:{gid}:{members}") - } else { - line.to_string() - } - } else { - line.to_string() - } - }) - .collect(); - - if !found { - lines.push(format!("sandbox:x:{gid}:")); - } - - let mut output = lines.join("\n"); - if content.ends_with('\n') || !found { - output.push('\n'); - } - - std::fs::write(path, output).into_diagnostic()?; - Ok(()) -} - -/// Recursively chown a directory tree to the given UID/GID. -/// -/// This retains the Kubernetes/OpenShift workspace reconciliation from before -/// OCI image identity fallback. Symlinks are skipped, and read-only nested -/// mounts are not traversed. -#[cfg(unix)] -fn chown_sandbox_home(root: &Path, uid: Option, gid: Option) -> Result<()> { - let meta = std::fs::symlink_metadata(root).into_diagnostic()?; - if meta.file_type().is_symlink() { - return Err(miette::miette!( - "path '{}' is a symlink — refusing to chown (potential privilege escalation)", - root.display() - )); - } - - nix::unistd::chown(root, uid, gid).into_diagnostic()?; - - if meta.is_dir() { - chown_children(root, uid, gid, &nix::unistd::chown)?; - } - - Ok(()) -} - -#[cfg(unix)] -fn prepare_oci_workspace( - root: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], -) -> Result<()> { - prepare_oci_workspace_with(root, uid, gid, supplementary_gids, &nix::unistd::chown) -} - -/// Validate that selecting an image-provided OCI workdir does not grant the -/// sandbox identity any filesystem authority it lacked in the immutable image. -/// -/// Every path component must be a real directory (never a symlink), every -/// parent must already be traversable, and the final directory must already be -/// writable and traversable. No ownership or mode bits are changed. -#[cfg(unix)] -pub fn validate_oci_workspace( - root: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], -) -> Result<()> { - let components = validated_workspace_components(root, false)?; - let mut current = PathBuf::from("/"); - validate_workspace_component(¤t, uid, gid, supplementary_gids, false)?; - let last_component = components.len().saturating_sub(1); - for (index, component) in components.into_iter().enumerate() { - current.push(component); - validate_workspace_component( - ¤t, - uid, - gid, - supplementary_gids, - index == last_component, - )?; - } - Ok(()) -} - -/// Validate an image-provided workdir in a clean copy of the supervisor so the -/// main process retains the root authority needed for subsequent setup. -#[cfg(target_os = "linux")] -fn validate_oci_workspace_in_subprocess( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, - workdir: &Path, -) -> Result<()> { - use std::os::unix::process::CommandExt; - - let (uid, gid, supplementary_gids) = resolve_filesystem_identity(policy, resolved_identity)?; - let uid = uid.ok_or_else(|| miette::miette!("workspace validator UID is unresolved"))?; - let gid = gid.ok_or_else(|| miette::miette!("workspace validator GID is unresolved"))?; - let groups = supplementary_gids - .iter() - .map(|group| group.as_raw()) - .collect::>(); - let executable = std::env::current_exe().into_diagnostic()?; - let mut command = std::process::Command::new(executable); - command - .arg("validate-workspace") - .arg("--workdir") - .arg(workdir) - .arg("--expected-uid") - .arg(uid.to_string()) - .arg("--expected-gid") - .arg(gid.to_string()) - .env_clear() - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()); - - // `pre_exec` runs after fork and before exec. These direct credential - // syscalls are async-signal-safe and affect only the one-shot child. - #[allow(unsafe_code)] - unsafe { - command.pre_exec(move || { - if libc::setgroups(groups.len(), groups.as_ptr()) != 0 - || libc::setgid(gid.as_raw()) != 0 - || libc::setuid(uid.as_raw()) != 0 - { - return Err(std::io::Error::last_os_error()); - } - Ok(()) - }); - } - - let output = command.output().into_diagnostic()?; - if output.status.success() { - return Ok(()); - } - - let diagnostic = String::from_utf8_lossy(&output.stderr); - let diagnostic = diagnostic.trim(); - if diagnostic.is_empty() { - return Err(miette::miette!( - "image workspace validation failed with status {}", - output.status - )); - } - Err(miette::miette!( - "image workspace validation failed: {diagnostic}" - )) -} - -#[cfg(unix)] -fn validate_workspace_component( - path: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], - is_workspace: bool, -) -> Result<()> { - let metadata = std::fs::symlink_metadata(path).map_err(|error| { - if error.kind() == std::io::ErrorKind::NotFound { - miette::miette!( - "image workspace path component '{}' does not exist", - path.display() - ) - } else { - miette::miette!( - "failed to inspect image workspace path component '{}': {error}", - path.display() - ) - } - })?; - if metadata.file_type().is_symlink() { - return Err(miette::miette!( - "workspace path component '{}' is a symlink — refusing to follow it", - path.display() - )); - } - if !metadata.is_dir() { - return Err(miette::miette!( - "workspace path component '{}' is not a directory", - path.display() - )); - } - let required = if is_workspace { 0o3 } else { 0o1 }; - if !identity_has_permissions(&metadata, uid, gid, supplementary_gids, required) { - let requirement = if is_workspace { - "writable and traversable" - } else { - "traversable" - }; - return Err(miette::miette!( - "workspace path component '{}' is not {requirement} by the sandbox identity in the image", - path.display() - )); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -pub fn validate_oci_workspace_as_effective_identity(root: &Path) -> Result<()> { - use rustix::fs::{Access, AtFlags, FileType, Mode, OFlags}; - - let components = validated_workspace_components(root, false)?; - let open_flags = OFlags::PATH | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC; - let mut current_path = PathBuf::from("/"); - let mut current_fd = rustix::fs::open("/", open_flags, Mode::empty()).into_diagnostic()?; - rustix::fs::accessat( - ¤t_fd, - ".", - Access::EXEC_OK, - AtFlags::EACCESS | AtFlags::SYMLINK_NOFOLLOW, - ) - .map_err(|error| { - miette::miette!( - "workspace path component '{}' is not traversable by the sandbox identity in the image: {error}", - current_path.display() - ) - })?; - - let last_component = components.len().saturating_sub(1); - for (index, component) in components.into_iter().enumerate() { - current_path.push(&component); - let stat = rustix::fs::statat(¤t_fd, &component, AtFlags::SYMLINK_NOFOLLOW).map_err( - |error| { - if error == rustix::io::Errno::NOENT { - miette::miette!( - "image workspace path component '{}' does not exist", - current_path.display() - ) - } else { - miette::miette!( - "failed to inspect image workspace path component '{}': {error}", - current_path.display() - ) - } - }, - )?; - let file_type = FileType::from_raw_mode(stat.st_mode); - if file_type.is_symlink() { - return Err(miette::miette!( - "workspace path component '{}' is a symlink — refusing to follow it", - current_path.display() - )); - } - if !file_type.is_dir() { - return Err(miette::miette!( - "workspace path component '{}' is not a directory", - current_path.display() - )); - } - - let is_workspace = index == last_component; - rustix::fs::accessat( - ¤t_fd, - &component, - Access::EXEC_OK, - AtFlags::EACCESS | AtFlags::SYMLINK_NOFOLLOW, - ) - .map_err(|error| { - miette::miette!( - "workspace path component '{}' is not traversable by the sandbox identity in the image: {error}", - current_path.display() - ) - })?; - - let next_fd = rustix::fs::openat(¤t_fd, &component, open_flags, Mode::empty()) - .map_err(|error| { - miette::miette!( - "failed to open image workspace path component '{}': {error}", - current_path.display() - ) - })?; - if is_workspace { - validate_effective_workspace_write(&next_fd, ¤t_path)?; - } - current_fd = next_fd; - } - - Ok(()) -} - -#[cfg(target_os = "linux")] -fn validate_effective_workspace_write(fd: &impl std::os::fd::AsFd, path: &Path) -> Result<()> { - use rustix::fs::{AtFlags, Mode, OFlags}; - - let mode = Mode::RUSR | Mode::WUSR; - let tmpfile_flags = OFlags::TMPFILE | OFlags::WRONLY | OFlags::CLOEXEC; - match rustix::fs::openat(fd, ".", tmpfile_flags, mode) { - Ok(_probe) => return Ok(()), - Err(rustix::io::Errno::INVAL | rustix::io::Errno::ISDIR | rustix::io::Errno::NOTSUP) => {} - Err(error) => { - return Err(miette::miette!( - "workspace path component '{}' is not writable by the sandbox identity in the image: {error}", - path.display() - )); - } - } - - // Some filesystems do not implement O_TMPFILE. Fall back to a short-lived, - // no-follow entry. A collision fails closed after bounded retries. - let create_flags = - OFlags::CREATE | OFlags::EXCL | OFlags::WRONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC; - for attempt in 0..16 { - let name = format!(".openshell-workdir-probe-{}-{attempt}", std::process::id()); - match rustix::fs::openat(fd, &name, create_flags, mode) { - Ok(_probe) => { - rustix::fs::unlinkat(fd, &name, AtFlags::empty()).map_err(|error| { - miette::miette!( - "workspace write probe cleanup failed for '{}': {error}", - path.display() - ) - })?; - return Ok(()); - } - Err(rustix::io::Errno::EXIST) => {} - Err(error) => { - return Err(miette::miette!( - "workspace path component '{}' is not writable by the sandbox identity in the image: {error}", - path.display() - )); - } - } - } - - Err(miette::miette!( - "workspace write probe could not allocate a unique entry in '{}'", - path.display() - )) -} - -/// Prepare only the resolved `OpenShell` workspace directory itself. -/// -/// Image-provided children retain their declared ownership. This avoids -/// crossing symlinks or user-provided nested mounts. -#[cfg(unix)] -fn prepare_oci_workspace_with( - root: &Path, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, -) -> Result<()> { - let components = validated_workspace_components(root, true)?; - - let last_component = components.len().saturating_sub(1); - let mut current = PathBuf::from("/"); - for (index, component) in components.into_iter().enumerate() { - current.push(component); - match std::fs::symlink_metadata(¤t) { - Ok(metadata) if metadata.file_type().is_symlink() => { - return Err(miette::miette!( - "workspace path component '{}' is a symlink — refusing to follow it", - current.display() - )); - } - Ok(metadata) if !metadata.is_dir() => { - return Err(miette::miette!( - "workspace path component '{}' is not a directory", - current.display() - )); - } - Ok(metadata) => { - if index != last_component - && !identity_can_traverse(&metadata, uid, gid, supplementary_gids) - { - return Err(miette::miette!( - "workspace parent '{}' is not traversable by the sandbox identity", - current.display() - )); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::create_dir(¤t).into_diagnostic()?; - std::fs::set_permissions(¤t, std::fs::Permissions::from_mode(0o755)) - .into_diagnostic()?; - } - Err(error) => return Err(error).into_diagnostic(), - } - } - - do_chown(root, uid, gid).into_diagnostic()?; - - let metadata = std::fs::symlink_metadata(root).into_diagnostic()?; - let mode = metadata.permissions().mode() & 0o7777; - if mode & 0o300 != 0o300 { - std::fs::set_permissions(root, std::fs::Permissions::from_mode(mode | 0o300)) - .into_diagnostic()?; - } - Ok(()) -} - -#[cfg(unix)] -fn validated_workspace_components( - root: &Path, - allow_managed_fallback: bool, -) -> Result> { - let root_str = root - .to_str() - .ok_or_else(|| miette::miette!("workspace path must be valid UTF-8"))?; - let validated_root = openshell_core::driver_mounts::resolve_oci_workspace_root(root_str) - .map_err(|error| miette::miette!(error))?; - if Path::new(&validated_root) != root - || (!allow_managed_fallback - && validated_root == openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT) - { - return Err(miette::miette!( - "workspace path '{}' must be a normalized absolute {}path", - root.display(), - if allow_managed_fallback { - "non-root " - } else { - "non-fallback " - } - )); - } - - root.components() - .skip(1) - .map(|component| match component { - std::path::Component::Normal(component) => Ok(component.to_os_string()), - _ => Err(miette::miette!( - "workspace path '{}' must be normalized", - root.display() - )), - }) - .collect() -} - -#[cfg(unix)] -fn identity_can_traverse( - metadata: &std::fs::Metadata, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], -) -> bool { - identity_has_permissions(metadata, uid, gid, supplementary_gids, 0o1) -} - -#[cfg(unix)] -fn identity_has_permissions( - metadata: &std::fs::Metadata, - uid: Option, - gid: Option, - supplementary_gids: &[Gid], - required: u32, -) -> bool { - let user_id = uid.unwrap_or_else(nix::unistd::geteuid).as_raw(); - if user_id == 0 { - return true; - } - - let group_id = gid.unwrap_or_else(nix::unistd::getegid).as_raw(); - let mode = metadata.permissions().mode(); - if metadata.uid() == user_id { - mode & (required << 6) == required << 6 - } else if metadata.gid() == group_id - || supplementary_gids - .iter() - .any(|supplementary_gid| supplementary_gid.as_raw() == metadata.gid()) - { - mode & (required << 3) == required << 3 - } else { - mode & required == required - } -} - -#[cfg(not(any( - target_os = "aix", - target_os = "haiku", - target_os = "illumos", - target_os = "ios", - target_os = "macos", - target_os = "redox", - target_os = "solaris" -)))] -fn named_user_supplementary_groups(user_name: &str, primary_gid: Gid) -> Result> { - let user_name = CString::new(user_name).map_err(|_| miette::miette!("Invalid user name"))?; - nix::unistd::getgrouplist(user_name.as_c_str(), primary_gid).into_diagnostic() -} - -#[cfg(any( - target_os = "aix", - target_os = "haiku", - target_os = "illumos", - target_os = "ios", - target_os = "macos", - target_os = "redox", - target_os = "solaris" -))] -#[allow(clippy::unnecessary_wraps)] -fn named_user_supplementary_groups(_user_name: &str, _primary_gid: Gid) -> Result> { - // Privilege dropping does not call initgroups on these targets. - Ok(Vec::new()) -} - -#[cfg(unix)] -fn chown_children( - dir: &Path, - uid: Option, - gid: Option, - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, -) -> Result<()> { - match std::fs::read_dir(dir) { - Ok(entries) => { - for entry in entries { - let entry = entry.into_diagnostic()?; - chown_recursive(&entry.path(), uid, gid, do_chown)?; - } - } - Err(error) => { - debug!( - path = %dir.display(), - %error, - "Cannot list directory during sandbox home chown" - ); - } - } - Ok(()) -} - -#[cfg(unix)] -fn chown_recursive( - path: &Path, - uid: Option, - gid: Option, - do_chown: &impl Fn(&Path, Option, Option) -> nix::Result<()>, -) -> Result<()> { - let meta = std::fs::symlink_metadata(path).into_diagnostic()?; - if meta.file_type().is_symlink() { - debug!(path = %path.display(), "Skipping symlink during sandbox home chown"); - return Ok(()); - } - - if let Err(error) = do_chown(path, uid, gid) { - if error == nix::errno::Errno::EROFS { - debug!(path = %path.display(), "Skipping read-only path during sandbox home chown"); - return Ok(()); - } - return Err(error).into_diagnostic(); - } - - if meta.is_dir() { - chown_children(path, uid, gid, do_chown)?; - } - - Ok(()) -} - -/// Prepare filesystem for the sandboxed process. -/// -/// Creates `read_write` directories if they don't exist and sets ownership -/// on newly-created paths to the configured sandbox user/group. This runs as -/// the supervisor (root) before forking the child process. -/// -/// Accepts both name-based identities (resolved via `/etc/passwd`) and numeric -/// UIDs/GIDs (passed directly to `chown` without a passwd lookup). -#[cfg(unix)] -pub fn prepare_filesystem(policy: &SandboxPolicy) -> Result<()> { - prepare_filesystem_with_identity(policy, ResolvedProcessIdentity::default(), None, false) -} - -#[cfg(unix)] -pub fn prepare_filesystem_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, - workdir: Option<&str>, - prepare_workspace: bool, -) -> Result<()> { - use nix::unistd::chown; - - // If no user/group configured, nothing to do - if policy - .process - .run_as_user - .as_deref() - .is_none_or(str::is_empty) - && policy - .process - .run_as_group - .as_deref() - .is_none_or(str::is_empty) - { - return Ok(()); - } - - let (uid, gid, supplementary_gids) = resolve_filesystem_identity(policy, resolved_identity)?; - - // Docker owns workspace resolution and must make the selected root usable - // by the final effective identity, including when both policy identity - // fields were explicit. Validate it before processing any user-authored - // read-write paths so an unsafe image path fails first. Other drivers - // retain their preparation. - if prepare_workspace { - let workspace = workdir.ok_or_else(|| { - miette::miette!("local container driver did not supply a workspace workdir") - })?; - let workspace = Path::new(workspace); - if workspace == Path::new(openshell_core::driver_mounts::DEFAULT_WORKSPACE_ROOT) { - info!(path = %workspace.display(), ?uid, ?gid, "Preparing managed workspace"); - prepare_oci_workspace(workspace, uid, gid, &supplementary_gids)?; - } else { - info!(path = %workspace.display(), ?uid, ?gid, "Validating image workspace authority"); - #[cfg(target_os = "linux")] - validate_oci_workspace_in_subprocess(policy, resolved_identity, workspace)?; - #[cfg(not(target_os = "linux"))] - validate_oci_workspace(workspace, uid, gid, &supplementary_gids)?; - } - } - - // Create missing read_write paths and only chown the ones we created. - for path in &policy.filesystem.read_write { - if prepare_read_write_path(path)? { - debug!( - path = %path.display(), - ?uid, - ?gid, - "Setting ownership on newly created read_write path" - ); - chown(path, uid, gid).into_diagnostic()?; - } - } - - // Retain the existing Kubernetes/OpenShift behavior for driver-injected - // numeric identities. Docker clears this variable and does not receive - // identity-specific workspace preparation. - if std::env::var(openshell_core::sandbox_env::SANDBOX_UID).is_ok_and(|uid| !uid.is_empty()) { - let sandbox_home = Path::new("/sandbox"); - if sandbox_home.exists() { - info!(?uid, ?gid, "Chowning /sandbox for driver-injected UID/GID"); - chown_sandbox_home(sandbox_home, uid, gid)?; - } - } - - Ok(()) -} - -#[cfg(unix)] -fn resolve_filesystem_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<(Option, Option, Vec)> { - let user_name = policy - .process - .run_as_user - .as_deref() - .filter(|name| !name.is_empty()); - let group_name = policy - .process - .run_as_group - .as_deref() - .filter(|name| !name.is_empty()); - - let uid = match resolved_identity.uid() { - Some(uid) => Some(Uid::from_raw(uid)), - None => match user_name { - Some(name) if name.parse::().is_ok() => { - Some(Uid::from_raw(name.parse().into_diagnostic()?)) - } - Some(name) => User::from_name(name).into_diagnostic()?.map(|u| u.uid), - _ => None, - }, - }; - - // Resolve GID: numeric values are passed directly; names resolve via group. - let gid = match resolved_identity.gid() { - Some(gid) => Some(Gid::from_raw(gid)), - None => match group_name { - Some(name) if name.parse::().is_ok() => { - Some(Gid::from_raw(name.parse().into_diagnostic()?)) - } - Some(name) => Group::from_name(name).into_diagnostic()?.map(|g| g.gid), - _ => None, - }, - }; - - let supplementary_gids = match user_name { - Some(name) if name.parse::().is_err() => { - let primary_gid = if let Some(gid) = gid { - gid - } else { - let uid = - uid.ok_or_else(|| miette::miette!("Failed to resolve sandbox user '{name}'"))?; - User::from_uid(uid) - .into_diagnostic()? - .ok_or_else(|| miette::miette!("Failed to resolve user from UID {uid}"))? - .gid - }; - if resolved_identity.uid().is_some() { - crate::identity::resolve_oci_supplementary_gids(name, primary_gid.as_raw())? - .into_iter() - .map(Gid::from_raw) - .collect() - } else { - named_user_supplementary_groups(name, primary_gid)? - } - } - _ => Vec::new(), - }; - - Ok((uid, gid, supplementary_gids)) -} - -#[cfg(not(unix))] -pub fn prepare_filesystem(_policy: &SandboxPolicy) -> Result<()> { - Ok(()) -} - -// `effective_gid`/`effective_uid` are intentionally parallel names (same role -// for different identifiers) and the noise from renaming would obscure intent. -#[cfg(unix)] -#[allow(clippy::similar_names)] -pub fn drop_privileges(policy: &SandboxPolicy) -> Result<()> { - drop_privileges_with_identity(policy, ResolvedProcessIdentity::default()) -} - -#[cfg(unix)] -#[allow(clippy::similar_names)] -pub fn drop_privileges_with_identity( - policy: &SandboxPolicy, - resolved_identity: ResolvedProcessIdentity, -) -> Result<()> { - let user_name = match policy.process.run_as_user.as_deref() { - Some(name) if !name.is_empty() => Some(name), - _ => None, - }; - let group_name = match policy.process.run_as_group.as_deref() { - Some(name) if !name.is_empty() => Some(name), - _ => None, - }; - - // If no user/group is configured and we are running as root, fall back to - // "sandbox:sandbox" instead of silently keeping root. This covers the - // local/dev-mode path for drivers that provide no identity metadata. - // For non-root runtimes, the no-op is safe -- we are already unprivileged. - if user_name.is_none() && group_name.is_none() { - if nix::unistd::geteuid().is_root() { - let mut fallback = policy.clone(); - fallback.process.run_as_user = Some("sandbox".into()); - fallback.process.run_as_group = Some("sandbox".into()); - return drop_privileges_with_identity(&fallback, resolved_identity); - } - return Ok(()); - } - - // Resolve UID: numeric values are used directly; names resolve via passwd. - let target_uid = match resolved_identity.uid() { - Some(uid) => Uid::from_raw(uid), - None => match user_name { - Some(name) if name.parse::().is_ok() => { - Uid::from_raw(name.parse().into_diagnostic()?) - } - Some(name) => { - User::from_name(name) - .into_diagnostic()? - .ok_or_else(|| miette::miette!("Sandbox user not found: {name}"))? - .uid - } - None => nix::unistd::geteuid(), - }, - }; - - // Resolve group: if a numeric GID is configured use it directly. - // Otherwise try name resolution, then fall back to current user's primary group. - let target_gid = match resolved_identity.gid() { - Some(gid) => Gid::from_raw(gid), - None => match group_name { - Some(name) if name.parse::().is_ok() => { - Gid::from_raw(name.parse().into_diagnostic()?) - } - Some(name) => { - Group::from_name(name) - .into_diagnostic()? - .ok_or_else(|| miette::miette!("Sandbox group not found: {name}"))? - .gid - } - None => match target_uid.as_raw() { - 0 => nix::unistd::getegid(), - _ => Group::from_gid( - User::from_uid(target_uid) - .into_diagnostic()? - .ok_or_else(|| { - miette::miette!("Failed to resolve user from UID {target_uid}") - })? - .gid, - ) - .into_diagnostic()? - .map_or_else(nix::unistd::getegid, |g| g.gid), - }, - }, - }; - - // Resolve the name for initgroups only for the existing explicit-policy - // path. OCI-derived users carry a numeric UID from the bounded parser and - // must not be looked up again through NSS. - let user_name_is_numeric = user_name.is_some_and(|n| n.parse::().is_ok()); - let initgroups_name = - if user_name.is_some() && !user_name_is_numeric && resolved_identity.uid().is_none() { - Some( - User::from_uid(target_uid) - .into_diagnostic()? - .ok_or_else(|| { - miette::miette!("Failed to resolve user record for UID {target_uid}") - })? - .name, - ) - } else { - None - }; - - if target_uid != nix::unistd::geteuid() { - if resolved_identity.uses_oci_user_fallback() { - // OCI named users use the bounded /etc/group parser shared with - // workspace validation. Numeric OCI users resolve to an empty - // list. Never retain the root supervisor's inherited groups. - #[cfg(not(any( - target_os = "macos", - target_os = "ios", - target_os = "haiku", - target_os = "redox" - )))] - { - let (_, _, supplementary_gids) = - resolve_filesystem_identity(policy, resolved_identity)?; - nix::unistd::setgroups(&supplementary_gids).into_diagnostic()?; - } - } else if let Some(ref user_name) = initgroups_name { - let user_cstr = CString::new(user_name.as_str()) - .map_err(|_| miette::miette!("Invalid user name"))?; - #[cfg(any( - target_os = "macos", - target_os = "ios", - target_os = "haiku", - target_os = "redox" - ))] - { - let _ = user_cstr; - } - #[cfg(not(any( - target_os = "macos", - target_os = "ios", - target_os = "haiku", - target_os = "redox" - )))] - { - nix::unistd::initgroups(user_cstr.as_c_str(), target_gid).into_diagnostic()?; - } - } - } - - if target_gid != nix::unistd::getegid() { - nix::unistd::setgid(target_gid).into_diagnostic()?; - } - - // Verify effective GID actually changed (defense-in-depth, CWE-250 / CERT POS37-C) - let effective_gid = nix::unistd::getegid(); - if effective_gid != target_gid { - return Err(miette::miette!( - "Privilege drop verification failed: expected effective GID {}, got {}", - target_gid, - effective_gid - )); - } - - #[cfg(target_os = "linux")] - if nix::unistd::geteuid().is_root() { - drop_capability_bounding_set()?; - } - - if user_name.is_some() { - if target_uid != nix::unistd::geteuid() { - nix::unistd::setuid(target_uid).into_diagnostic()?; - } - - // Verify effective UID actually changed (defense-in-depth, CWE-250 / CERT POS37-C) - let effective_uid = nix::unistd::geteuid(); - if effective_uid != target_uid { - return Err(miette::miette!( - "Privilege drop verification failed: expected effective UID {}, got {}", - target_uid, - effective_uid - )); - } - - // Verify root cannot be re-acquired (CERT POS37-C hardening). - // If we dropped from root, setuid(0) must fail; success means privileges - // were not fully relinquished. - if nix::unistd::setuid(Uid::from_raw(0)).is_ok() && target_uid.as_raw() != 0 { - return Err(miette::miette!( - "Privilege drop verification failed: process can still re-acquire root (UID 0) \ - after switching to UID {}", - target_uid - )); - } - } - - Ok(()) -} - -/// Process exit status. -#[derive(Debug, Clone, Copy)] -pub struct ProcessStatus { - code: Option, - signal: Option, -} - -impl ProcessStatus { - /// Get the conventional exit code when the process exited normally. - #[must_use] - pub const fn exit_code(&self) -> Option { - self.code - } - - /// Get the exit code, or 128 + signal number if killed by signal. - #[must_use] - pub fn code(&self) -> i32 { - self.code - .or_else(|| self.signal.map(|s| 128 + s)) - .unwrap_or(-1) - } - - /// Check if the process exited successfully. - #[must_use] - pub fn success(&self) -> bool { - self.code == Some(0) - } - - /// Get the signal that killed the process, if any. - #[must_use] - pub const fn signal(&self) -> Option { - self.signal - } -} - -impl From for ProcessStatus { - fn from(status: std::process::ExitStatus) -> Self { - #[cfg(unix)] - { - use std::os::unix::process::ExitStatusExt; - Self { - code: status.code(), - signal: status.signal(), - } - } - - #[cfg(not(unix))] - { - Self { - code: status.code(), - signal: None, - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - #[cfg(unix)] - use nix::sys::wait::{WaitStatus, waitpid}; - #[cfg(unix)] - use nix::unistd::{ForkResult, fork}; - use openshell_core::policy::{ - FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy, SandboxPolicy, - }; - #[cfg(unix)] - use std::mem::size_of; - use std::process::Stdio as StdStdio; - - /// Helper to create a minimal `SandboxPolicy` with the given process policy. - fn policy_with_process(process: ProcessPolicy) -> SandboxPolicy { - SandboxPolicy { - version: 1, - filesystem: FilesystemPolicy::default(), - network: NetworkPolicy::default(), - landlock: LandlockPolicy::default(), - process, - } - } - - #[cfg(unix)] - #[tokio::test] - async fn canonical_tty_environment_replaces_supervisor_identity_defaults() { - let current_user = User::from_uid(nix::unistd::geteuid()) - .expect("look up current user") - .expect("current user entry"); - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(current_user.name.clone()), - run_as_group: None, - }); - let workspace = ResolvedWorkspace::default(); - let mut cmd = Command::new("/usr/bin/env"); - cmd.env_clear() - .env("HOME", "/root") - .env("TERM", "dumb") - .stdout(StdStdio::piped()); - - apply_canonical_process_environment(&mut cmd, &policy, &workspace, true, &HashMap::new()); - - let output = cmd.output().await.expect("run environment probe"); - assert!(output.status.success()); - let environment = String::from_utf8(output.stdout).expect("environment is UTF-8"); - let variables: HashMap<_, _> = environment - .lines() - .filter_map(|line| line.split_once('=')) - .collect(); - - assert_eq!( - variables.get("HOME"), - Some(¤t_user.dir.to_string_lossy().as_ref()) - ); - assert_eq!(variables.get("USER"), Some(¤t_user.name.as_str())); - // SHELL is the shell detected in the current root filesystem, not a - // hardcoded path (bash-less images resolve to /bin/sh). - let expected_shell = openshell_core::shell::detect_login_shell(); - assert_eq!(variables.get("SHELL"), Some(&expected_shell.as_str())); - assert_eq!(variables.get("TERM"), Some(&"xterm-256color")); - } - - /// Unknown names may yield `Ok(None)` (`… not found …`) or `Err` when NSS fails first - /// (e.g. `ENOENT: No such file or directory`). - fn assert_unknown_identity_lookup_failed(msg: &str) { - assert!( - msg.contains("not found") - || msg.contains("ENOENT") - || msg.contains("No such file or directory"), - "expected unknown user/group lookup failure (…not found… or ENOENT): {msg}" - ); - } - - #[test] - #[cfg(unix)] - fn explicit_identity_accepts_non_root_system_ids() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("101".into()), - run_as_group: Some("102".into()), - }); - - assert!(validate_sandbox_user(&policy).is_ok()); - assert!(validate_sandbox_group(&policy).is_ok()); - } - - #[test] - #[cfg(unix)] - fn resolved_oci_identity_accepts_non_root_system_ids() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("app".into()), - run_as_group: Some("staff".into()), - }); - let resolved = ResolvedProcessIdentity::new(Some(101), Some(102)); - - assert!(validate_sandbox_user_with_identity(&policy, resolved).is_ok()); - assert!(validate_sandbox_group_with_identity(&policy, resolved).is_ok()); - } - - #[test] - #[cfg(unix)] - fn completed_runtime_identity_rejects_numeric_root() { - let root_user = policy_with_process(ProcessPolicy { - run_as_user: Some("0".into()), - run_as_group: Some("102".into()), - }); - let root_group = policy_with_process(ProcessPolicy { - run_as_user: Some("101".into()), - run_as_group: Some("0".into()), - }); - - assert!(validate_sandbox_user(&root_user).is_err()); - assert!(validate_sandbox_group(&root_group).is_err()); - } - - #[test] - #[cfg(unix)] - fn resolved_oci_components_do_not_repeat_nss_validation() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("__oci_name_not_in_host_nss__".into()), - run_as_group: Some("__oci_group_not_in_host_nss__".into()), - }); - let resolved = ResolvedProcessIdentity::new(Some(1234), Some(1235)); - - assert!(validate_sandbox_user_with_identity(&policy, resolved).is_ok()); - assert!(validate_sandbox_group_with_identity(&policy, resolved).is_ok()); - } - - #[test] - #[cfg(unix)] - fn explicit_policy_components_keep_existing_validation_path() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("__explicit_name_not_in_host_nss__".into()), - run_as_group: Some("__oci_group_not_in_host_nss__".into()), - }); - let resolved = ResolvedProcessIdentity::new(None, Some(1235)); - - assert!(validate_sandbox_user_with_identity(&policy, resolved).is_err()); - assert!(validate_sandbox_group_with_identity(&policy, resolved).is_ok()); - } - - #[test] - fn full_enforcement_uses_privileged_setup_and_child_sandbox() { - assert!(ProcessEnforcementMode::Full.uses_privileged_process_setup()); - assert!(ProcessEnforcementMode::Full.enforces_child_sandbox()); - } - - #[test] - fn network_only_enforcement_keeps_child_sandbox_without_privileged_setup() { - assert!(!ProcessEnforcementMode::NetworkOnly.uses_privileged_process_setup()); - assert!(ProcessEnforcementMode::NetworkOnly.enforces_child_sandbox()); - } - - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_available() -> bool { - capctl::caps::CapState::get_current() - .is_ok_and(|state| state.effective.has(capctl::caps::Cap::SETPCAP)) - || capctl::caps::bounding::probe().is_empty() - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_accepts_empty_eperm() { - let remaining = capctl::caps::CapSet::empty(); - - assert!( - validate_capability_bounding_set_clear( - Err(capctl::Error::from_code(libc::EPERM)), - remaining, - || Ok(()), - ) - .is_ok() - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_rejects_nonempty_eperm() { - let mut remaining = capctl::caps::CapSet::empty(); - remaining.add(capctl::caps::Cap::CHOWN); - - let result = validate_capability_bounding_set_clear( - Err(capctl::Error::from_code(libc::EPERM)), - remaining, - || panic!("unknown capabilities should not be checked when known caps remain"), - ); - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Failed to clear child capability bounding set") - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_rejects_nonempty_success() { - let mut remaining = capctl::caps::CapSet::empty(); - remaining.add(capctl::caps::Cap::CHOWN); - - let result = validate_capability_bounding_set_clear(Ok(()), remaining, || { - panic!("unknown capabilities should not be checked when known caps remain") - }); - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("capabilities remain raised") - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_bounding_set_clear_rejects_unknown_eperm() { - let remaining = capctl::caps::CapSet::empty(); - - let result = validate_capability_bounding_set_clear( - Err(capctl::Error::from_code(libc::EPERM)), - remaining, - || Err(capctl::Error::from_code(libc::EPERM)), - ); - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("Failed to clear unknown child capability bounding set entries") - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn capability_probe_child() { - if std::env::var_os("OPENSHELL_TEST_PROBE_CHILD_CAPS").is_none() { - return; - } - - assert!( - capctl::caps::bounding::probe().is_empty(), - "child CapBnd should be empty after exec" - ); - } - - #[test] - fn drop_privileges_noop_when_no_user_or_group() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: None, - }); - if nix::unistd::geteuid().is_root() { - // As root, drop_privileges falls back to "sandbox:sandbox". - // If that user exists, it succeeds; if not (e.g. CI), it - // must error rather than silently keep root. - let has_sandbox = User::from_name("sandbox").ok().flatten().is_some(); - assert_eq!(drop_privileges(&policy).is_ok(), has_sandbox); - } else { - assert!(drop_privileges(&policy).is_ok()); - } - } - - #[test] - fn drop_privileges_noop_when_empty_strings() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(String::new()), - run_as_group: Some(String::new()), - }); - if nix::unistd::geteuid().is_root() { - let has_sandbox = User::from_name("sandbox").ok().flatten().is_some(); - assert_eq!(drop_privileges(&policy).is_ok(), has_sandbox); - } else { - assert!(drop_privileges(&policy).is_ok()); - } - } - - #[test] - fn drop_privileges_succeeds_for_current_group() { - // Set only run_as_group (no run_as_user) so that initgroups() is not - // called. initgroups(3) requires CAP_SETGID/root even when the target - // is the current user, so it cannot be exercised without elevated - // privileges. This test covers the setgid() + GID post-condition - // verification path without needing root. - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("getgrgid") - .expect("current group entry"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: Some(current_group.name), - }); - - let result = drop_privileges(&policy); - #[cfg(target_os = "linux")] - { - if nix::unistd::geteuid().is_root() && !capability_bounding_set_clear_available() { - let msg = format!("{}", result.unwrap_err()); - assert!( - msg.contains("Failed to clear child capability bounding set"), - "unexpected failure: {msg}" - ); - return; - } - } - assert!(result.is_ok(), "drop_privileges failed: {result:?}"); - } - - #[test] - #[cfg(target_os = "linux")] - #[allow(unsafe_code)] - fn drop_privileges_clears_bounding_set_for_spawned_child_when_permitted() { - use std::os::unix::process::CommandExt; - - if !capability_bounding_set_clear_available() { - eprintln!( - "skipping: CAP_SETPCAP is not effective and the capability bounding set is nonempty" - ); - return; - } - - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("getgrgid") - .expect("current group entry"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: Some(current_group.name), - }); - - let mut cmd = std::process::Command::new(std::env::current_exe().expect("current exe")); - cmd.arg("capability_probe_child") - .arg("--nocapture") - .env("OPENSHELL_TEST_PROBE_CHILD_CAPS", "1") - .stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::piped()); - - unsafe { - cmd.pre_exec(move || { - drop_privileges(&policy).map_err(|err| std::io::Error::other(err.to_string())) - }); - } - - let output = cmd.output().expect("spawn child status probe"); - assert!( - output.status.success(), - "status probe failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } - - #[test] - #[ignore = "initgroups(3) requires CAP_SETGID; run as root: sudo cargo test -- --ignored"] - fn drop_privileges_succeeds_for_current_user() { - // Exercises the full privilege-drop path including initgroups(), - // setgid(), setuid(), and the root-reacquisition check. Requires - // CAP_SETGID (root) because initgroups(3) calls setgroups(2) - // internally. Fixes: https://github.com/NVIDIA/OpenShell/issues/622 - let current_user = User::from_uid(nix::unistd::geteuid()) - .expect("getpwuid") - .expect("current user entry"); - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("getgrgid") - .expect("current group entry"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(current_user.name), - run_as_group: Some(current_group.name), - }); - - assert!(drop_privileges(&policy).is_ok()); - } - - #[test] - fn drop_privileges_fails_for_nonexistent_user() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("__nonexistent_test_user_42__".to_string()), - run_as_group: None, - }); - - let result = drop_privileges(&policy); - assert!(result.is_err()); - let msg = format!("{}", result.unwrap_err()); - assert_unknown_identity_lookup_failed(&msg); - } - - #[test] - fn drop_privileges_fails_for_nonexistent_group() { - let policy = policy_with_process(ProcessPolicy { - run_as_user: None, - run_as_group: Some("__nonexistent_test_group_42__".to_string()), - }); - - let result = drop_privileges(&policy); - assert!(result.is_err()); - let msg = format!("{}", result.unwrap_err()); - assert_unknown_identity_lookup_failed(&msg); - } - - #[cfg(unix)] - #[allow(unsafe_code)] - fn probe_hardened_child(probe: unsafe fn() -> i64) -> i64 { - const HARDEN_FAILED: i64 = -2; - - let mut fds = [0; 2]; - let pipe_rc = unsafe { libc::pipe(fds.as_mut_ptr()) }; - assert_eq!( - pipe_rc, - 0, - "pipe failed: {}", - std::io::Error::last_os_error() - ); - - match unsafe { fork() }.expect("fork should succeed") { - ForkResult::Child => { - unsafe { libc::close(fds[0]) }; - let value = match harden_child_process() { - Ok(()) => unsafe { probe() }, - Err(_) => HARDEN_FAILED, - }; - let bytes = value.to_ne_bytes(); - let written = unsafe { libc::write(fds[1], bytes.as_ptr().cast(), bytes.len()) }; - unsafe { - libc::close(fds[1]); - libc::_exit(i32::from(written != bytes.len().cast_signed())); - } - } - ForkResult::Parent { child } => { - unsafe { libc::close(fds[1]) }; - let mut bytes = [0u8; size_of::()]; - let read = unsafe { libc::read(fds[0], bytes.as_mut_ptr().cast(), bytes.len()) }; - unsafe { libc::close(fds[0]) }; - assert_eq!( - read.cast_unsigned(), - bytes.len(), - "expected {} probe bytes, got {}", - bytes.len(), - read - ); - - match waitpid(child, None).expect("waitpid should succeed") { - WaitStatus::Exited(_, 0) => {} - status => panic!("probe child exited unexpectedly: {status:?}"), - } - - i64::from_ne_bytes(bytes) - } - } - } - - #[cfg(unix)] - #[allow(unsafe_code)] - unsafe fn core_dump_limit_is_zero_probe() -> i64 { - let mut limit = std::mem::MaybeUninit::::uninit(); - let rc = unsafe { libc::getrlimit(libc::RLIMIT_CORE, limit.as_mut_ptr()) }; - if rc != 0 { - return -1; - } - let limit = unsafe { limit.assume_init() }; - i64::from(limit.rlim_cur == 0 && limit.rlim_max == 0) - } - - #[test] - #[cfg(unix)] - fn harden_child_process_disables_core_dumps() { - assert_eq!(probe_hardened_child(core_dump_limit_is_zero_probe), 1); - } - - #[cfg(target_os = "linux")] - #[allow(unsafe_code)] - unsafe fn dumpable_flag_probe() -> i64 { - unsafe { i64::from(libc::prctl(libc::PR_GET_DUMPABLE, 0, 0, 0, 0)) } - } - - #[test] - #[cfg(target_os = "linux")] - fn harden_child_process_marks_process_nondumpable() { - assert_eq!(probe_hardened_child(dumpable_flag_probe), 0); - } - - #[test] - #[cfg(target_os = "linux")] - fn parse_pids_max_detects_limited_runtime() { - assert_eq!( - parse_pids_max("2048\n"), - RuntimePidLimitStatus::Limited(2048) - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn parse_pids_max_detects_unlimited_runtime() { - assert_eq!(parse_pids_max("max\n"), RuntimePidLimitStatus::Unlimited); - } - - #[test] - #[cfg(target_os = "linux")] - fn parse_pids_max_reports_invalid_values() { - let status = parse_pids_max("not-a-number\n"); - assert!(matches!(status, RuntimePidLimitStatus::Unavailable(_))); - } - - #[test] - #[cfg(target_os = "linux")] - fn pid_limit_require_mode_rejects_missing_guardrail_statuses() { - for status in [ - RuntimePidLimitStatus::Unlimited, - RuntimePidLimitStatus::Unavailable("missing".to_string()), - ] { - let result = check_runtime_pid_limit_status(status, RuntimePidLimitMode::Require); - assert!(result.is_err()); - } - } - - #[test] - #[cfg(target_os = "linux")] - fn pid_limit_warn_mode_accepts_missing_guardrail_statuses() { - for status in [ - RuntimePidLimitStatus::Unlimited, - RuntimePidLimitStatus::Unavailable("missing".to_string()), - ] { - let result = check_runtime_pid_limit_status(status, RuntimePidLimitMode::Warn); - assert!(result.is_ok()); - } - } - - #[tokio::test] - async fn inject_provider_env_sets_placeholder_values() { - let mut cmd = Command::new("/usr/bin/env"); - cmd.stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::null()); - - let provider_env = std::iter::once(( - "ANTHROPIC_API_KEY".to_string(), - "openshell:resolve:env:ANTHROPIC_API_KEY".to_string(), - )) - .collect(); - - inject_provider_env(&mut cmd, &provider_env); - - let output = cmd.output().await.expect("spawn env"); - let stdout = String::from_utf8(output.stdout).expect("utf8"); - assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY")); - } - - #[cfg(unix)] - fn sandbox_policy_with_read_write( - path: PathBuf, - run_as_user: Option, - run_as_group: Option, - ) -> SandboxPolicy { - SandboxPolicy { - version: 1, - filesystem: FilesystemPolicy { - read_only: vec![], - read_write: vec![path], - include_workdir: false, - }, - network: NetworkPolicy::default(), - landlock: LandlockPolicy::default(), - process: ProcessPolicy { - run_as_user, - run_as_group, - }, - } - } - - #[cfg(unix)] - #[test] - fn prepare_read_write_path_creates_missing_directory() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("missing").join("nested"); - - assert!(prepare_read_write_path(&missing).unwrap()); - assert!(missing.is_dir()); - } - - #[cfg(unix)] - #[test] - fn prepare_read_write_path_preserves_existing_directory() { - let dir = tempfile::tempdir().unwrap(); - let existing = dir.path().join("existing"); - std::fs::create_dir(&existing).unwrap(); - - assert!(!prepare_read_write_path(&existing).unwrap()); - assert!(existing.is_dir()); - } - - #[cfg(unix)] - #[test] - fn prepare_read_write_path_rejects_symlink() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("target"); - let link = dir.path().join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let error = prepare_read_write_path(&link).unwrap_err(); - assert!( - error - .to_string() - .contains("is a symlink — refusing to chown"), - "unexpected error: {error}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_filesystem_skips_chown_for_existing_read_write_paths() { - use std::os::unix::fs::MetadataExt; - - if nix::unistd::geteuid().is_root() { - return; - } - - let Ok(Some(current_user)) = User::from_uid(nix::unistd::geteuid()) else { - eprintln!("skipping: current UID has no /etc/passwd entry"); - return; - }; - let restricted_group = Group::from_gid(Gid::from_raw(0)) - .unwrap() - .expect("gid 0 group entry"); - if restricted_group.gid == nix::unistd::getegid() { - return; - } - - let dir = tempfile::tempdir().unwrap(); - let existing = dir.path().join("existing"); - std::fs::create_dir(&existing).unwrap(); - let before = std::fs::metadata(&existing).unwrap(); - - let policy = sandbox_policy_with_read_write( - existing.clone(), - Some(current_user.name), - Some(restricted_group.name), - ); - - prepare_filesystem(&policy).expect("existing path should not be re-owned"); - - let after = std::fs::metadata(&existing).unwrap(); - assert_eq!(after.uid(), before.uid()); - assert_eq!(after.gid(), before.gid()); - } - - #[cfg(unix)] - #[test] - #[allow(clippy::similar_names)] - fn chown_sandbox_home_changes_ownership_recursively() { - use std::os::unix::fs::MetadataExt; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - std::fs::write(root.join("file.txt"), "hello").unwrap(); - std::fs::create_dir(root.join("subdir")).unwrap(); - std::fs::write(root.join("subdir").join("nested.txt"), "world").unwrap(); - - let expected_uid = nix::unistd::geteuid(); - let expected_gid = nix::unistd::getegid(); - chown_sandbox_home(&root, Some(expected_uid), Some(expected_gid)).unwrap(); - - for path in &[ - root.clone(), - root.join("file.txt"), - root.join("subdir"), - root.join("subdir").join("nested.txt"), - ] { - let meta = std::fs::metadata(path).unwrap(); - assert_eq!(meta.uid(), expected_uid.as_raw()); - assert_eq!(meta.gid(), expected_gid.as_raw()); - } - } - - #[cfg(unix)] - #[test] - fn chown_sandbox_home_rejects_symlink_root() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("real"); - let link = dir.path().join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let err = chown_sandbox_home( - &link, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - ) - .unwrap_err(); - assert!( - err.to_string().contains("symlink"), - "expected symlink rejection: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn chown_sandbox_home_skips_symlink_children() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let target = dir.path().join("outside"); - std::fs::write(&target, "secret").unwrap(); - symlink(&target, root.join("link")).unwrap(); - - chown_sandbox_home( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - ) - .expect("symlink children should be skipped"); - } - - #[cfg(unix)] - #[test] - fn chown_recursive_skips_erofs_subtree_but_continues_siblings() { - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - - let readonly_dir = root.join("ro-mount"); - std::fs::create_dir(&readonly_dir).unwrap(); - std::fs::write(readonly_dir.join("child-under-ro.txt"), "data").unwrap(); - std::fs::write(root.join("writable-sibling.txt"), "data").unwrap(); - - let chowned = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&chowned); - let readonly_dir_for_chown = readonly_dir.clone(); - let fake_chown = - move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - if path == readonly_dir_for_chown { - return Err(nix::errno::Errno::EROFS); - } - observed.lock().unwrap().push(path.to_path_buf()); - Ok(()) - }; - - chown_children( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &fake_chown, - ) - .expect("read-only subtree should be skipped"); - - let chowned = chowned.lock().unwrap(); - assert!( - !chowned.contains(&readonly_dir.join("child-under-ro.txt")), - "children under EROFS directory must not be traversed" - ); - assert!( - chowned.contains(&root.join("writable-sibling.txt")), - "writable sibling should still be chowned" - ); - } - - #[cfg(unix)] - #[test] - fn chown_recursive_propagates_non_erofs_errors() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let fake_chown = |_path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - Err(nix::errno::Errno::EPERM) - }; - - let result = chown_recursive( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &fake_chown, - ); - assert!(result.is_err(), "non-EROFS errors should propagate"); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_chowns_only_root() { - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let child = root.join("image-content.txt"); - std::fs::write(&child, "image-owned").unwrap(); - - let chowned = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&chowned); - let fake_chown = - move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - observed.lock().unwrap().push(path.to_path_buf()); - Ok(()) - }; - - prepare_oci_workspace_with( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - &fake_chown, - ) - .expect("workspace root should be prepared"); - - assert_eq!(*chowned.lock().unwrap(), vec![root]); - assert!(child.exists(), "image-provided child should be untouched"); - } - - #[cfg(unix)] - #[test] - fn validate_oci_workspace_accepts_existing_owner_writable_directory() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); - - validate_oci_workspace( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .expect("image owner already has write and traverse authority"); - } - - #[cfg(unix)] - #[test] - fn validate_oci_workspace_accepts_supplementary_group_write_authority() { - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o070)).unwrap(); - let metadata = std::fs::symlink_metadata(&root).unwrap(); - - validate_oci_workspace( - &root, - Some(Uid::from_raw(metadata.uid().wrapping_add(1))), - Some(Gid::from_raw(metadata.gid().wrapping_add(1))), - &[Gid::from_raw(metadata.gid())], - ) - .expect("supplementary group already has write and traverse authority"); - } - - #[cfg(unix)] - #[test] - fn validate_oci_workspace_rejects_unwritable_directory() { - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o755)).unwrap(); - let metadata = std::fs::symlink_metadata(&root).unwrap(); - - let error = validate_oci_workspace( - &root, - Some(Uid::from_raw(metadata.uid().wrapping_add(1))), - Some(Gid::from_raw(metadata.gid().wrapping_add(1))), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("not writable and traversable")); - } - - #[cfg(unix)] - #[test] - fn validate_oci_workspace_rejects_missing_path() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("missing"); - - let error = validate_oci_workspace( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("does not exist")); - } - - #[cfg(target_os = "linux")] - #[test] - #[allow(unsafe_code)] - fn effective_identity_validation_honors_named_user_acl() { - const TEST_UID: u32 = 42_234; - const TEST_GID: u32 = 42_235; - const ACL_XATTR_VERSION: u32 = 2; - const ACL_USER_OBJ: u16 = 0x01; - const ACL_USER: u16 = 0x02; - const ACL_GROUP_OBJ: u16 = 0x04; - const ACL_MASK: u16 = 0x10; - const ACL_OTHER: u16 = 0x20; - const ACL_UNDEFINED_ID: u32 = u32::MAX; - - if !nix::unistd::geteuid().is_root() { - return; - } - - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o711)).unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); - - let mut acl = ACL_XATTR_VERSION.to_ne_bytes().to_vec(); - for (tag, permissions, id) in [ - (ACL_USER_OBJ, 0o7_u16, ACL_UNDEFINED_ID), - (ACL_USER, 0o7_u16, TEST_UID), - (ACL_GROUP_OBJ, 0o0_u16, ACL_UNDEFINED_ID), - (ACL_MASK, 0o7_u16, ACL_UNDEFINED_ID), - (ACL_OTHER, 0o0_u16, ACL_UNDEFINED_ID), - ] { - acl.extend_from_slice(&tag.to_ne_bytes()); - acl.extend_from_slice(&permissions.to_ne_bytes()); - acl.extend_from_slice(&id.to_ne_bytes()); - } - let path = CString::new(root.as_os_str().as_encoded_bytes()).unwrap(); - let name = c"system.posix_acl_access"; - let result = unsafe { - libc::setxattr( - path.as_ptr(), - name.as_ptr(), - acl.as_ptr().cast(), - acl.len(), - 0, - ) - }; - assert_eq!( - result, - 0, - "setxattr failed: {}", - std::io::Error::last_os_error() - ); - - match unsafe { fork() }.expect("fork should succeed") { - ForkResult::Child => { - let credentials_dropped = unsafe { - libc::setgroups(0, std::ptr::null()) == 0 - && libc::setgid(TEST_GID) == 0 - && libc::setuid(TEST_UID) == 0 - }; - let valid = credentials_dropped - && validate_oci_workspace_as_effective_identity(&root).is_ok(); - unsafe { libc::_exit(i32::from(!valid)) }; - } - ForkResult::Parent { child } => { - assert_eq!( - waitpid(child, None).expect("waitpid should succeed"), - WaitStatus::Exited(child, 0), - "named ACL user should retain workspace authority" - ); - } - } - } - - #[cfg(target_os = "linux")] - #[test] - #[allow(unsafe_code)] - fn effective_identity_validation_honors_landlock_denial() { - let dir = tempfile::tempdir_in("/tmp").unwrap(); - let root = dir.path().canonicalize().unwrap().join("project"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); - - let mut policy = policy_with_process(ProcessPolicy::default()); - policy.filesystem = FilesystemPolicy { - read_only: vec![root.clone()], - read_write: Vec::new(), - include_workdir: false, - }; - policy.landlock = LandlockPolicy { - compatibility: openshell_core::policy::LandlockCompatibility::HardRequirement, - }; - let Ok(prepared) = sandbox::linux::prepare_current_user(&policy, None) else { - return; - }; - - match unsafe { fork() }.expect("fork should succeed") { - ForkResult::Child => { - let denied = sandbox::linux::enforce(prepared).is_ok() - && validate_oci_workspace_as_effective_identity(&root).is_err(); - unsafe { libc::_exit(i32::from(!denied)) }; - } - ForkResult::Parent { child } => { - assert_eq!( - waitpid(child, None).expect("waitpid should succeed"), - WaitStatus::Exited(child, 0), - "kernel-effective validation should honor an enforced LSM denial" - ); - } - } - } - - #[cfg(unix)] - #[test] - fn validate_oci_workspace_rejects_restrictive_parent() { - let dir = tempfile::tempdir().unwrap(); - let parent = dir.path().canonicalize().unwrap().join("private"); - let root = parent.join("project"); - std::fs::create_dir_all(&root).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o777)).unwrap(); - let metadata = std::fs::symlink_metadata(&parent).unwrap(); - - let error = validate_oci_workspace( - &root, - Some(Uid::from_raw(metadata.uid().wrapping_add(1))), - Some(Gid::from_raw(metadata.gid().wrapping_add(1))), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("not traversable")); - } - - #[cfg(unix)] - #[test] - fn validate_oci_workspace_rejects_symlink_component() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let base = dir.path().canonicalize().unwrap(); - let target = base.join("target"); - let link = base.join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let error = validate_oci_workspace( - &link, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!(error.to_string().contains("symlink")); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_makes_existing_root_owner_writable() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o555)).unwrap(); - - prepare_oci_workspace_with(&root, None, None, &[], &|_, _, _| Ok(())) - .expect("read-only workspace root should be prepared"); - - let mode = std::fs::symlink_metadata(&root) - .unwrap() - .permissions() - .mode(); - assert_eq!(mode & 0o777, 0o755); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_symlink_root() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let base = dir.path().canonicalize().unwrap(); - let target = base.join("real"); - let link = base.join("link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let err = prepare_oci_workspace( - &link, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - err.to_string().contains("symlink"), - "expected symlink rejection: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_symlink_parent() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let base = dir.path().canonicalize().unwrap(); - let target = base.join("real"); - let parent_link = base.join("parent-link"); - std::fs::create_dir(&target).unwrap(); - symlink(&target, &parent_link).unwrap(); - - let err = prepare_oci_workspace( - &parent_link.join("workspace"), - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - err.to_string().contains("symlink"), - "expected parent symlink rejection: {err}" - ); - assert!( - !target.join("workspace").exists(), - "workspace must not be created through a symlink parent" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_parent_traversal() { - let err = prepare_oci_workspace( - Path::new("/tmp/workspace/../escape"), - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - err.to_string().contains("must be normalized"), - "expected traversal rejection: {err}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_inaccessible_existing_parent() { - let dir = tempfile::tempdir().unwrap(); - let parent = dir.path().canonicalize().unwrap().join("workspace"); - std::fs::create_dir(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); - let metadata = std::fs::symlink_metadata(&parent).unwrap(); - let different_user = Uid::from_raw(metadata.uid().wrapping_add(1)); - let different_group = Gid::from_raw(metadata.gid().wrapping_add(1)); - let root = parent.join("project"); - - let error = prepare_oci_workspace_with( - &root, - Some(different_user), - Some(different_group), - &[], - &|_, _, _| Ok(()), - ) - .unwrap_err(); - - assert!( - error.to_string().contains("is not traversable"), - "unexpected error: {error}" - ); - assert!( - !root.exists(), - "workspace must not be created below an inaccessible parent" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_accepts_supplementary_group_parent() { - let dir = tempfile::tempdir_in("/tmp").unwrap(); - std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o710)).unwrap(); - let parent = dir.path().canonicalize().unwrap().join("workspace"); - std::fs::create_dir(&parent).unwrap(); - std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o710)).unwrap(); - let metadata = std::fs::symlink_metadata(&parent).unwrap(); - let different_user = Uid::from_raw(metadata.uid().wrapping_add(1)); - let different_group = Gid::from_raw(metadata.gid().wrapping_add(1)); - let supplementary_group = Gid::from_raw(metadata.gid()); - let root = parent.join("project"); - - prepare_oci_workspace_with( - &root, - Some(different_user), - Some(different_group), - &[supplementary_group], - &|_, _, _| Ok(()), - ) - .expect("supplementary group execute permission should allow traversal"); - - assert!(root.is_dir()); - } - - #[cfg(not(any( - target_os = "aix", - target_os = "haiku", - target_os = "illumos", - target_os = "ios", - target_os = "macos", - target_os = "redox", - target_os = "solaris" - )))] - #[test] - fn named_user_supplementary_groups_include_primary_group() { - let user = User::from_uid(nix::unistd::geteuid()) - .expect("resolve current UID") - .expect("current user exists"); - - let groups = named_user_supplementary_groups(&user.name, user.gid) - .expect("resolve named-user supplementary groups"); - - assert!(groups.contains(&user.gid)); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_rejects_non_directory_root() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::write(&root, "not a directory").unwrap(); - - let error = prepare_oci_workspace( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - ) - .unwrap_err(); - assert!( - error.to_string().contains("is not a directory"), - "unexpected error: {error}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_propagates_root_chown_error() { - let dir = tempfile::tempdir().unwrap(); - let root = dir.path().canonicalize().unwrap().join("sandbox"); - std::fs::create_dir(&root).unwrap(); - let fake_chown = |_path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - Err(nix::errno::Errno::EROFS) - }; - - let error = prepare_oci_workspace_with( - &root, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - &fake_chown, - ) - .unwrap_err(); - - assert!( - error.to_string().contains("Read-only file system"), - "unexpected error: {error}" - ); - } - - #[cfg(unix)] - #[test] - fn prepare_oci_workspace_creates_missing_root() { - use std::sync::{Arc, Mutex}; - - let dir = tempfile::tempdir().unwrap(); - let missing = dir - .path() - .canonicalize() - .unwrap() - .join("missing") - .join("sandbox"); - let chowned = Arc::new(Mutex::new(Vec::new())); - let observed = Arc::clone(&chowned); - let fake_chown = - move |path: &Path, _uid: Option, _gid: Option| -> nix::Result<()> { - observed.lock().unwrap().push(path.to_path_buf()); - Ok(()) - }; - - prepare_oci_workspace_with( - &missing, - Some(nix::unistd::geteuid()), - Some(nix::unistd::getegid()), - &[], - &fake_chown, - ) - .expect("missing OCI workspace should be created"); - - assert!(missing.is_dir()); - assert_eq!( - std::fs::symlink_metadata(missing.parent().unwrap()) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o755 - ); - assert_eq!(*chowned.lock().unwrap(), vec![missing]); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_modifies_existing_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - std::fs::write( - &passwd, - "root:x:0:0:root:/root:/bin/bash\nsandbox:x:1000:1000::/sandbox:/bin/bash\n", - ) - .unwrap(); - - rewrite_passwd_at(&passwd, "5000", "6000").unwrap(); - - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("sandbox:x:5000:6000::/sandbox:/bin/bash")); - assert!(content.contains("root:x:0:0:root:/root:/bin/bash")); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_appends_when_no_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - std::fs::write(&passwd, "root:x:0:0:root:/root:/bin/bash\n").unwrap(); - - rewrite_passwd_at(&passwd, "5000", "6000").unwrap(); - - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("root:x:0:0:root:/root:/bin/bash")); - assert!(content.contains("sandbox:x:5000:6000::/sandbox:/bin/sh")); - } - - #[cfg(unix)] - #[test] - fn rewrite_group_modifies_existing_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let group = dir.path().join("group"); - std::fs::write(&group, "root:x:0:\nsandbox:x:1000:\n").unwrap(); - - rewrite_group_at(&group, "6000").unwrap(); - - let content = std::fs::read_to_string(&group).unwrap(); - assert!(content.contains("sandbox:x:6000:")); - assert!(content.contains("root:x:0:")); - } - - #[cfg(unix)] - #[test] - fn rewrite_group_appends_when_no_sandbox_entry() { - let dir = tempfile::tempdir().unwrap(); - let group = dir.path().join("group"); - std::fs::write(&group, "root:x:0:\n").unwrap(); - - rewrite_group_at(&group, "6000").unwrap(); - - let content = std::fs::read_to_string(&group).unwrap(); - assert!(content.contains("root:x:0:")); - assert!(content.contains("sandbox:x:6000:")); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_leaves_malformed_entry_unchanged() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - // Only 3 fields — slice pattern should fall through instead of panic. - std::fs::write(&passwd, "sandbox:x:1000\n").unwrap(); - rewrite_passwd_at(&passwd, "5000", "6000").unwrap(); - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("sandbox:x:1000")); - } - - #[cfg(unix)] - #[test] - fn rewrite_group_leaves_malformed_entry_unchanged() { - let dir = tempfile::tempdir().unwrap(); - let group = dir.path().join("group"); - // Only 2 fields — slice pattern should fall through instead of panic. - std::fs::write(&group, "sandbox:x\n").unwrap(); - rewrite_group_at(&group, "6000").unwrap(); - let content = std::fs::read_to_string(&group).unwrap(); - assert!(content.contains("sandbox:x")); - } - - #[cfg(unix)] - #[test] - fn rewrite_passwd_preserves_other_entries() { - let dir = tempfile::tempdir().unwrap(); - let passwd = dir.path().join("passwd"); - std::fs::write( - &passwd, - "root:x:0:0:root:/root:/bin/bash\nnobody:x:65534:65534:nobody:/:/usr/sbin/nologin\nsandbox:x:1000:1000::/sandbox:/bin/bash\n", - ) - .unwrap(); - - rewrite_passwd_at(&passwd, "1234567", "1234567").unwrap(); - - let content = std::fs::read_to_string(&passwd).unwrap(); - assert!(content.contains("root:x:0:0:root:/root:/bin/bash")); - assert!(content.contains("nobody:x:65534:65534:nobody:/:/usr/sbin/nologin")); - assert!(content.contains("sandbox:x:1234567:1234567::/sandbox:/bin/bash")); - assert_eq!(content.lines().count(), 3); - } - - #[tokio::test] - async fn inject_provider_env_skips_supervisor_identity_material() { - let mut cmd = Command::new("/usr/bin/env"); - cmd.env_clear() - .stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::null()); - - let provider_env = HashMap::from([ - ( - "ANTHROPIC_API_KEY".to_string(), - "openshell:resolve:env:ANTHROPIC_API_KEY".to_string(), - ), - ( - openshell_core::sandbox_env::SANDBOX_TOKEN.to_string(), - "provider-token".to_string(), - ), - ( - openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET.to_string(), - "/spiffe-workload-api/spire-agent.sock".to_string(), - ), - ]); - - inject_provider_env(&mut cmd, &provider_env); - - let output = cmd.output().await.expect("spawn env"); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8"); - assert!(stdout.contains("ANTHROPIC_API_KEY=openshell:resolve:env:ANTHROPIC_API_KEY")); - assert!(!stdout.contains(openshell_core::sandbox_env::SANDBOX_TOKEN)); - assert!(!stdout.contains(openshell_core::sandbox_env::PROVIDER_SPIFFE_WORKLOAD_API_SOCKET)); - } - - #[tokio::test] - async fn strip_supervisor_only_env_removes_identity_material() { - let mut cmd = Command::new("/usr/bin/env"); - cmd.stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::null()) - .env("OPENSHELL_ENDPOINT", "https://gateway.example.test"); - - for key in SUPERVISOR_ONLY_ENV_VARS { - cmd.env(key, format!("{key}-secret")); - } - - strip_supervisor_only_env(&mut cmd); - - let output = cmd.output().await.expect("spawn env"); - assert!(output.status.success()); - let stdout = String::from_utf8(output.stdout).expect("utf8"); - - for key in SUPERVISOR_ONLY_ENV_VARS { - assert!( - !stdout - .lines() - .any(|line| line.starts_with(&format!("{key}="))), - "{key} must not be inherited by sandbox child processes" - ); - } - assert!(stdout.contains("OPENSHELL_ENDPOINT=https://gateway.example.test")); - } - - #[test] - fn supervisor_identity_mount_target_uses_socket_parent() { - assert_eq!( - supervisor_identity_mount_target("/spiffe-workload-api/spire-agent.sock") - .expect("plain path should parse"), - Some(PathBuf::from("/spiffe-workload-api")) - ); - assert_eq!( - supervisor_identity_mount_target("unix:/spiffe-workload-api/spire-agent.sock") - .expect("unix path should parse"), - Some(PathBuf::from("/spiffe-workload-api")) - ); - } - - #[test] - fn supervisor_identity_mount_target_ignores_empty_socket_path() { - assert_eq!( - supervisor_identity_mount_target(" ").expect("empty path should be ignored"), - None - ); - } - - #[test] - fn supervisor_identity_mount_target_rejects_unhideable_endpoints() { - assert_eq!( - supervisor_identity_mount_target("tcp:127.0.0.1:8081") - .expect("tcp endpoint should not require mount hiding"), - None - ); - assert!(supervisor_identity_mount_target("spiffe-workload-api/spire-agent.sock").is_err()); - assert!(supervisor_identity_mount_target("/spire-agent.sock").is_err()); - } - - #[test] - fn supervisor_identity_mount_target_rejects_shared_root_shadowing() { - for socket_path in [ - "/run/spire-agent.sock", - "/var/spire-agent.sock", - "/tmp/spire-agent.sock", - "/etc/spire-agent.sock", - ] { - let err = supervisor_identity_mount_target(socket_path) - .expect_err("shared root shadowing should be rejected"); - assert!(err.to_string().contains("dedicated subdirectory")); - } - - assert_eq!( - supervisor_identity_mount_target("/run/spire/spire-agent.sock") - .expect("dedicated subdirectory should be accepted"), - Some(PathBuf::from("/run/spire")) - ); - } - - // ---- Numeric UID tests (Phase 2) ---- - - #[test] - fn drop_privileges_accepts_numeric_uid() { - // When running as non-root, a numeric UID/GID that matches the - // current process should succeed without any passwd lookup. - if nix::unistd::geteuid().is_root() { - return; - } - - let uid_raw = nix::unistd::geteuid().as_raw(); - let gid_raw = nix::unistd::getegid().as_raw(); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(uid_raw.to_string()), - run_as_group: Some(gid_raw.to_string()), - }); - - assert!( - drop_privileges(&policy).is_ok(), - "should accept current process UID/GID as numeric strings" - ); - } - - #[test] - fn drop_privileges_numeric_uid_skips_initgroups() { - // When running as non-root with a numeric user but group matches, - // initgroups should not be called (guard: target_uid != geteuid()). - if nix::unistd::geteuid().is_root() { - return; - } - - let current_uid = nix::unistd::geteuid().as_raw(); - - // Use a different group name that exists (the current one). - let current_group = Group::from_gid(nix::unistd::getegid()) - .expect("should resolve current group") - .expect("current group should exist"); - - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some(current_uid.to_string()), // numeric UID, no passwd entry needed - run_as_group: Some(current_group.name), // name-based group - }); - - assert!( - drop_privileges(&policy).is_ok(), - "should accept numeric UID with name-based group (initgroups guarded)" - ); - } - - #[test] - fn numeric_uid_privilege_drop_child() { - if std::env::var_os("OPENSHELL_TEST_NUMERIC_UID_CHILD").is_none() { - return; - } - let policy = policy_with_process(ProcessPolicy { - run_as_user: Some("999999".into()), - run_as_group: Some("999999".into()), - }); - match drop_privileges(&policy) { - Ok(()) => {} - Err(e) => { - assert!( - !e.to_string().contains("Failed to resolve user record"), - "unexpected error for numeric UID without passwd entry: {e}" - ); - } - } - } - - #[test] - fn drop_privileges_numeric_uid_without_passwd_entry_skips_lookup() { - let mut cmd = std::process::Command::new(std::env::current_exe().expect("current exe")); - cmd.arg("numeric_uid_privilege_drop_child") - .arg("--nocapture") - .env("OPENSHELL_TEST_NUMERIC_UID_CHILD", "1") - .stdin(StdStdio::null()) - .stdout(StdStdio::piped()) - .stderr(StdStdio::piped()); - let output = cmd.output().expect("spawn child"); - assert!( - output.status.success(), - "numeric UID privilege drop child failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - } -} diff --git a/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs b/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs deleted file mode 100644 index 0a5bbfdc7..000000000 --- a/crates/openshell-supervisor-process/src/sandbox/linux/landlock.rs +++ /dev/null @@ -1,769 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Landlock filesystem sandboxing. - -use landlock::{ - ABI, Access, AccessFs, BitFlags, CompatLevel, Compatible, PathBeneath, PathFd, PathFdError, - Ruleset, RulesetAttr, RulesetCreatedAttr, -}; -use miette::{IntoDiagnostic, Result}; -use openshell_core::policy::{LandlockCompatibility, SandboxPolicy}; -use std::os::fd::AsFd; -use std::path::{Path, PathBuf}; -use tracing::debug; - -/// Result of probing the kernel for Landlock support. -#[derive(Debug)] -pub enum LandlockAvailability { - /// Landlock is available with the given ABI version. - Available { abi: i32 }, - /// Kernel does not implement Landlock (ENOSYS). - NotImplemented, - /// Landlock is compiled in but not enabled at boot (EOPNOTSUPP). - NotEnabled, - /// Landlock syscall is blocked, likely by a container seccomp profile (EPERM). - Blocked, - /// Unexpected error from the probe syscall. - Unknown(i32), -} - -impl std::fmt::Display for LandlockAvailability { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Available { abi } => write!(f, "available (ABI v{abi})"), - Self::NotImplemented => { - write!(f, "not implemented (kernel lacks CONFIG_SECURITY_LANDLOCK)") - } - Self::NotEnabled => write!( - f, - "not enabled (Landlock built into kernel but not in active LSM list)" - ), - Self::Blocked => write!( - f, - "blocked (container seccomp profile denies Landlock syscalls)" - ), - Self::Unknown(errno) => write!(f, "unexpected probe error (errno {errno})"), - } - } -} - -/// Probe the kernel for Landlock support by issuing the `landlock_create_ruleset` -/// syscall with the version-check flag. -/// -/// This is safe to call from the parent process and does not create any file -/// descriptors or modify process state. -pub fn probe_availability() -> LandlockAvailability { - // landlock_create_ruleset syscall number (same on x86_64 and aarch64). - const SYS_LANDLOCK_CREATE_RULESET: libc::c_long = 444; - // Flag: return the highest supported ABI version instead of creating a ruleset. - const LANDLOCK_CREATE_RULESET_VERSION: libc::c_uint = 1 << 0; - - // SAFETY: landlock_create_ruleset(NULL, 0, LANDLOCK_CREATE_RULESET_VERSION) - // is a read-only probe that returns the ABI version or an error code. - // It does not allocate file descriptors or modify process state. - #[allow(unsafe_code)] - let ret = unsafe { - libc::syscall( - SYS_LANDLOCK_CREATE_RULESET, - std::ptr::null::(), - 0_usize, - LANDLOCK_CREATE_RULESET_VERSION, - ) - }; - - if ret >= 0 { - #[allow(clippy::cast_possible_truncation)] - LandlockAvailability::Available { abi: ret as i32 } - } else { - let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); - match errno { - libc::ENOSYS => LandlockAvailability::NotImplemented, - libc::EOPNOTSUPP => LandlockAvailability::NotEnabled, - libc::EPERM => LandlockAvailability::Blocked, - other => LandlockAvailability::Unknown(other), - } - } -} - -/// A prepared Landlock ruleset ready to be enforced via `restrict_self()`. -/// -/// Created by [`prepare`] while running as root (so `PathFd::new()` can open -/// any path regardless of DAC permissions). Enforced by [`enforce`] after -/// `drop_privileges()` — `restrict_self()` does not require elevated privileges. -pub struct PreparedRuleset { - ruleset: landlock::RulesetCreated, - compatibility: LandlockCompatibility, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PathOpenMode { - Privileged, - CurrentUser, -} - -/// Phase 1: Open `PathFds` and build the Landlock ruleset **as root**. -/// -/// This must run before `drop_privileges()` so that `PathFd::new()` can open -/// paths that are only accessible to root (e.g. mode 700 directories). -/// -/// Returns `None` if there are no filesystem paths to restrict (no-op). -/// Returns `Some(PreparedRuleset)` on success, or an error. -pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result> { - prepare_with_path_open_mode(policy, workdir, PathOpenMode::Privileged) -} - -/// Phase 1 for already-unprivileged workloads. -/// -/// Kubernetes sidecar mode starts the process supervisor as the sandbox UID, so -/// Landlock path FDs are opened as the same UID that will run the workload. -/// Paths this UID cannot open are already unavailable to the workload; omit -/// them from the allowlist and let the resulting ruleset deny everything else. -pub fn prepare_current_user( - policy: &SandboxPolicy, - workdir: Option<&str>, -) -> Result> { - prepare_with_path_open_mode(policy, workdir, PathOpenMode::CurrentUser) -} - -fn prepare_with_path_open_mode( - policy: &SandboxPolicy, - workdir: Option<&str>, - path_open_mode: PathOpenMode, -) -> Result> { - let read_only = policy.filesystem.read_only.clone(); - let mut read_write = policy.filesystem.read_write.clone(); - - if policy.filesystem.include_workdir - && let Some(dir) = workdir - { - let workdir_path = PathBuf::from(dir); - if !read_write.contains(&workdir_path) { - read_write.push(workdir_path); - } - } - - if read_only.is_empty() && read_write.is_empty() { - if matches!( - policy.landlock.compatibility, - LandlockCompatibility::HardRequirement - ) { - miette::bail!( - "landlock.compatibility is hard_requirement but no filesystem paths are configured" - ); - } - return Ok(None); - } - - let compatibility = &policy.landlock.compatibility; - - // Probe first: kernels without Landlock (e.g. gVisor's sentry returns - // ENOSYS) would otherwise log misleading "Applying"+"Built" events. - let availability = probe_availability(); - if !matches!(availability, LandlockAvailability::Available { .. }) { - match compatibility { - LandlockCompatibility::BestEffort => { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Open) - .severity(openshell_ocsf::SeverityId::High) - .confidence(openshell_ocsf::ConfidenceId::High) - .is_alert(true) - .finding_info( - openshell_ocsf::FindingInfo::new( - "landlock-unavailable", - "Landlock Filesystem Sandbox Unavailable", - ) - .with_desc(&format!( - "Running WITHOUT filesystem restrictions: Landlock is {availability}. \ - Set landlock.compatibility to 'hard_requirement' to make this fatal." - )), - ) - .message(format!( - "Landlock filesystem sandbox unavailable: {availability}" - )) - .build() - ); - return Ok(None); - } - LandlockCompatibility::HardRequirement => { - return Err(miette::miette!( - "Landlock unavailable in hard_requirement mode: {availability}" - )); - } - } - } - - let total_paths = read_only.len() + read_write.len(); - let abi = ABI::V2; - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "applying") - .message(format!( - "Applying Landlock filesystem sandbox [abi:{abi:?} compat:{:?} ro:{} rw:{}]", - policy.landlock.compatibility, - read_only.len(), - read_write.len(), - )) - .build() - ); - - let result: Result = (|| { - let access_all = AccessFs::from_all(abi); - let access_read = AccessFs::from_read(abi); - - let mut ruleset = Ruleset::default(); - ruleset = ruleset - .set_compatibility(compat_level(compatibility)) - .handle_access(access_all) - .into_diagnostic()?; - - let mut ruleset = ruleset.create().into_diagnostic()?; - let mut rules_applied: usize = 0; - - for path in &read_only { - if let Some(path_fd) = try_open_path(path, compatibility, path_open_mode)? { - let allowed_access = access_for_path_fd(&path_fd, access_read, abi)?; - debug!(path = %path.display(), "Landlock allow read-only"); - ruleset = ruleset - .add_rule(PathBeneath::new(path_fd, allowed_access)) - .into_diagnostic()?; - rules_applied += 1; - } - } - - for path in &read_write { - if let Some(path_fd) = try_open_path(path, compatibility, path_open_mode)? { - let allowed_access = access_for_path_fd(&path_fd, access_all, abi)?; - debug!(path = %path.display(), "Landlock allow read-write"); - ruleset = ruleset - .add_rule(PathBeneath::new(path_fd, allowed_access)) - .into_diagnostic()?; - rules_applied += 1; - } - } - - if rules_applied == 0 { - return Err(miette::miette!( - "Landlock ruleset has zero valid paths — all {} path(s) failed to open. \ - Refusing to apply an empty ruleset that would block all filesystem access.", - total_paths, - )); - } - - let skipped = total_paths - rules_applied; - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "built") - .message(format!( - "Landlock ruleset built [rules_applied:{rules_applied} skipped:{skipped}]" - )) - .build() - ); - - Ok(PreparedRuleset { - ruleset, - compatibility: compatibility.clone(), - }) - })(); - - match result { - Ok(prepared) => Ok(Some(prepared)), - Err(err) => { - if matches!(compatibility, LandlockCompatibility::BestEffort) { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Open) - .severity(openshell_ocsf::SeverityId::High) - .confidence(openshell_ocsf::ConfidenceId::High) - .is_alert(true) - .finding_info( - openshell_ocsf::FindingInfo::new( - "landlock-unavailable", - "Landlock Filesystem Sandbox Unavailable", - ) - .with_desc(&format!( - "Running WITHOUT filesystem restrictions: {err}. \ - Set landlock.compatibility to 'hard_requirement' to make this fatal." - )), - ) - .message(format!("Landlock filesystem sandbox unavailable: {err}")) - .build() - ); - Ok(None) - } else { - Err(err) - } - } - } -} - -/// Phase 2: Enforce a prepared Landlock ruleset by calling `restrict_self()`. -/// -/// This runs **after** `drop_privileges()`. The `restrict_self()` syscall does -/// not require root — it only restricts the calling thread (and its future -/// children), which is always permitted. -/// -/// Respects the same `best_effort` / `hard_requirement` compatibility as -/// [`prepare`]: if `restrict_self()` fails and the policy is `best_effort`, -/// the error is logged and the sandbox continues without Landlock. -pub fn enforce(prepared: PreparedRuleset) -> Result<()> { - let result = prepared.ruleset.restrict_self().into_diagnostic(); - if let Err(err) = result { - if matches!(prepared.compatibility, LandlockCompatibility::BestEffort) { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Open) - .severity(openshell_ocsf::SeverityId::High) - .confidence(openshell_ocsf::ConfidenceId::High) - .is_alert(true) - .finding_info( - openshell_ocsf::FindingInfo::new( - "landlock-enforce-failed", - "Landlock restrict_self Failed", - ) - .with_desc(&format!( - "Ruleset was prepared but restrict_self() failed: {err}. \ - Running WITHOUT filesystem restrictions. \ - Set landlock.compatibility to 'hard_requirement' to make this fatal." - )), - ) - .message(format!( - "Landlock restrict_self failed (best_effort): {err}" - )) - .build() - ); - return Ok(()); - } - return Err(err); - } - Ok(()) -} - -/// Legacy single-phase apply. Kept for non-Linux platforms and tests. -/// On Linux, callers should use [`prepare`] + [`enforce`] for correct -/// privilege ordering. -#[allow(dead_code)] // Retained for backward compat; live callers use prepare+enforce. -pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { - if let Some(prepared) = prepare(policy, workdir)? { - enforce(prepared)?; - } - Ok(()) -} - -/// Tailor a rule's access mask to the inode referenced by its already-open FD. -/// -/// Landlock directory-only rights such as `ReadDir` are invalid for regular -/// files and device nodes in hard-requirement mode. Classifying through the -/// same `PathFd` used by the rule avoids a pathname TOCTOU race. -fn access_for_path_fd( - path_fd: &PathFd, - requested_access: BitFlags, - abi: ABI, -) -> Result> { - let stat = rustix::fs::fstat(path_fd.as_fd()).into_diagnostic()?; - Ok(match rustix::fs::FileType::from_raw_mode(stat.st_mode) { - rustix::fs::FileType::Directory => requested_access, - _ => requested_access & AccessFs::from_file(abi), - }) -} - -/// Attempt to open a path for Landlock rule creation. -/// -/// In `BestEffort` mode, inaccessible paths (missing, permission denied, symlink -/// loops, etc.) are skipped with a warning and `Ok(None)` is returned so the -/// caller can continue building the ruleset from the remaining valid paths. -/// -/// In `HardRequirement` mode, any failure is fatal — the caller propagates the -/// error, which ultimately aborts sandbox startup. -fn try_open_path( - path: &Path, - compatibility: &LandlockCompatibility, - path_open_mode: PathOpenMode, -) -> Result> { - match PathFd::new(path) { - Ok(fd) => Ok(Some(fd)), - Err(err) => { - let reason = classify_path_fd_error(&err); - let is_not_found = matches!( - &err, - PathFdError::OpenCall { source, .. } - if source.kind() == std::io::ErrorKind::NotFound - ); - if matches!(path_open_mode, PathOpenMode::CurrentUser) { - if is_not_found { - debug!( - path = %path.display(), - reason, - "Skipping non-existent Landlock path for current user" - ); - } else { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Other, "already-denied") - .message(format!( - "Skipping inaccessible Landlock path for current user [path:{} error:{err}]", - path.display() - )) - .build() - ); - } - return Ok(None); - } - match compatibility { - LandlockCompatibility::BestEffort => { - // NotFound is expected for stale baseline paths (e.g. - // /app baked into the server-stored policy but absent - // in this container image). Log at debug! to avoid - // polluting SSH exec stdout — the pre_exec hook - // inherits the tracing subscriber whose writer targets - // fd 1 (the pipe/PTY). - // - // Other errors (permission denied, symlink loops, etc.) - // are genuinely unexpected and logged at warn!. - if is_not_found { - debug!( - path = %path.display(), - reason, - "Skipping non-existent Landlock path (best-effort mode)" - ); - } else { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Medium) - .status(openshell_ocsf::StatusId::Failure) - .state(openshell_ocsf::StateId::Other, "degraded") - .message(format!( - "Skipping inaccessible Landlock path (best-effort) [path:{} error:{err}]", - path.display() - )) - .build() - ); - } - Ok(None) - } - LandlockCompatibility::HardRequirement => Err(miette::miette!( - "Landlock path unavailable in hard_requirement mode: {} ({}): {}", - path.display(), - reason, - err, - )), - } - } - } -} - -/// Classify a [`PathFdError`] into a human-readable reason. -/// -/// `PathFd::new()` wraps `open(path, O_PATH | O_CLOEXEC)` which can fail for -/// several reasons beyond simple non-existence. The `PathFdError::OpenCall` -/// variant wraps the underlying `std::io::Error`. -fn classify_path_fd_error(err: &PathFdError) -> &'static str { - match err { - PathFdError::OpenCall { source, .. } => classify_io_error(source), - // PathFdError is #[non_exhaustive], handle future variants gracefully. - _ => "unexpected error", - } -} - -/// Classify a `std::io::Error` into a human-readable reason string. -fn classify_io_error(err: &std::io::Error) -> &'static str { - match err.kind() { - std::io::ErrorKind::NotFound => "path does not exist", - std::io::ErrorKind::PermissionDenied => "permission denied", - _ => match err.raw_os_error() { - Some(40) => "too many symlink levels", // ELOOP - Some(36) => "path name too long", // ENAMETOOLONG - Some(20) => "path component is not a directory", // ENOTDIR - _ => "unexpected error", - }, - } -} - -fn compat_level(level: &LandlockCompatibility) -> CompatLevel { - match level { - LandlockCompatibility::BestEffort => CompatLevel::BestEffort, - LandlockCompatibility::HardRequirement => CompatLevel::HardRequirement, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use openshell_core::policy::{FilesystemPolicy, LandlockPolicy, NetworkPolicy, ProcessPolicy}; - - fn hard_requirement_policy(read_only: Vec, read_write: Vec) -> SandboxPolicy { - SandboxPolicy { - version: 1, - filesystem: FilesystemPolicy { - read_only, - read_write, - include_workdir: false, - }, - network: NetworkPolicy::default(), - landlock: LandlockPolicy { - compatibility: LandlockCompatibility::HardRequirement, - }, - process: ProcessPolicy::default(), - } - } - - #[test] - fn prepare_hard_requirement_accepts_device_paths() { - if !matches!(probe_availability(), LandlockAvailability::Available { .. }) { - return; - } - - let policy = hard_requirement_policy( - vec![PathBuf::from("/tmp"), PathBuf::from("/dev/urandom")], - vec![PathBuf::from("/dev/null")], - ); - - let result = prepare(&policy, None); - if let Err(err) = result { - panic!("hard_requirement should accept mixed directory and device paths: {err}"); - } - } - #[test] - fn prepare_hard_requirement_no_paths_aborts() { - // Zero configured paths under hard_requirement must fail startup rather - // than silently running without filesystem restrictions. - let policy = hard_requirement_policy(vec![], vec![]); - let Err(err) = prepare(&policy, None) else { - panic!("should abort with no paths"); - }; - let msg = err.to_string(); - assert!( - msg.contains("hard_requirement") && msg.contains("no filesystem paths"), - "error should explain the empty hard_requirement policy: {msg}" - ); - } - - #[test] - fn prepare_best_effort_no_paths_is_noop() { - let policy = SandboxPolicy { - version: 1, - filesystem: FilesystemPolicy { - read_only: vec![], - read_write: vec![], - include_workdir: false, - }, - network: NetworkPolicy::default(), - landlock: LandlockPolicy { - compatibility: LandlockCompatibility::BestEffort, - }, - process: ProcessPolicy::default(), - }; - let prepared = prepare(&policy, None).expect("best_effort no-op should succeed"); - assert!(prepared.is_none(), "no paths should produce no ruleset"); - } - - #[test] - fn prepare_include_workdir_counts_as_configured_path() { - // With no explicit paths but include_workdir set, the workdir must be - // treated as a configured path — so the zero-path abort must NOT fire. - let policy = SandboxPolicy { - version: 1, - filesystem: FilesystemPolicy { - read_only: vec![], - read_write: vec![], - include_workdir: true, - }, - network: NetworkPolicy::default(), - landlock: LandlockPolicy { - compatibility: LandlockCompatibility::HardRequirement, - }, - process: ProcessPolicy::default(), - }; - // Any error (e.g. Landlock unavailable on this host) is acceptable, but - // it must not be the "no filesystem paths" abort. - if let Err(err) = prepare(&policy, Some("/tmp")) { - let msg = err.to_string(); - assert!( - !msg.contains("no filesystem paths"), - "workdir should count as a configured path: {msg}" - ); - } - } - - fn tailored_access(path: &Path, requested_access: BitFlags) -> BitFlags { - let path_fd = PathFd::new(path).unwrap(); - access_for_path_fd(&path_fd, requested_access, ABI::V2).unwrap() - } - - #[test] - fn access_for_path_fd_preserves_directory_access() { - let dir = tempfile::tempdir().unwrap(); - let requested_access = AccessFs::from_all(ABI::V2); - - assert_eq!( - tailored_access(dir.path(), requested_access), - requested_access - ); - } - - #[test] - fn access_for_path_fd_limits_regular_file_access() { - let file = tempfile::NamedTempFile::new().unwrap(); - let requested_access = AccessFs::from_all(ABI::V2); - - assert_eq!( - tailored_access(file.path(), requested_access), - requested_access & AccessFs::from_file(ABI::V2) - ); - } - - #[test] - fn access_for_path_fd_limits_character_device_access() { - let requested_read = AccessFs::from_read(ABI::V2); - let requested_write = AccessFs::from_all(ABI::V2); - - assert_eq!( - tailored_access(Path::new("/dev/urandom"), requested_read), - requested_read & AccessFs::from_file(ABI::V2) - ); - assert_eq!( - tailored_access(Path::new("/dev/null"), requested_write), - requested_write & AccessFs::from_file(ABI::V2) - ); - } - - #[test] - fn access_for_path_fd_classifies_symlink_target() { - use std::os::unix::fs::symlink; - - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("target"); - let link = dir.path().join("link"); - std::fs::File::create(&target).unwrap(); - symlink(&target, &link).unwrap(); - - let requested_access = AccessFs::from_all(ABI::V2); - assert_eq!( - tailored_access(&link, requested_access), - requested_access & AccessFs::from_file(ABI::V2) - ); - } - - #[test] - fn try_open_path_best_effort_returns_none_for_missing_path() { - let result = try_open_path( - &PathBuf::from("/nonexistent/openshell/test/path"), - &LandlockCompatibility::BestEffort, - PathOpenMode::Privileged, - ); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); - } - - #[test] - fn try_open_path_hard_requirement_errors_for_missing_path() { - let result = try_open_path( - &PathBuf::from("/nonexistent/openshell/test/path"), - &LandlockCompatibility::HardRequirement, - PathOpenMode::Privileged, - ); - assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!( - err_msg.contains("hard_requirement"), - "error should mention hard_requirement mode: {err_msg}" - ); - assert!( - err_msg.contains("does not exist"), - "error should include the classified reason: {err_msg}" - ); - } - - #[test] - fn try_open_path_succeeds_for_existing_path() { - let dir = tempfile::tempdir().unwrap(); - let result = try_open_path( - dir.path(), - &LandlockCompatibility::BestEffort, - PathOpenMode::Privileged, - ); - assert!(result.is_ok()); - assert!(result.unwrap().is_some()); - } - - #[test] - fn try_open_path_current_user_skips_missing_path_in_hard_requirement() { - let result = try_open_path( - &PathBuf::from("/nonexistent/openshell/test/path"), - &LandlockCompatibility::HardRequirement, - PathOpenMode::CurrentUser, - ); - assert!(result.is_ok()); - assert!(result.unwrap().is_none()); - } - - #[test] - fn classify_not_found() { - let err = std::io::Error::from_raw_os_error(libc::ENOENT); - assert_eq!(classify_io_error(&err), "path does not exist"); - } - - #[test] - fn classify_permission_denied() { - let err = std::io::Error::from_raw_os_error(libc::EACCES); - assert_eq!(classify_io_error(&err), "permission denied"); - } - - #[test] - fn classify_symlink_loop() { - let err = std::io::Error::from_raw_os_error(libc::ELOOP); - assert_eq!(classify_io_error(&err), "too many symlink levels"); - } - - #[test] - fn classify_name_too_long() { - let err = std::io::Error::from_raw_os_error(libc::ENAMETOOLONG); - assert_eq!(classify_io_error(&err), "path name too long"); - } - - #[test] - fn classify_not_a_directory() { - let err = std::io::Error::from_raw_os_error(libc::ENOTDIR); - assert_eq!(classify_io_error(&err), "path component is not a directory"); - } - - #[test] - fn classify_unknown_error() { - let err = std::io::Error::from_raw_os_error(libc::EIO); - assert_eq!(classify_io_error(&err), "unexpected error"); - } - - #[test] - fn classify_path_fd_error_extracts_io_error() { - // Use PathFd::new on a non-existent path to get a real PathFdError - // (the OpenCall variant is #[non_exhaustive] and can't be constructed directly). - let err = PathFd::new("/nonexistent/openshell/classify/test").unwrap_err(); - assert_eq!(classify_path_fd_error(&err), "path does not exist"); - } - - #[test] - fn probe_availability_returns_a_result() { - // The probe should not panic regardless of whether Landlock is available. - // On Linux hosts with Landlock, this returns Available; on Docker Desktop - // linuxkit or older kernels, it returns NotImplemented/NotEnabled/Blocked. - let result = probe_availability(); - let display = format!("{result}"); - assert!( - !display.is_empty(), - "probe_availability Display should produce output" - ); - // Verify the Debug impl works too. - let debug = format!("{result:?}"); - assert!( - !debug.is_empty(), - "probe_availability Debug should produce output" - ); - } -} diff --git a/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs b/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs deleted file mode 100644 index 8afb3caef..000000000 --- a/crates/openshell-supervisor-process/src/sandbox/linux/mod.rs +++ /dev/null @@ -1,227 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Linux sandbox implementation using Landlock and seccomp. - -mod landlock; -mod seccomp; - -use miette::Result; -use openshell_core::policy::SandboxPolicy; -use std::path::PathBuf; -use std::sync::Once; - -/// Opaque handle to a prepared-but-not-yet-enforced sandbox. -/// Holds the Landlock ruleset with `PathFds` opened before child exec. -pub struct PreparedSandbox { - landlock: Option, - policy: SandboxPolicy, -} - -/// Phase 1: Prepare sandbox restrictions **as root** (before `drop_privileges`). -/// -/// Opens Landlock `PathFds` while the process still has root privileges, -/// ensuring paths like mode-700 directories are accessible. -pub fn prepare(policy: &SandboxPolicy, workdir: Option<&str>) -> Result { - let landlock = landlock::prepare(policy, workdir)?; - Ok(PreparedSandbox { - landlock, - policy: policy.clone(), - }) -} - -/// Phase 1 for already-unprivileged workloads. -/// -/// Opens Landlock `PathFds` as the current UID. This is used by Kubernetes -/// sidecar mode, where the agent container already runs as the sandbox user. -pub fn prepare_current_user( - policy: &SandboxPolicy, - workdir: Option<&str>, -) -> Result { - let landlock = landlock::prepare_current_user(policy, workdir)?; - Ok(PreparedSandbox { - landlock, - policy: policy.clone(), - }) -} - -/// Phase 2: Enforce prepared sandbox restrictions (after `drop_privileges`). -/// -/// Calls `restrict_self()` for Landlock and applies seccomp filters. -/// Neither operation requires root privileges. -pub fn enforce(prepared: PreparedSandbox) -> Result<()> { - if let Some(ruleset) = prepared.landlock { - landlock::enforce(ruleset)?; - } - seccomp::apply(&prepared.policy)?; - Ok(()) -} - -/// Apply the supervisor seccomp prelude after privileged bootstrap completes. -pub fn apply_supervisor_prelude() -> Result<()> { - seccomp::apply_supervisor_prelude() -} - -/// Legacy single-phase apply. Kept for backward compatibility. -/// New callers should use [`prepare`] + [`enforce`] for correct privilege ordering. -#[allow(dead_code)] // Retained for backward compat; live callers use prepare+enforce. -pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { - landlock::apply(policy, workdir)?; - seccomp::apply(policy)?; - Ok(()) -} - -/// Probe Landlock availability and emit OCSF logs from the parent process. -/// -/// This must be called **before** `pre_exec` / `fork()` so that the OCSF events -/// are emitted through the parent's tracing subscriber (the child process after -/// fork does not have a working tracing pipeline). -pub fn log_sandbox_readiness(policy: &SandboxPolicy, workdir: Option<&str>) { - static PROBED: Once = Once::new(); - let mut already_probed = true; - PROBED.call_once(|| already_probed = false); - if already_probed { - return; - } - - let mut read_write = policy.filesystem.read_write.clone(); - let read_only = &policy.filesystem.read_only; - - if policy.filesystem.include_workdir - && let Some(dir) = workdir - { - let workdir_path = PathBuf::from(dir); - if !read_write.contains(&workdir_path) { - read_write.push(workdir_path); - } - } - - let total_paths = read_only.len() + read_write.len(); - - if total_paths == 0 { - if matches!( - policy.landlock.compatibility, - openshell_core::policy::LandlockCompatibility::HardRequirement - ) { - // hard_requirement with no paths is fatal (see `landlock::prepare`). - // Emit a failure state so operators don't see a misleading "skipped" - // success event immediately before startup aborts. - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::High) - .status(openshell_ocsf::StatusId::Failure) - .state(openshell_ocsf::StateId::Other, "invalid") - .message( - "Landlock hard_requirement but no filesystem paths configured; \ - sandbox startup will abort" - .to_string(), - ) - .build() - ); - // Dual-emit a security finding for the unsafe policy (per OCSF - // guidance: pair the domain event with a DetectionFinding). - openshell_ocsf::ocsf_emit!( - openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Open) - .severity(openshell_ocsf::SeverityId::High) - .confidence(openshell_ocsf::ConfidenceId::High) - .is_alert(true) - .finding_info( - openshell_ocsf::FindingInfo::new( - "landlock-hard-requirement-no-paths", - "Landlock Hard Requirement Without Paths", - ) - .with_desc( - "landlock.compatibility is hard_requirement but no filesystem \ - paths are configured; sandbox startup will abort.", - ), - ) - .message( - "Landlock hard_requirement with no filesystem paths configured".to_string(), - ) - .build() - ); - } else { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Other, "skipped") - .message( - "Landlock filesystem sandbox skipped: no paths configured".to_string(), - ) - .build() - ); - } - return; - } - - let availability = landlock::probe_availability(); - if let landlock::LandlockAvailability::Available { abi } = &availability { - openshell_ocsf::ocsf_emit!( - openshell_ocsf::ConfigStateChangeBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .state(openshell_ocsf::StateId::Enabled, "probed") - .message(format!( - "Landlock filesystem sandbox available \ - [abi:v{abi} compat:{:?} ro:{} rw:{}]", - policy.landlock.compatibility, - read_only.len(), - read_write.len(), - )) - .build() - ); - } else { - // Landlock is NOT available — this is the critical log that was - // previously invisible because it only fired inside pre_exec. - let is_best_effort = matches!( - policy.landlock.compatibility, - openshell_core::policy::LandlockCompatibility::BestEffort - ); - let (desc, msg) = if is_best_effort { - ( - format!( - "Sandbox will run WITHOUT filesystem restrictions: {availability}. \ - Policy requests {total_paths} path rule(s) \ - (ro:{} rw:{}) but Landlock cannot enforce them. \ - Set landlock.compatibility to 'hard_requirement' to make this fatal.", - read_only.len(), - read_write.len(), - ), - format!( - "Landlock filesystem sandbox unavailable (best_effort, degraded): {availability}" - ), - ) - } else { - ( - format!( - "Landlock is unavailable: {availability}. \ - Policy requires {total_paths} path rule(s) \ - (ro:{} rw:{}) with hard_requirement — sandbox startup will fail.", - read_only.len(), - read_write.len(), - ), - format!( - "Landlock filesystem sandbox unavailable (hard_requirement, will fail): {availability}" - ), - ) - }; - openshell_ocsf::ocsf_emit!( - openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Open) - .severity(openshell_ocsf::SeverityId::High) - .confidence(openshell_ocsf::ConfidenceId::High) - .is_alert(true) - .finding_info( - openshell_ocsf::FindingInfo::new( - "landlock-unavailable", - "Landlock Filesystem Sandbox Unavailable", - ) - .with_desc(&desc), - ) - .message(msg) - .build() - ); - } -} diff --git a/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs b/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs deleted file mode 100644 index a9c67af95..000000000 --- a/crates/openshell-supervisor-process/src/sandbox/linux/seccomp.rs +++ /dev/null @@ -1,841 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Seccomp syscall filtering. -//! -//! The filter uses a default-allow policy with targeted blocks: -//! -//! 1. **Socket domain blocks** -- prevent raw/kernel sockets that bypass the proxy -//! 2. **Unconditional syscall blocks** -- block syscalls that enable sandbox escape -//! (fileless exec, ptrace, BPF, cross-process memory access, `io_uring`, mount) -//! 3. **Conditional syscall blocks** -- block dangerous flag combinations on otherwise -//! needed syscalls (`execveat+AT_EMPTY_PATH`, `unshare+CLONE_NEWUSER`, -//! `seccomp+SET_MODE_FILTER`) -//! -//! ## `AF_NETLINK` policy -//! -//! `AF_NETLINK` sockets are allowed **only** for the `NETLINK_ROUTE` protocol -//! (protocol value 0). All other netlink protocols are blocked with `EPERM`. -//! -//! `NETLINK_ROUTE` is required by `getifaddrs(3)` on Linux (used by Node.js, -//! Python, Go, and many HTTP/gRPC client libraries during startup). Without it -//! those runtimes fail to enumerate network interfaces even when they have no -//! intent to modify them. -//! -//! The risk is contained by existing sandbox layers: -//! - **Privilege drop**: `CAP_NET_ADMIN` is not granted, so all write operations -//! (add/delete routes, addresses, interfaces) fail with `EPERM` regardless. -//! - **Network namespace**: the sandboxed process sees only `lo` and one veth; -//! no host interfaces are visible. -//! - **nftables bypass rules**: all non-proxy traffic is rejected at the -//! netfilter level regardless of what the sandbox learns about its interfaces. -//! -//! Every other netlink protocol (`NETLINK_SOCK_DIAG`, `NETLINK_NETFILTER`, -//! `NETLINK_AUDIT`, `NETLINK_XFRM`, `NETLINK_GENERIC`, etc.) remains blocked. - -use miette::{IntoDiagnostic, Result}; -use openshell_core::policy::{NetworkMode, SandboxPolicy}; -use seccompiler::{ - SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter, SeccompRule, - apply_filter, apply_filter_all_threads, -}; -use std::collections::BTreeMap; -use std::convert::TryInto; -use tracing::debug; - -/// Value of `SECCOMP_SET_MODE_FILTER` (linux/seccomp.h). -const SECCOMP_SET_MODE_FILTER: u64 = 1; - -// libc 0.2.185 omits `SYS_kexec_file_load` from the musl/aarch64 bindings even -// though the kernel exposes syscall 294. Fall back to the literal so the -// supervisor's seccomp filter still blocks fileless kernel-image loads when -// built statically against musl on aarch64. -#[cfg(all(target_arch = "aarch64", target_env = "musl"))] -#[allow(non_upper_case_globals)] -const SYS_kexec_file_load: libc::c_long = 294; -#[cfg(not(all(target_arch = "aarch64", target_env = "musl")))] -use libc::SYS_kexec_file_load; - -/// Apply the supervisor seccomp filter across the running process. -/// -/// This runs after privileged startup helpers complete and synchronizes the -/// filter across all supervisor threads via TSYNC. It intentionally blocks -/// only the privileged escape primitives that the long-lived supervisor no -/// longer needs once bootstrap is complete. -pub fn apply_supervisor_prelude() -> Result<()> { - let filter = build_supervisor_prelude_filter()?; - set_no_new_privs()?; - apply_filter_all_threads(&filter).into_diagnostic()?; - Ok(()) -} - -pub fn apply(policy: &SandboxPolicy) -> Result<()> { - let allow_inet = matches!(policy.network.mode, NetworkMode::Proxy | NetworkMode::Allow); - let main_filter = build_filter(allow_inet)?; - let clone3_filter = build_clone3_filter()?; - - set_no_new_privs()?; - apply_runtime_filters(&main_filter, &clone3_filter)?; - - Ok(()) -} - -fn build_filter(allow_inet: bool) -> Result { - let rules = build_filter_rules(allow_inet)?; - compile_filter(rules, SeccompAction::Errno(libc::EPERM as u32)) -} - -fn build_supervisor_prelude_filter() -> Result { - compile_filter( - build_supervisor_prelude_rules(), - SeccompAction::Errno(libc::EPERM as u32), - ) -} - -fn build_supervisor_prelude_rules() -> BTreeMap> { - let mut rules: BTreeMap> = BTreeMap::new(); - - for syscall in [ - libc::SYS_mount, - libc::SYS_fsopen, - libc::SYS_fsconfig, - libc::SYS_fsmount, - libc::SYS_fspick, - libc::SYS_move_mount, - libc::SYS_open_tree, - libc::SYS_pivot_root, - libc::SYS_umount2, - libc::SYS_bpf, - libc::SYS_perf_event_open, - libc::SYS_userfaultfd, - libc::SYS_init_module, - libc::SYS_finit_module, - libc::SYS_delete_module, - libc::SYS_kexec_load, - SYS_kexec_file_load, - ] { - rules.entry(syscall).or_default(); - } - - rules -} - -fn set_no_new_privs() -> Result<()> { - // libc/syscall FFI requires unsafe - #[allow(unsafe_code)] - let rc = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) }; - if rc != 0 { - return Err(miette::miette!( - "Failed to set no_new_privs: {}", - std::io::Error::last_os_error() - )); - } - - Ok(()) -} - -fn compile_filter( - rules: BTreeMap>, - blocked_action: SeccompAction, -) -> Result { - let arch = std::env::consts::ARCH - .try_into() - .map_err(|_| miette::miette!("Unsupported architecture for seccomp"))?; - - let filter = - SeccompFilter::new(rules, SeccompAction::Allow, blocked_action, arch).into_diagnostic()?; - - filter.try_into().into_diagnostic() -} - -/// Build a minimal BPF filter that blocks clone3 with ENOSYS. -/// -/// This is a separate filter from the main one because seccomp BPF cannot -/// dereference the `struct clone_args *` pointer that clone3 takes as arg 0, -/// so we cannot selectively block `CLONE_NEWUSER`. We block clone3 -/// unconditionally with ENOSYS so glibc falls back to the older clone -/// syscall (where flags are a direct register argument and CAN be filtered). -/// -/// glibc's clone3 wrapper checks for ENOSYS specifically — EPERM would be -/// treated as a hard failure and propagated to the caller instead of -/// triggering the clone fallback. -fn build_clone3_filter() -> Result { - let mut rules: BTreeMap> = BTreeMap::new(); - rules.entry(libc::SYS_clone3).or_default(); - compile_filter(rules, SeccompAction::Errno(libc::ENOSYS as u32)) -} - -/// Install the sandbox seccomp filters in the required order. -/// -/// Order matters: -/// 1. Install the dedicated clone3 filter first so it can still call -/// `seccomp(SECCOMP_SET_MODE_FILTER)`. -/// 2. Install the main filter second. It blocks further seccomp filter -/// installation with `EPERM`, preserving the original hardening intent. -fn apply_runtime_filters( - main_filter: seccompiler::BpfProgramRef<'_>, - clone3_filter: seccompiler::BpfProgramRef<'_>, -) -> Result<()> { - apply_filter(clone3_filter).into_diagnostic()?; - apply_filter(main_filter).into_diagnostic()?; - Ok(()) -} - -fn build_filter_rules(allow_inet: bool) -> Result>> { - let mut rules: BTreeMap> = BTreeMap::new(); - - // --- Socket domain blocks --- - let mut blocked_domains = vec![ - libc::AF_PACKET, - libc::AF_BLUETOOTH, - libc::AF_VSOCK, - // AF_NETLINK is handled separately below: NETLINK_ROUTE (protocol 0) - // is allowed for getifaddrs(3); all other netlink protocols are blocked. - ]; - if !allow_inet { - blocked_domains.push(libc::AF_INET); - blocked_domains.push(libc::AF_INET6); - } - - for domain in blocked_domains { - debug!(domain, "Blocking socket domain via seccomp"); - add_socket_domain_rule(&mut rules, domain)?; - } - - // Allow AF_NETLINK only for NETLINK_ROUTE (protocol 0). - // - // NETLINK_ROUTE is needed by getifaddrs(3) which is called by Node.js, - // Python, Go, and many HTTP/gRPC client libraries during startup to - // enumerate local network interfaces. Blocking it causes runtime errors - // such as "getifaddrs returned an error" in tools like Claude Code. - // - // The rule blocks socket(AF_NETLINK, *, protocol) for any protocol != 0. - // Write operations via NETLINK_ROUTE still require CAP_NET_ADMIN, which - // the sandbox does not grant, so interface/route modification is not possible. - add_netlink_non_route_rule(&mut rules)?; - - // --- Unconditional syscall blocks --- - // These syscalls are blocked entirely (empty rule vec = unconditional EPERM). - - // Fileless binary execution via memfd bypasses Landlock filesystem restrictions. - rules.entry(libc::SYS_memfd_create).or_default(); - // Cross-process memory inspection and code injection. - rules.entry(libc::SYS_ptrace).or_default(); - // Kernel BPF program loading. - rules.entry(libc::SYS_bpf).or_default(); - // Cross-process memory read. - rules.entry(libc::SYS_process_vm_readv).or_default(); - // Cross-process memory write (symmetric with process_vm_readv). - rules.entry(libc::SYS_process_vm_writev).or_default(); - // Process handle acquisition, fd theft, and signalling via pidfd. - rules.entry(libc::SYS_pidfd_open).or_default(); - rules.entry(libc::SYS_pidfd_getfd).or_default(); - rules.entry(libc::SYS_pidfd_send_signal).or_default(); - // Async I/O subsystem with extensive CVE history. - rules.entry(libc::SYS_io_uring_setup).or_default(); - // Filesystem mount could subvert Landlock or overlay writable paths. - rules.entry(libc::SYS_mount).or_default(); - // New mount API syscalls (Linux 5.2+) bypass the SYS_mount block entirely. - rules.entry(libc::SYS_fsopen).or_default(); - rules.entry(libc::SYS_fsconfig).or_default(); - rules.entry(libc::SYS_fsmount).or_default(); - rules.entry(libc::SYS_fspick).or_default(); - rules.entry(libc::SYS_move_mount).or_default(); - rules.entry(libc::SYS_open_tree).or_default(); - // Namespace manipulation — setns enters existing namespaces, pivot_root/umount2 - // change the filesystem root. The supervisor calls setns before seccomp is applied, - // so blocking it here is safe. - rules.entry(libc::SYS_setns).or_default(); - rules.entry(libc::SYS_umount2).or_default(); - rules.entry(libc::SYS_pivot_root).or_default(); - // Kernel exploit primitives: userfaultfd enables race-condition exploitation (multiple - // CVEs), perf_event_open enables Spectre-class side channels. Both blocked by Docker's - // default seccomp profile. - rules.entry(libc::SYS_userfaultfd).or_default(); - rules.entry(libc::SYS_perf_event_open).or_default(); - - // --- Conditional syscall blocks --- - - // execveat with AT_EMPTY_PATH enables fileless execution from an anonymous fd. - add_masked_arg_rule( - &mut rules, - libc::SYS_execveat, - 4, // flags argument - libc::AT_EMPTY_PATH as u64, - )?; - - // unshare with CLONE_NEWUSER allows creating user namespaces to escalate privileges. - add_masked_arg_rule( - &mut rules, - libc::SYS_unshare, - 0, // flags argument - libc::CLONE_NEWUSER as u64, - )?; - - // clone with CLONE_NEWUSER achieves the same as unshare via a different syscall. - add_masked_arg_rule( - &mut rules, - libc::SYS_clone, - 0, // flags argument - libc::CLONE_NEWUSER as u64, - )?; - // clone3 is handled by a separate filter — see build_clone3_filter(). - - // seccomp(SECCOMP_SET_MODE_FILTER) would let sandboxed code replace the active filter. - let condition = SeccompCondition::new( - 0, // operation argument - SeccompCmpArgLen::Dword, - SeccompCmpOp::Eq, - SECCOMP_SET_MODE_FILTER, - ) - .into_diagnostic()?; - let rule = SeccompRule::new(vec![condition]).into_diagnostic()?; - rules.entry(libc::SYS_seccomp).or_default().push(rule); - - Ok(rules) -} - -#[allow(clippy::cast_sign_loss)] -fn add_socket_domain_rule(rules: &mut BTreeMap>, domain: i32) -> Result<()> { - let condition = - SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Eq, domain as u64) - .into_diagnostic()?; - - let rule = SeccompRule::new(vec![condition]).into_diagnostic()?; - rules.entry(libc::SYS_socket).or_default().push(rule); - Ok(()) -} - -/// Block `socket(AF_NETLINK, *, protocol)` for every protocol except -/// `NETLINK_ROUTE` (protocol 0). -/// -/// Two AND'd conditions are required: -/// - arg0 == `AF_NETLINK` (domain) -/// - arg2 != 0 (protocol is not `NETLINK_ROUTE`) -/// -/// A seccomp rule fires (and returns EPERM) only when **all** conditions -/// match, so this rule is triggered for any `socket(AF_NETLINK, *, non-zero)` -/// call while leaving `socket(AF_NETLINK, *, 0)` (`NETLINK_ROUTE`) through. -#[allow(clippy::cast_sign_loss)] -fn add_netlink_non_route_rule(rules: &mut BTreeMap>) -> Result<()> { - let domain_condition = SeccompCondition::new( - 0, // domain argument - SeccompCmpArgLen::Dword, - SeccompCmpOp::Eq, - libc::AF_NETLINK as u64, - ) - .into_diagnostic()?; - - let protocol_condition = SeccompCondition::new( - 2, // protocol argument - SeccompCmpArgLen::Dword, - SeccompCmpOp::Ne, - 0, // NETLINK_ROUTE = 0 - ) - .into_diagnostic()?; - - let rule = SeccompRule::new(vec![domain_condition, protocol_condition]).into_diagnostic()?; - rules.entry(libc::SYS_socket).or_default().push(rule); - Ok(()) -} - -/// Block a syscall when a specific bit pattern is set in an argument. -/// -/// Uses `MaskedEq` to check `(arg & flag_bit) == flag_bit`, which triggers -/// EPERM when the flag is present regardless of other bits in the argument. -fn add_masked_arg_rule( - rules: &mut BTreeMap>, - syscall: i64, - arg_index: u8, - flag_bit: u64, -) -> Result<()> { - let condition = SeccompCondition::new( - arg_index, - SeccompCmpArgLen::Dword, - SeccompCmpOp::MaskedEq(flag_bit), - flag_bit, - ) - .into_diagnostic()?; - let rule = SeccompRule::new(vec![condition]).into_diagnostic()?; - rules.entry(syscall).or_default().push(rule); - Ok(()) -} - -#[cfg(test)] -// libc/syscall FFI requires unsafe; these tests fork children and exercise -// blocked syscalls, so unsafe blocks/calls are pervasive. -#[allow( - unsafe_code, - unsafe_op_in_unsafe_fn, - unused_unsafe, - clippy::borrow_as_ptr, - trivial_numeric_casts -)] -mod tests { - use super::*; - - // These tests cover both filter construction (rule map shape and BPF - // compilation) and selected runtime behavior on Linux via forked children. - - #[test] - fn build_filter_proxy_mode_compiles() { - let filter = build_filter(true); - assert!(filter.is_ok(), "build_filter(true) should succeed"); - } - - #[test] - fn build_filter_block_mode_compiles() { - let filter = build_filter(false); - assert!(filter.is_ok(), "build_filter(false) should succeed"); - } - - #[test] - fn build_supervisor_prelude_filter_compiles() { - let filter = build_supervisor_prelude_filter(); - assert!( - filter.is_ok(), - "build_supervisor_prelude_filter() should succeed" - ); - } - - #[test] - fn add_masked_arg_rule_creates_entry() { - let mut rules: BTreeMap> = BTreeMap::new(); - let result = add_masked_arg_rule(&mut rules, libc::SYS_execveat, 4, 0x1000); - assert!(result.is_ok()); - assert!( - rules.contains_key(&libc::SYS_execveat), - "should have an entry for SYS_execveat" - ); - assert_eq!( - rules[&libc::SYS_execveat].len(), - 1, - "should have exactly one rule" - ); - } - - #[test] - fn unconditional_blocks_present_in_filter() { - // Build a real filter and verify all unconditional blocks are present. - let filter_rules = build_filter_rules(true).unwrap(); - - // Unconditional blocks have an empty Vec (no conditions = always match). - let expected = [ - libc::SYS_memfd_create, - libc::SYS_ptrace, - libc::SYS_bpf, - libc::SYS_process_vm_readv, - libc::SYS_process_vm_writev, - libc::SYS_pidfd_open, - libc::SYS_pidfd_getfd, - libc::SYS_pidfd_send_signal, - libc::SYS_io_uring_setup, - libc::SYS_mount, - libc::SYS_fsopen, - libc::SYS_fsconfig, - libc::SYS_fsmount, - libc::SYS_fspick, - libc::SYS_move_mount, - libc::SYS_open_tree, - libc::SYS_setns, - libc::SYS_umount2, - libc::SYS_pivot_root, - libc::SYS_userfaultfd, - libc::SYS_perf_event_open, - ]; - - for syscall in expected { - assert!( - filter_rules.contains_key(&syscall), - "syscall {syscall} should be in the rules map" - ); - assert!( - filter_rules[&syscall].is_empty(), - "syscall {syscall} should have empty rules (unconditional block)" - ); - } - } - - #[test] - fn conditional_blocks_have_rules() { - // Build a real filter and verify the conditional syscalls have rule entries - // (non-empty Vec means conditional match). - let filter_rules = build_filter_rules(true).unwrap(); - - for syscall in [ - libc::SYS_execveat, - libc::SYS_unshare, - libc::SYS_clone, - libc::SYS_seccomp, - ] { - assert!( - filter_rules.contains_key(&syscall), - "syscall {syscall} should be in the rules map" - ); - assert!( - !filter_rules[&syscall].is_empty(), - "syscall {syscall} should have conditional rules" - ); - } - } - - #[test] - fn netlink_socket_rules_are_conditional_not_unconditional() { - // SYS_socket must appear in the rules map (for domain blocks and the - // AF_NETLINK+non-ROUTE filter), but it must NOT be an unconditional block - // (empty Vec). An empty Vec would block ALL socket() calls, including - // socket(AF_NETLINK, *, NETLINK_ROUTE=0) which getifaddrs(3) needs. - let filter_rules = build_filter_rules(true).unwrap(); - - assert!( - filter_rules.contains_key(&libc::SYS_socket), - "SYS_socket should be in the rules map (domain blocks present)" - ); - - // The Vec for SYS_socket must be non-empty (rules are - // conditional), which is the opposite of an unconditional block. - assert!( - !filter_rules[&libc::SYS_socket].is_empty(), - "SYS_socket should have conditional rules, not an unconditional block" - ); - } - - #[test] - fn supervisor_prelude_blocks_expected_syscalls() { - let filter_rules = build_supervisor_prelude_rules(); - - for syscall in [ - libc::SYS_mount, - libc::SYS_fsopen, - libc::SYS_fsconfig, - libc::SYS_fsmount, - libc::SYS_fspick, - libc::SYS_move_mount, - libc::SYS_open_tree, - libc::SYS_pivot_root, - libc::SYS_umount2, - libc::SYS_bpf, - libc::SYS_perf_event_open, - libc::SYS_userfaultfd, - libc::SYS_init_module, - libc::SYS_finit_module, - libc::SYS_delete_module, - libc::SYS_kexec_load, - SYS_kexec_file_load, - ] { - assert!( - filter_rules.contains_key(&syscall), - "syscall {syscall} should be in the supervisor prelude rules" - ); - assert!( - filter_rules[&syscall].is_empty(), - "syscall {syscall} should be unconditionally blocked in the supervisor prelude" - ); - } - } - - #[test] - fn supervisor_prelude_keeps_required_setup_syscalls_available() { - let filter_rules = build_supervisor_prelude_rules(); - - for syscall in [ - libc::SYS_setns, - libc::SYS_clone, - libc::SYS_unshare, - libc::SYS_ptrace, - ] { - assert!( - !filter_rules.contains_key(&syscall), - "syscall {syscall} should remain available during supervisor startup" - ); - } - } - - #[test] - fn clone3_filter_compiles_and_blocks_clone3() { - let bpf = build_clone3_filter(); - assert!(bpf.is_ok(), "clone3 ENOSYS filter should compile"); - } - - #[test] - fn clone3_not_in_main_filter() { - // clone3 must NOT be in the main filter; it has its own ENOSYS filter. - let filter_rules = build_filter_rules(true).unwrap(); - assert!( - !filter_rules.contains_key(&libc::SYS_clone3), - "clone3 should not be in the main filter — it uses a separate ENOSYS filter" - ); - } - - // --- Behavioral tests --- - // - // These apply seccomp filters in a forked child and verify that blocked - // syscalls actually return the expected errno. They only compile and run - // on Linux (seccomp is a Linux kernel feature). - - /// Fork a child, apply the given filter, invoke `syscall_nr`, and return - /// the errno observed by the child. The child exits 0 if the syscall - /// returned the expected errno, 1 otherwise. - unsafe fn assert_blocked_in_child( - filter: &seccompiler::BpfProgram, - syscall_nr: i64, - expected_errno: i32, - ) { - let pid = libc::fork(); - assert!(pid >= 0, "fork failed"); - if pid == 0 { - // Child: apply filter and try the syscall. - libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); - apply_filter(filter).expect("apply_filter"); - let ret = libc::syscall(syscall_nr, 0 as libc::c_ulong, 0 as libc::c_ulong); - let errno = *libc::__errno_location(); - if ret == -1 && errno == expected_errno { - libc::_exit(0); - } else { - // Write diagnostic before exiting so test failures are debuggable. - let msg = format!( - "syscall {syscall_nr}: expected errno={expected_errno}, got ret={ret} errno={errno}\n" - ); - libc::write(2, msg.as_ptr().cast(), msg.len()); - libc::_exit(1); - } - } - // Parent: wait for child. - let mut status: libc::c_int = 0; - libc::waitpid(pid, &mut status, 0); - assert!( - libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0, - "child failed: syscall {syscall_nr} was not blocked with errno {expected_errno}" - ); - } - - unsafe fn install_runtime_filters_in_child( - main_filter: &seccompiler::BpfProgram, - clone3_filter: &seccompiler::BpfProgram, - ) { - libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0); - if let Err(err) = apply_runtime_filters(main_filter, clone3_filter) { - let msg = format!("failed to install runtime seccomp filters: {err}\n"); - libc::write(2, msg.as_ptr().cast(), msg.len()); - libc::_exit(1); - } - } - - #[test] - fn behavioral_memfd_create_blocked() { - let filter = build_filter(true).unwrap(); - unsafe { assert_blocked_in_child(&filter, libc::SYS_memfd_create, libc::EPERM) }; - } - - #[test] - fn behavioral_ptrace_blocked() { - let filter = build_filter(true).unwrap(); - unsafe { assert_blocked_in_child(&filter, libc::SYS_ptrace, libc::EPERM) }; - } - - #[test] - fn behavioral_process_vm_writev_blocked() { - let filter = build_filter(true).unwrap(); - unsafe { assert_blocked_in_child(&filter, libc::SYS_process_vm_writev, libc::EPERM) }; - } - - #[test] - fn behavioral_userfaultfd_blocked() { - let filter = build_filter(true).unwrap(); - unsafe { assert_blocked_in_child(&filter, libc::SYS_userfaultfd, libc::EPERM) }; - } - - #[test] - fn behavioral_perf_event_open_blocked() { - let filter = build_filter(true).unwrap(); - unsafe { assert_blocked_in_child(&filter, libc::SYS_perf_event_open, libc::EPERM) }; - } - - #[test] - fn behavioral_setns_blocked() { - let filter = build_filter(true).unwrap(); - unsafe { assert_blocked_in_child(&filter, libc::SYS_setns, libc::EPERM) }; - } - - #[test] - fn behavioral_supervisor_prelude_mount_blocked() { - let pid = unsafe { libc::fork() }; - assert!(pid >= 0, "fork failed"); - if pid == 0 { - unsafe { - if let Err(err) = apply_supervisor_prelude() { - let msg = format!("failed to install supervisor prelude: {err}\n"); - libc::write(2, msg.as_ptr().cast(), msg.len()); - libc::_exit(1); - } - let ret = libc::syscall( - libc::SYS_mount, - std::ptr::null::(), - std::ptr::null::(), - std::ptr::null::(), - 0 as libc::c_ulong, - std::ptr::null::(), - ); - let errno = *libc::__errno_location(); - if ret == -1 && errno == libc::EPERM { - libc::_exit(0); - } else { - let msg = format!( - "mount: expected EPERM after supervisor prelude, got ret={ret} errno={errno}\n" - ); - libc::write(2, msg.as_ptr().cast(), msg.len()); - libc::_exit(1); - } - } - } - - let mut status: libc::c_int = 0; - unsafe { libc::waitpid(pid, &mut status, 0) }; - assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, - "mount should be blocked by the supervisor prelude filter" - ); - } - - #[test] - fn behavioral_clone3_returns_enosys() { - // clone3 uses a separate filter that returns ENOSYS (not EPERM) so - // glibc falls back to clone. - let main_filter = build_filter(true).unwrap(); - let clone3_filter = build_clone3_filter().unwrap(); - // Apply in the same order as apply(): clone3 filter first, main filter second. - let pid = unsafe { libc::fork() }; - assert!(pid >= 0, "fork failed"); - if pid == 0 { - unsafe { - install_runtime_filters_in_child(&main_filter, &clone3_filter); - let ret = libc::syscall(libc::SYS_clone3, 0 as libc::c_ulong, 0 as libc::c_ulong); - let errno = *libc::__errno_location(); - if ret == -1 && errno == libc::ENOSYS { - libc::_exit(0); - } else { - let msg = format!("clone3: expected ENOSYS, got ret={ret} errno={errno}\n"); - libc::write(2, msg.as_ptr().cast(), msg.len()); - libc::_exit(1); - } - } - } - let mut status: libc::c_int = 0; - unsafe { libc::waitpid(pid, &mut status, 0) }; - assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, - "clone3 should be blocked with ENOSYS, not EPERM" - ); - } - - #[test] - fn behavioral_third_filter_install_blocked_after_startup() { - let main_filter = build_filter(true).unwrap(); - let clone3_filter = build_clone3_filter().unwrap(); - let third_filter = build_clone3_filter().unwrap(); - - let pid = unsafe { libc::fork() }; - assert!(pid >= 0, "fork failed"); - if pid == 0 { - unsafe { - install_runtime_filters_in_child(&main_filter, &clone3_filter); - match apply_filter(&third_filter) { - Err(seccompiler::Error::Seccomp(e)) - if e.raw_os_error() == Some(libc::EPERM) => - { - libc::_exit(0); - } - Err(err) => { - let msg = - format!("third filter install failed with unexpected error: {err}\n"); - libc::write(2, msg.as_ptr().cast(), msg.len()); - libc::_exit(1); - } - Ok(()) => { - let msg = "third filter unexpectedly installed\n"; - libc::write(2, msg.as_ptr().cast(), msg.len()); - libc::_exit(1); - } - } - } - } - - let mut status: libc::c_int = 0; - unsafe { libc::waitpid(pid, &mut status, 0) }; - assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, - "additional seccomp filter installation should be blocked after startup" - ); - } - - #[test] - fn behavioral_netlink_route_allowed() { - // socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE=0) must succeed (not blocked). - // This is the call getifaddrs(3) makes on Linux to enumerate interfaces. - let filter = build_filter(true).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_filter"); - // NETLINK_ROUTE = 0 - let fd = libc::socket(libc::AF_NETLINK, libc::SOCK_RAW, 0); - if fd >= 0 { - libc::close(fd); - libc::_exit(0); - } else { - let errno = *libc::__errno_location(); - let msg = format!( - "socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE): expected success, got errno={errno}\n" - ); - libc::write(2, msg.as_ptr().cast(), msg.len()); - libc::_exit(1); - } - } - } - let mut status: libc::c_int = 0; - unsafe { libc::waitpid(pid, &mut status, 0) }; - assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, - "socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE) should be allowed for getifaddrs(3)" - ); - } - - #[test] - fn behavioral_netlink_non_route_blocked() { - // socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG=4) must be blocked. - // NETLINK_SOCK_DIAG is representative of non-ROUTE netlink protocols - // that have no legitimate use inside the sandbox. - let filter = build_filter(true).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_filter"); - // NETLINK_SOCK_DIAG = 4 - let fd = libc::socket(libc::AF_NETLINK, libc::SOCK_RAW, 4); - let errno = *libc::__errno_location(); - if fd == -1 && errno == libc::EPERM { - libc::_exit(0); - } else { - if fd >= 0 { - libc::close(fd); - } - let msg = format!( - "socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG): expected EPERM, got fd={fd} errno={errno}\n" - ); - libc::write(2, msg.as_ptr().cast(), msg.len()); - libc::_exit(1); - } - } - } - let mut status: libc::c_int = 0; - unsafe { libc::waitpid(pid, &mut status, 0) }; - assert!( - unsafe { libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0 }, - "socket(AF_NETLINK, SOCK_RAW, NETLINK_SOCK_DIAG) should be blocked with EPERM" - ); - } -} diff --git a/crates/openshell-supervisor-process/src/sandbox/mod.rs b/crates/openshell-supervisor-process/src/sandbox/mod.rs deleted file mode 100644 index ff44f8ba1..000000000 --- a/crates/openshell-supervisor-process/src/sandbox/mod.rs +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Platform sandboxing implementation. - -use miette::Result; -use openshell_core::policy::SandboxPolicy; - -#[cfg(target_os = "linux")] -pub mod linux; - -/// Apply sandboxing rules for the current platform. -/// -/// # Errors -/// -/// Returns an error if the sandbox cannot be applied. -// On Linux the spawn path uses `prepare`+`enforce` directly; this single-phase -// apply is only invoked from the non-Linux spawn_impl. -#[cfg_attr(target_os = "linux", allow(dead_code))] -#[cfg_attr(not(target_os = "linux"), allow(clippy::unnecessary_wraps))] -pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> { - #[cfg(target_os = "linux")] - { - linux::apply(policy, workdir) - } - - #[cfg(not(target_os = "linux"))] - { - let _ = (policy, workdir); - openshell_ocsf::ocsf_emit!( - openshell_ocsf::DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Open) - .severity(openshell_ocsf::SeverityId::Medium) - .finding_info(openshell_ocsf::FindingInfo::new( - "platform-sandbox-unavailable", - "Platform Sandboxing Not Implemented", - ).with_desc("Sandbox policy provided but platform sandboxing is not yet implemented on this OS")) - .message("Platform sandboxing not yet implemented") - .build() - ); - Ok(()) - } -} - -/// Apply seccomp hardening for the long-lived supervisor process itself. -#[cfg_attr(not(target_os = "linux"), allow(clippy::unnecessary_wraps))] -pub fn apply_supervisor_startup_hardening() -> Result<()> { - #[cfg(target_os = "linux")] - { - linux::apply_supervisor_prelude() - } - - #[cfg(not(target_os = "linux"))] - { - Ok(()) - } -}