fix(sandbox): bound control admission and retire stale mediation

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
Drew Newberry
2026-09-11 15:12:16 -07:00
parent 7ec8eff932
commit f59d403882
13 changed files with 561 additions and 6953 deletions
+11
View File
@@ -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
+463 -72
View File
@@ -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<BoundaryRuntime>,
pending: tokio::sync::OwnedSemaphorePermit,
active_connections: Arc<AtomicUsize>,
) -> 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<BoundaryRuntime>,
) -> 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<io::Result<()>> {
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<io::Result<usize>> {
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<io::Result<()>> {
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<io::Result<()>> {
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<BoundaryRuntime>,
connection_closed: tokio::sync::watch::Receiver<()>,
}
type GrpcResponseStream = ReceiverStream<Result<BoundaryChunk, tonic::Status>>;
@@ -477,7 +515,8 @@ mod linux {
&self,
request: tonic::Request<tonic::Streaming<BoundaryChunk>>,
) -> Result<tonic::Response<Self::ExchangeStream>, 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<tonic::Streaming<BoundaryChunk>>,
) -> Result<tonic::Response<Self::MediateStream>, 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<BoundaryChunk>,
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::<Result<BoundaryChunk, tonic::Status>>(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<BoundaryRuntime>);
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<BoundaryRuntime>,
) -> 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<Option<StartedAgent>>,
next_exec_id: AtomicU64,
mediation_active: AtomicBool,
mediation_active: tokio::sync::Mutex<()>,
next_mediation_stream_id: AtomicU64,
exec_handles: Mutex<std::collections::HashMap<String, ExecHandle>>,
/// 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::<libc::ucred>()).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<bool> {
// 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::<u32>()
.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<Self> {
runtime.block_on(self.establish_async(runtime))
}
async fn establish_async(self, runtime: &tokio::runtime::Handle) -> io::Result<Self> {
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<BoundaryRuntime> {
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<Box<dyn std::any::Any>> = 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<BoundaryRuntime>,
token: &str,
) -> (
tokio::io::DuplexStream,
tokio::task::JoinHandle<Result<(), String>>,
) {
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()
+84 -3
View File
@@ -187,6 +187,7 @@ fn register_dns_socket(
#[derive(Clone)]
struct NotificationQueues {
protected_control_port: Option<u16>,
accept_registrar: crate::accept_interrupt::AcceptRegistrar,
identity_resolver: ProcfsIdentityResolver,
pending: mpsc::Sender<PendingTcpOpen>,
@@ -207,8 +208,11 @@ pub struct NetworkBroker {
}
impl NetworkBroker {
pub(crate) fn start(listener: NotificationListener) -> io::Result<Self> {
Self::start_with_dns_address(listener, DNS_RELAY_ADDRESS)
pub(crate) fn start(
listener: NotificationListener,
protected_control_port: Option<u16>,
) -> io::Result<Self> {
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<u16>,
) -> io::Result<Self> {
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<u16>,
decision_timeout: Duration,
) -> io::Result<Self> {
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<u16>,
) -> 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<Mutex<SocketRegistry>>,
listener: Arc<NotificationListener>,
@@ -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(&registry);
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));
@@ -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
@@ -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
+3 -8
View File
@@ -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!(
@@ -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"));
}
}
@@ -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<Self> {
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<String>,
uid: Option<String>,
gid: Option<String>,
) -> Result<Self> {
// 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::<u32>().ok().filter(|uid| {
(openshell_policy::MIN_SANDBOX_UID..=openshell_policy::MAX_SANDBOX_UID)
.contains(uid)
});
let gid = gid.parse::<u32>().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<ResolvedProcessIdentity> {
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<ResolvedProcessIdentity> {
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<u32>)> {
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::<u32>() {
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<u32> {
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::<u32>() {
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<Option<PasswdEntry>> {
find_unique(path, |fields| {
(fields.first().copied() == Some(name)).then(|| parse_passwd(fields))
})
}
fn find_passwd_by_uid(path: &Path, uid: u32) -> Result<Option<PasswdEntry>> {
find_unique(path, |fields| {
fields
.get(2)
.and_then(|value| value.parse::<u32>().ok())
.filter(|candidate| *candidate == uid)
.map(|_| parse_passwd(fields))
})
}
fn find_group_by_name(path: &Path, name: &str) -> Result<Option<GroupEntry>> {
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<Vec<u32>> {
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<Vec<u32>> {
let (user, _) = split_oci_declaration(declaration);
validate_component(user, "OCI user")?;
if user.parse::<u32>().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::<Vec<_>>();
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::<u32>().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<T>(
path: &Path,
mut select: impl FnMut(&[&str]) -> Option<Result<T>>,
) -> Result<Option<T>> {
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::<Vec<_>>();
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<PasswdEntry> {
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<GroupEntry> {
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<String> {
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<Option<String>> {
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<Option<String>> {
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());
}
}
File diff suppressed because it is too large Load Diff
@@ -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::<libc::c_void>(),
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<Option<PreparedRuleset>> {
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<Option<PreparedRuleset>> {
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<Option<PreparedRuleset>> {
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<PreparedRuleset> = (|| {
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<AccessFs>,
abi: ABI,
) -> Result<BitFlags<AccessFs>> {
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<Option<PathFd>> {
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<PathBuf>, read_write: Vec<PathBuf>) -> 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<AccessFs>) -> BitFlags<AccessFs> {
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"
);
}
}
@@ -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<landlock::PreparedRuleset>,
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<PreparedSandbox> {
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<PreparedSandbox> {
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()
);
}
}
@@ -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<seccompiler::BpfProgram> {
let rules = build_filter_rules(allow_inet)?;
compile_filter(rules, SeccompAction::Errno(libc::EPERM as u32))
}
fn build_supervisor_prelude_filter() -> Result<seccompiler::BpfProgram> {
compile_filter(
build_supervisor_prelude_rules(),
SeccompAction::Errno(libc::EPERM as u32),
)
}
fn build_supervisor_prelude_rules() -> BTreeMap<i64, Vec<SeccompRule>> {
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = 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<i64, Vec<SeccompRule>>,
blocked_action: SeccompAction,
) -> Result<seccompiler::BpfProgram> {
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<seccompiler::BpfProgram> {
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = 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<BTreeMap<i64, Vec<SeccompRule>>> {
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = 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<i64, Vec<SeccompRule>>, 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<i64, Vec<SeccompRule>>) -> 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<i64, Vec<SeccompRule>>,
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<i64, Vec<SeccompRule>> = 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<SeccompRule> 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::<libc::c_char>(),
std::ptr::null::<libc::c_char>(),
std::ptr::null::<libc::c_char>(),
0 as libc::c_ulong,
std::ptr::null::<libc::c_void>(),
);
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"
);
}
}
@@ -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(())
}
}