From e16cee26886783390d63e5f33682b510f18d2fbc Mon Sep 17 00:00:00 2001 From: Akram Date: Thu, 17 Sep 2026 16:22:05 +0400 Subject: [PATCH 1/5] fix(sandbox): fall back to plain seccomp listener when WAIT_KILLABLE_RECV is unavailable SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV was added in Linux 5.19. On older kernels (for example RHEL 9.x / 5.14 nodes such as RHCOS on OpenShift) the flag is rejected with EINVAL, which made the capability-free sandbox fail to start during the notification probe with "notification launcher disappeared". Install the notification listener with WAIT_KILLABLE_RECV when the kernel supports it and fall back to a plain NEW_LISTENER on EINVAL. The fallback listener records wait_killable_recv = false: its notification receive is uninterruptible, but the sandbox is otherwise fully functional. Signed-off-by: Akram --- .../src/linux/seccomp_notify.rs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs b/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs index 7d24dfa4d9..79d8db62bc 100644 --- a/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs +++ b/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs @@ -330,16 +330,20 @@ pub fn install_listener(syscalls: &[i64]) -> io::Result { verify_notification_sizes()?; set_no_new_privileges()?; - install_listener_with_flags(syscalls, true).map_err(|error| { - if error.raw_os_error() == Some(libc::EINVAL) { - io::Error::new( - io::ErrorKind::Unsupported, - "seccomp WAIT_KILLABLE_RECV is required (Linux 5.19 or newer)", - ) - } else { - error + // WAIT_KILLABLE_RECV (Linux 5.19+) makes the supervisor's notification + // receive interruptible by a fatal signal. Kernels older than 5.19 (for + // example RHEL 9.x / 5.14 nodes) reject the flag with EINVAL. Rather than + // refusing to start there, fall back to a plain listener: the notification + // receive is then uninterruptible, but the sandbox is otherwise fully + // functional. The resulting listener records `wait_killable_recv = false` + // so callers can observe the degraded cancellation semantics. + match install_listener_with_flags(syscalls, true) { + Ok(listener) => Ok(listener), + Err(error) if error.raw_os_error() == Some(libc::EINVAL) => { + install_listener_with_flags(syscalls, false) } - }) + Err(error) => Err(error), + } } /// Install the capability-free workload networking listener on the calling From 7b901741cf9d7d4d44c39300bece52a73572ce3f Mon Sep 17 00:00:00 2001 From: Akram Date: Thu, 17 Sep 2026 16:50:55 +0400 Subject: [PATCH 2/5] fix(sandbox): do not gate launch confirmation on seccomp cancellation The confirmation evidence check required seccomp.cancellation (WAIT_KILLABLE_RECV, Linux 5.19+). On kernels older than 5.19 the sandbox falls back to a plain listener and reports cancellation=false, so requiring it stopped the supervisor from confirming the sandbox there ("confirmation failed: sandbox confirmation evidence is incomplete or mismatched"). Cancellation is a robustness property, not a containment prerequisite: containment still holds via the listener itself. Drop it from the launch gate and update the conformance test to assert rejection on a containment-critical primitive instead. Signed-off-by: Akram --- crates/openshell-isolation-interface/src/contract.rs | 6 +++++- .../tests/backend_conformance.rs | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index 8e68d48c77..f21af0ac01 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -562,7 +562,11 @@ impl SandboxConfirmEvidence { && self.seccomp.proc_fd_identity && self.seccomp.task_memory_read && self.seccomp.task_memory_write - && self.seccomp.cancellation + // `cancellation` (seccomp WAIT_KILLABLE_RECV, Linux 5.19+) is a + // robustness property, not a containment prerequisite: on older + // kernels (e.g. RHEL 9.x / 5.14) the sandbox falls back to a plain + // listener and reports cancellation=false. Containment still holds + // via the listener itself, so launch is not gated on cancellation. && self.landlock_abi >= 3 && self.landlock_allow_deny && self.udp_dns_round_trip diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index 73559b203f..f8b0236dc6 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -538,7 +538,11 @@ async fn one_driver_runs_both_backends() { #[test] fn confirmation_constructor_rejects_incomplete_evidence() { let mut evidence = confirmation_evidence(); - evidence.seccomp.cancellation = false; + // A missing containment-critical primitive (the notification listener + // itself) must be rejected. `cancellation` is intentionally NOT in this set: + // it reflects WAIT_KILLABLE_RECV (Linux 5.19+) and is unavailable on older + // kernels, where the sandbox degrades to a plain listener. + evidence.seccomp.new_listener = false; let result = ConfirmedBoundary::try_new( Box::new(MockReady:: { _k: PhantomData }), evidence, From 2370e5059f9d144a65b25f0b515b9eddea929d90 Mon Sep 17 00:00:00 2001 From: Akram Date: Fri, 18 Sep 2026 11:15:36 +0400 Subject: [PATCH 3/5] =?UTF-8?q?fix(sandbox):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20legacy=20read-only=20listener=20mode=20+=20cancella?= =?UTF-8?q?tion=20invariant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the PR review (GATOR-de00bfcc-01 / mrunalp): make the < 5.19 fallback cancellation-safe instead of racing broker writes. - Record an explicit ListenerMode (Killable vs LegacyReadOnly); add writes_disabled()/mode() and emit the selected mode in qualification output (seccomp_listener_mode). - Centralize task-memory output writes behind NotificationListener:: write_task_output; in LegacyReadOnly mode getpeername, accept/accept4 with a non-null address, and sendmmsg length write-backs fail closed with EOPNOTSUPP. accept with a null address, socket/connect/bind/listen/sendto/sendmsg keep working (copied inputs, scalar responses, atomic ADDFD_SEND). - Enforce the launch invariant `cancellation || task_memory_writes_disabled` in SandboxConfirmEvidence::validate() rather than dropping cancellation unconditionally; add task_memory_writes_disabled to SeccompEvidence. - Add per-path fail-closed tests (write_task_output, write_socket_addr, a real plain listener installed on a modern kernel) and confirmation-invariant tests. - Correct the flag-semantics comments and document both modes plus the reduced legacy syscall compatibility in architecture/sandbox.md. Signed-off-by: Akram --- architecture/sandbox.md | 28 +++- .../src/contract.rs | 18 ++- .../src/linux/seccomp_notify.rs | 148 +++++++++++++++++- .../tests/backend_conformance.rs | 32 ++++ .../openshell-sandbox-backend/src/runtime.rs | 1 + .../openshell-sandbox/src/boundary_server.rs | 1 + crates/openshell-sandbox/src/main.rs | 14 ++ .../openshell-sandbox/src/network_broker.rs | 39 ++++- 8 files changed, 260 insertions(+), 21 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 74bbfb4aea..7ed29d5dd2 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -173,9 +173,31 @@ The sandbox reserves `SIGUSR2` with a non-restarting no-op handler for these broker threads; startup rejects a conflicting handler. This signal disposition is process-global kernel state, while registrations and cancellation state are owned by the broker. Workload exec resets the caught handler to its default. -This sandbox runtime requires Linux 6.2 or newer for Landlock ABI v3 and treats -`SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV` as mandatory so cancelled -notifications cannot race task-memory writes. +This sandbox runtime requires Landlock ABI v3 (Linux 6.2, or an equivalent +vendor backport). The seccomp listener is installed in one of two cancellation +modes, and the launch confirmation enforces the invariant +`cancellation || task_memory_writes_disabled`: + +- **Killable** (`SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV`, Linux 5.19+): the + notified workload thread waits kill-only, so a non-fatal signal cannot resume + a mediated syscall between notification validation and the broker's result + write. Full mediation, including task-memory output writes. +- **LegacyReadOnly** (kernels < 5.19, e.g. RHEL 9.x / 5.14): the flag is + unavailable (`EINVAL`), so the listener falls back to a plain notifier and the + broker refuses every task-memory *output* write to stay cancellation-safe. + Concretely, in this mode `getpeername`, `accept`/`accept4` **with a non-null + peer-address argument**, and `sendmmsg` paths that write per-message lengths + fail closed with `EOPNOTSUPP`. `accept` with a null address, and socket + creation, `connect`, `bind`, `listen`, `sendto`, and `sendmsg` continue to + work — they use copied inputs, scalar responses, or atomic `ADDFD_SEND`, none + of which write into workload memory. Some server workloads whose accept + wrappers request the peer address will therefore not run until the kernel + provides `WAIT_KILLABLE_RECV` (a distribution backport); outbound-oriented + workloads are unaffected. + +Input mediation, DNS/TCP authorization, and outer-fence enforcement are +identical in both modes. The selected mode is emitted in the sandbox +qualification output (`seccomp_listener_mode`). DNS uses an exact sandbox-local resolver at `127.0.0.53:53`. The driver sets the nameserver and permits an unprivileged bind to port 53. UDP and TCP DNS requests diff --git a/crates/openshell-isolation-interface/src/contract.rs b/crates/openshell-isolation-interface/src/contract.rs index f21af0ac01..7e0092c507 100644 --- a/crates/openshell-isolation-interface/src/contract.rs +++ b/crates/openshell-isolation-interface/src/contract.rs @@ -423,6 +423,10 @@ pub struct SeccompEvidence { pub task_memory_read: bool, pub task_memory_write: bool, pub cancellation: bool, + /// The broker disables every task-memory *output* write (legacy plain + /// listener on kernels < 5.19). Paired with `cancellation` in the + /// launch-safety invariant: one of the two must hold. + pub task_memory_writes_disabled: bool, } /// Driver-owned evidence that the mandatory outer network fence is installed. @@ -562,11 +566,15 @@ impl SandboxConfirmEvidence { && self.seccomp.proc_fd_identity && self.seccomp.task_memory_read && self.seccomp.task_memory_write - // `cancellation` (seccomp WAIT_KILLABLE_RECV, Linux 5.19+) is a - // robustness property, not a containment prerequisite: on older - // kernels (e.g. RHEL 9.x / 5.14) the sandbox falls back to a plain - // listener and reports cancellation=false. Containment still holds - // via the listener itself, so launch is not gated on cancellation. + // Cancellation-safety invariant: `cancellation || writes_disabled`. + // Either the listener keeps the notified workload thread kill-only + // (WAIT_KILLABLE_RECV / cancellation, Linux 5.19+), or the broker + // disables every task-memory output write (legacy plain listener on + // kernels < 5.19). One of the two MUST hold so a signal-resumed + // syscall can never race a privileged write into workload memory. + // We do not remove cancellation unconditionally; we require the + // legacy read-only guarantee in its place. + && (self.seccomp.cancellation || self.seccomp.task_memory_writes_disabled) && self.landlock_abi >= 3 && self.landlock_allow_deny && self.udp_dns_round_trip diff --git a/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs b/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs index 79d8db62bc..4f86844d10 100644 --- a/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs +++ b/crates/openshell-isolation-interface/src/linux/seccomp_notify.rs @@ -177,6 +177,34 @@ impl NotificationProbeReport { } } +/// Cancellation posture a listener was installed with. +/// +/// `WAIT_KILLABLE_RECV` (Linux 5.19+) keeps the notified workload thread in a +/// kill-only wait so a non-fatal signal cannot resume the mediated syscall +/// after the broker has validated the notification. A plain listener has no +/// such guarantee, so it runs read-only: the broker must refuse every +/// task-memory *output* write to stay cancellation-safe. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ListenerMode { + /// Modern kernel: `WAIT_KILLABLE_RECV` active; full mediation including + /// task-memory output writes. + Killable, + /// Legacy kernel (< 5.19): plain listener; task-memory output writes are + /// disabled so a resumed syscall cannot race a broker write. + LegacyReadOnly, +} + +impl ListenerMode { + /// Stable identifier for qualification output and diagnostics. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Killable => "killable", + Self::LegacyReadOnly => "legacy_read_only", + } + } +} + /// Owned listener returned by `SECCOMP_FILTER_FLAG_NEW_LISTENER`. pub struct NotificationListener { fd: OwnedFd, @@ -184,6 +212,17 @@ pub struct NotificationListener { } impl NotificationListener { + /// Construct a listener from an already-owned notification descriptor in a + /// specific mode. Intended for tests that must exercise the legacy + /// read-only fail-closed paths without a `< 5.19` kernel. + #[must_use] + pub fn from_fd_with_mode(fd: OwnedFd, mode: ListenerMode) -> Self { + Self { + fd, + wait_killable_recv: matches!(mode, ListenerMode::Killable), + } + } + /// Raw listener descriptor for readiness integration and diagnostics. #[must_use] pub fn as_raw_fd(&self) -> RawFd { @@ -196,6 +235,23 @@ impl NotificationListener { self.wait_killable_recv } + /// The cancellation mode this listener was installed with. + #[must_use] + pub fn mode(&self) -> ListenerMode { + if self.wait_killable_recv { + ListenerMode::Killable + } else { + ListenerMode::LegacyReadOnly + } + } + + /// Whether broker task-memory output writes are disabled for this listener. + /// True exactly in `LegacyReadOnly` mode (no `WAIT_KILLABLE_RECV`). + #[must_use] + pub fn writes_disabled(&self) -> bool { + matches!(self.mode(), ListenerMode::LegacyReadOnly) + } + /// Receive the next kernel notification. pub fn receive(&self) -> io::Result { let mut raw = RawNotification::default(); @@ -223,6 +279,34 @@ impl NotificationListener { Ok(()) } + /// Write broker-produced output into the notified task's memory, closing + /// the validation-to-write race that a plain listener cannot. + /// + /// In `Killable` mode `WAIT_KILLABLE_RECV` keeps the notified workload + /// thread in a kill-only wait, so a non-fatal signal cannot resume the + /// mediated syscall between `validate_id` and this write. In + /// `LegacyReadOnly` mode (kernels < 5.19) there is no such guarantee: a + /// resumed syscall could repurpose the target buffer while the privileged + /// broker writes through the captured tid and pointer — via `/proc//mem` + /// even into pages the workload has since made read-only. There is no way to + /// close that window without the flag, so this fails closed (`EOPNOTSUPP`) + /// rather than racing. Callers must route every task-memory *output* write + /// through this method; input reads never write workload memory and are + /// unaffected. + pub fn write_task_output( + &self, + id: u64, + tid: u32, + address: u64, + data: &[u8], + ) -> io::Result<()> { + if self.writes_disabled() { + return Err(io::Error::from_raw_os_error(libc::EOPNOTSUPP)); + } + self.validate_id(id)?; + crate::linux::task_memory::write_exact(tid, address, data) + } + /// Return a successful scalar result to the notifying syscall. pub fn respond_value(&self, id: u64, value: i64) -> io::Result<()> { self.validate_id(id)?; @@ -330,13 +414,15 @@ pub fn install_listener(syscalls: &[i64]) -> io::Result { verify_notification_sizes()?; set_no_new_privileges()?; - // WAIT_KILLABLE_RECV (Linux 5.19+) makes the supervisor's notification - // receive interruptible by a fatal signal. Kernels older than 5.19 (for - // example RHEL 9.x / 5.14 nodes) reject the flag with EINVAL. Rather than - // refusing to start there, fall back to a plain listener: the notification - // receive is then uninterruptible, but the sandbox is otherwise fully - // functional. The resulting listener records `wait_killable_recv = false` - // so callers can observe the degraded cancellation semantics. + // WAIT_KILLABLE_RECV (Linux 5.19+) keeps the *notified workload thread* in + // a kill-only wait while the broker services its syscall, so a non-fatal + // signal cannot resume the syscall and repurpose its buffers underneath a + // pending broker write. Kernels older than 5.19 (for example RHEL 9.x / + // 5.14 nodes) reject the flag with EINVAL. Rather than refusing to start + // there, fall back to a plain listener so the sandbox boots; the resulting + // listener records `wait_killable_recv = false`, and the broker then fails + // closed on every task-memory output write (see `write_task_output`) + // instead of racing them. Input mediation is unaffected. match install_listener_with_flags(syscalls, true) { Ok(listener) => Ok(listener), Err(error) if error.raw_os_error() == Some(libc::EINVAL) => { @@ -939,4 +1025,52 @@ mod tests { .expect_err("zero errno must fail"); assert_eq!(error.kind(), io::ErrorKind::InvalidInput); } + + #[test] + fn plain_listener_fails_closed_on_output_write() { + // A LegacyReadOnly listener (kernels < 5.19) must refuse every + // task-memory output write rather than race a resumed syscall. The + // guard short-circuits before touching the descriptor or workload + // memory, so a dup of stderr is a sufficient stand-in. + // SAFETY: dup takes one valid descriptor and returns a new descriptor + // or a negative error without modifying memory. + let duplicated = unsafe { libc::dup(libc::STDERR_FILENO) }; + assert!(duplicated >= 0, "duplicate stderr for validation test"); + // SAFETY: successful dup returned a new owned descriptor. + let listener = NotificationListener::from_fd_with_mode( + unsafe { OwnedFd::from_raw_fd(duplicated) }, + ListenerMode::LegacyReadOnly, + ); + assert!(listener.writes_disabled()); + assert_eq!(listener.mode(), ListenerMode::LegacyReadOnly); + let error = listener + .write_task_output(1, 0, 0, &[0_u8; 4]) + .expect_err("plain listener must reject output writes"); + assert_eq!(error.raw_os_error(), Some(libc::EOPNOTSUPP)); + } + + #[test] + fn real_plain_listener_disables_output_writes() { + // Deliberately install a plain NEW_LISTENER (no WAIT_KILLABLE_RECV) + // even on a modern CI kernel and prove the broker write path fails + // closed on the real listener object. + set_no_new_privileges().expect("no_new_privs for listener install"); + let listener = install_listener_with_flags(&[libc::SYS_getppid], false) + .expect("install plain listener"); + assert_eq!(listener.mode(), ListenerMode::LegacyReadOnly); + assert!(listener.writes_disabled()); + let error = listener + .write_task_output(1, 0, 0, &[0_u8; 4]) + .expect_err("plain listener must reject output writes"); + assert_eq!(error.raw_os_error(), Some(libc::EOPNOTSUPP)); + } + + #[test] + fn killable_listener_enables_output_writes() { + set_no_new_privileges().expect("no_new_privs for listener install"); + let listener = install_listener_with_flags(&[libc::SYS_getppid], true) + .expect("install killable listener"); + assert_eq!(listener.mode(), ListenerMode::Killable); + assert!(!listener.writes_disabled()); + } } diff --git a/crates/openshell-isolation-interface/tests/backend_conformance.rs b/crates/openshell-isolation-interface/tests/backend_conformance.rs index f8b0236dc6..5273e706f0 100644 --- a/crates/openshell-isolation-interface/tests/backend_conformance.rs +++ b/crates/openshell-isolation-interface/tests/backend_conformance.rs @@ -395,6 +395,7 @@ fn confirmation_evidence() -> SandboxConfirmEvidence { task_memory_read: true, task_memory_write: true, cancellation: true, + task_memory_writes_disabled: false, }, landlock_abi: 3, landlock_allow_deny: true, @@ -551,6 +552,37 @@ fn confirmation_constructor_rejects_incomplete_evidence() { assert!(matches!(result, Err(BackendError::Confirm(_)))); } +#[test] +fn confirmation_accepts_legacy_read_only_listener() { + // Cancellation-safety invariant: cancellation OR writes-disabled. A legacy + // plain listener (< 5.19) has no cancellation but disables broker output + // writes, so it satisfies the invariant and must be accepted. + let mut evidence = confirmation_evidence(); + evidence.seccomp.cancellation = false; + evidence.seccomp.task_memory_writes_disabled = true; + let result = ConfirmedBoundary::try_new( + Box::new(MockReady:: { _k: PhantomData }), + evidence, + &workload_identity(), + ); + assert!(result.is_ok()); +} + +#[test] +fn confirmation_rejects_plain_listener_with_writes_enabled() { + // The unsafe configuration this PR must never admit: no cancellation AND + // task-memory output writes still enabled (the cancellation race). + let mut evidence = confirmation_evidence(); + evidence.seccomp.cancellation = false; + evidence.seccomp.task_memory_writes_disabled = false; + let result = ConfirmedBoundary::try_new( + Box::new(MockReady:: { _k: PhantomData }), + evidence, + &workload_identity(), + ); + assert!(matches!(result, Err(BackendError::Confirm(_)))); +} + #[test] fn confirmation_constructor_rejects_another_workload_identity() { let expected = ResolvedWorkloadIdentity::new( diff --git a/crates/openshell-sandbox-backend/src/runtime.rs b/crates/openshell-sandbox-backend/src/runtime.rs index 1b56d6df9d..729ca860b1 100644 --- a/crates/openshell-sandbox-backend/src/runtime.rs +++ b/crates/openshell-sandbox-backend/src/runtime.rs @@ -2580,6 +2580,7 @@ mod tests { task_memory_read: true, task_memory_write: true, cancellation: true, + task_memory_writes_disabled: false, }, landlock_abi: 3, landlock_allow_deny: true, diff --git a/crates/openshell-sandbox/src/boundary_server.rs b/crates/openshell-sandbox/src/boundary_server.rs index 5452ea6efa..3e05970dd0 100644 --- a/crates/openshell-sandbox/src/boundary_server.rs +++ b/crates/openshell-sandbox/src/boundary_server.rs @@ -4277,6 +4277,7 @@ mod linux { task_memory_read: true, task_memory_write: true, cancellation: true, + task_memory_writes_disabled: false, }, landlock_abi: 6, landlock_allow_deny: true, diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 24fa65ab16..34c0105f46 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -146,6 +146,11 @@ struct QualificationReport { tcp_allow_round_trip: bool, tcp_deny_round_trip: bool, wait_killable_recv: bool, + /// Selected seccomp listener cancellation mode: "killable" (>= 5.19) or + /// "legacy_read_only" (< 5.19, broker output writes disabled). + seccomp_listener_mode: &'static str, + /// Whether the broker disables task-memory output writes (legacy mode). + task_memory_writes_disabled: bool, } #[cfg(target_os = "linux")] @@ -237,6 +242,12 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi tcp_allow_round_trip: true, tcp_deny_round_trip: true, wait_killable_recv: notification.wait_killable_recv, + seccomp_listener_mode: if notification.wait_killable_recv { + "killable" + } else { + "legacy_read_only" + }, + task_memory_writes_disabled: !notification.wait_killable_recv, }; let qualification = openshell_sandbox::RuntimeQualification { seccomp: openshell_isolation_interface::contract::SeccompEvidence { @@ -249,6 +260,9 @@ fn qualify_runtime() -> Result<(openshell_sandbox::RuntimeQualification, Qualifi task_memory_read: notification.task_memory_copy(), task_memory_write: notification.task_memory_copy(), cancellation: notification.wait_killable_recv, + // Legacy plain listener (< 5.19) disables broker output writes; + // satisfies the `cancellation || writes_disabled` launch invariant. + task_memory_writes_disabled: !notification.wait_killable_recv, }, landlock_abi, landlock_allow_deny: true, diff --git a/crates/openshell-sandbox/src/network_broker.rs b/crates/openshell-sandbox/src/network_broker.rs index d4972465c6..aed81ce1e7 100644 --- a/crates/openshell-sandbox/src/network_broker.rs +++ b/crates/openshell-sandbox/src/network_broker.rs @@ -1325,8 +1325,8 @@ fn classify_send( if let Some(length_address) = message.result_length_address { let length = u32::try_from(message.data.len()) .map_err(|_| io::Error::from_raw_os_error(libc::EMSGSIZE))?; - listener.validate_id(notification.id)?; - task_memory::write_exact( + listener.write_task_output( + notification.id, notification.tid, length_address, &length.to_ne_bytes(), @@ -1679,6 +1679,15 @@ fn write_socket_addr( length_address: u64, value: SocketAddr, ) -> io::Result<()> { + // A LegacyReadOnly listener (kernels < 5.19) cannot safely write into + // workload memory: without WAIT_KILLABLE_RECV the notified accept/ + // getpeername could resume and repurpose these buffers between validation + // and the broker write. Fail closed before reading or writing anything, so + // this address-writing path is inert in legacy mode. Callers that pass a + // null address argument (accept with a null peer address) never reach here. + if listener.writes_disabled() { + return Err(io::Error::from_raw_os_error(libc::EOPNOTSUPP)); + } let mut supplied_length = [0_u8; size_of::()]; task_memory::read_exact(tid, length_address, &mut supplied_length)?; let supplied_length = libc::socklen_t::from_ne_bytes(supplied_length); @@ -1686,12 +1695,10 @@ fn write_socket_addr( let copied = usize::try_from(supplied_length) .unwrap_or(0) .min(bytes.len()); - listener.validate_id(notification_id)?; if copied != 0 { - task_memory::write_exact(tid, address, &bytes[..copied])?; + listener.write_task_output(notification_id, tid, address, &bytes[..copied])?; } - listener.validate_id(notification_id)?; - task_memory::write_exact(tid, length_address, &actual_length.to_ne_bytes()) + listener.write_task_output(notification_id, tid, length_address, &actual_length.to_ne_bytes()) } fn sockaddr_bytes(address: SocketAddr) -> io::Result<(Vec, libc::socklen_t)> { @@ -1760,6 +1767,7 @@ fn error_to_errno(error: &io::Error) -> i32 { #[cfg(test)] mod tests { use super::*; + use openshell_isolation_interface::linux::seccomp_notify::ListenerMode; use std::io::{Read as _, Write as _}; use std::os::unix::net::{UnixListener, UnixStream}; @@ -1776,6 +1784,25 @@ mod tests { ))); } + #[test] + fn legacy_listener_rejects_socket_addr_write() { + // accept-with-address and getpeername both route through + // write_socket_addr; on a LegacyReadOnly listener the path must fail + // closed (EOPNOTSUPP) before any task-memory access. + // SAFETY: dup returns a new descriptor or a negative error. + let dup = unsafe { libc::dup(libc::STDERR_FILENO) }; + assert!(dup >= 0, "dup stderr"); + let listener = NotificationListener::from_fd_with_mode( + // SAFETY: successful dup returned a new owned descriptor. + unsafe { OwnedFd::from_raw_fd(dup) }, + ListenerMode::LegacyReadOnly, + ); + let peer: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let error = write_socket_addr(&listener, 1, 0, 0, 0, peer) + .expect_err("legacy listener must reject socket-address writes"); + assert_eq!(error.raw_os_error(), Some(libc::EOPNOTSUPP)); + } + #[test] fn relay_rejects_descriptor_replaced_after_policy_decision() { let metadata = SocketMetadata { From 285085eb8b4081b482cc4efb493e8905ee5c3983 Mon Sep 17 00:00:00 2001 From: Akram Date: Fri, 18 Sep 2026 18:54:45 +0400 Subject: [PATCH 4/5] docs: document legacy read-only sandbox mode on kernels before 5.19 Kernels without SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV (< 5.19, e.g. RHEL 9.x / RHCOS 5.14) run the sandbox in a legacy read-only cancellation mode where the broker fails closed with EOPNOTSUPP on the mediated operations that write results back into workload memory (getpeername, accept/accept4 with a non-null address, sendmmsg length write-backs). Document this observable behavior and its syscall limitations in the public Fern docs: the support-matrix kernel requirements and the OpenShift runtime guidance. Signed-off-by: Akram --- docs/kubernetes/openshift.mdx | 15 +++++++++++++++ docs/reference/support-matrix.mdx | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/docs/kubernetes/openshift.mdx b/docs/kubernetes/openshift.mdx index aaad7f6c53..39b0a4bf14 100644 --- a/docs/kubernetes/openshift.mdx +++ b/docs/kubernetes/openshift.mdx @@ -19,6 +19,21 @@ process to install a nested seccomp user-notification filter and use Landlock. OpenShell fails sandbox startup when either capability-free runtime probe fails. +## Node kernel and legacy read-only mode + +OpenShift nodes run RHCOS, which currently ships a RHEL 9.x kernel (5.14). That +kernel predates `SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV` (Linux 5.19), so the +sandbox starts in a reduced **legacy read-only** cancellation mode. Isolation is +unchanged, but the broker fails closed with `EOPNOTSUPP` on the mediated +operations that write results back into workload memory — `getpeername`, +`accept`/`accept4` with a non-null peer-address argument, and `sendmmsg` +per-message length write-backs. Outbound-oriented workloads run unchanged; +server workloads that read the peer address on accept need a node kernel with +`WAIT_KILLABLE_RECV` (Linux 5.19+, or a distribution backport). See the +[support matrix](/reference/support-matrix#legacy-read-only-mode-kernels-before-linux-519) +for the full behavior; the selected mode is reported as `seccomp_listener_mode` +in the sandbox qualification output. + ## Prerequisites - OpenShift 4.x cluster with `oc` configured. diff --git a/docs/reference/support-matrix.mdx b/docs/reference/support-matrix.mdx index 740b5c997f..74a8d77be0 100644 --- a/docs/reference/support-matrix.mdx +++ b/docs/reference/support-matrix.mdx @@ -103,12 +103,37 @@ when it runs inside a container or microVM: | -------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [Landlock LSM](https://docs.kernel.org/security/landlock.html) | Required | ABI 3 or newer, introduced in Linux 6.2, with Landlock enabled. The mandatory baseline protects private channel and bootstrap files, including against truncation. A filesystem policy's `best_effort` setting never disables this baseline. | | seccomp | Required | Nested user-notification filters and atomic `SECCOMP_IOCTL_NOTIF_ADDFD` with `SECCOMP_ADDFD_FLAG_SEND`, usable under the runtime's existing seccomp profile without added capabilities. The sandbox actively probes these operations before admitting the workload. | +| seccomp `WAIT_KILLABLE_RECV` | Recommended (Linux 5.19+) | Keeps a notified workload thread in a kill-only wait so the broker can safely write mediated results into workload memory. Without it (kernels < 5.19, for example RHEL 9.x / RHCOS 5.14) the sandbox still starts, in a reduced **legacy read-only** mode described below. | A kernel version alone does not establish support. A disabled Landlock LSM or a runtime profile that blocks the required seccomp operations causes launch to fail closed. An upstream Linux 6.2 or newer kernel provides the required Landlock ABI; distribution backports must pass the same active qualification. +### Legacy read-only mode (kernels before Linux 5.19) + +`SECCOMP_FILTER_FLAG_WAIT_KILLABLE_RECV` was added in Linux 5.19. On older +kernels — notably RHEL 9.x and RHCOS, which ship a 5.14 kernel — the sandbox +cannot install a kill-only listener, so it falls back to a plain listener and +runs in a **legacy read-only** cancellation mode. The sandbox starts and +enforces the full isolation boundary (Landlock, the outer NetworkPolicy fence, +DNS and TCP authorization); the only difference is that the broker refuses the +mediated operations that write results back into workload memory, failing them +closed with `EOPNOTSUPP`: + +- `getpeername`; +- `accept` / `accept4` **when a non-null peer-address argument is supplied** + (a null address argument still works); +- `sendmmsg` paths that write per-message lengths back to the caller. + +Socket creation, `connect`, `bind`, `listen`, `sendto`, and `sendmsg` are +unaffected. Outbound-oriented workloads generally run unchanged; server +workloads whose accept wrappers request the peer address will see `EOPNOTSUPP` +until the node runs a kernel that provides `WAIT_KILLABLE_RECV` (Linux 5.19+, or +a distribution backport). The selected mode is reported in the sandbox +qualification output as `seccomp_listener_mode` (`killable` or +`legacy_read_only`). + On macOS, these kernel modules run inside the Docker Desktop Linux VM, not on the host kernel. ## Agent Compatibility From 72559418493abf8105d1acfe5d4df7bf5072e4e5 Mon Sep 17 00:00:00 2001 From: Akram Date: Fri, 18 Sep 2026 19:47:17 +0400 Subject: [PATCH 5/5] style(sandbox): satisfy rustfmt and clippy doc_markdown Match the pinned rustfmt (Rust 1.95.0) line-wrapping for the write_task_output call, and backtick `legacy_read_only` in the qualification-report doc comment so clippy::doc_markdown (-D warnings) passes. Signed-off-by: Akram --- crates/openshell-sandbox/src/main.rs | 4 ++-- crates/openshell-sandbox/src/network_broker.rs | 7 ++++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/openshell-sandbox/src/main.rs b/crates/openshell-sandbox/src/main.rs index 34c0105f46..5fa1e6daca 100644 --- a/crates/openshell-sandbox/src/main.rs +++ b/crates/openshell-sandbox/src/main.rs @@ -146,8 +146,8 @@ struct QualificationReport { tcp_allow_round_trip: bool, tcp_deny_round_trip: bool, wait_killable_recv: bool, - /// Selected seccomp listener cancellation mode: "killable" (>= 5.19) or - /// "legacy_read_only" (< 5.19, broker output writes disabled). + /// Selected seccomp listener cancellation mode: `killable` (>= 5.19) or + /// `legacy_read_only` (< 5.19, broker output writes disabled). seccomp_listener_mode: &'static str, /// Whether the broker disables task-memory output writes (legacy mode). task_memory_writes_disabled: bool, diff --git a/crates/openshell-sandbox/src/network_broker.rs b/crates/openshell-sandbox/src/network_broker.rs index aed81ce1e7..c420edb4b9 100644 --- a/crates/openshell-sandbox/src/network_broker.rs +++ b/crates/openshell-sandbox/src/network_broker.rs @@ -1698,7 +1698,12 @@ fn write_socket_addr( if copied != 0 { listener.write_task_output(notification_id, tid, address, &bytes[..copied])?; } - listener.write_task_output(notification_id, tid, length_address, &actual_length.to_ne_bytes()) + listener.write_task_output( + notification_id, + tid, + length_address, + &actual_length.to_ne_bytes(), + ) } fn sockaddr_bytes(address: SocketAddr) -> io::Result<(Vec, libc::socklen_t)> {