Repository: NVIDIA/OpenShell (2026-04-10T22:38:49Z)

Issue:
bug(sandbox): Landlock not enforced on Landlock-capable kernels — best_effort silently returns Ok()
## Summary

OpenShell 0.0.26 does not enforce Landlock filesystem restrictions despite receiving a correct policy and running on Landlock-capable kernels. The sandbox process runs as `unconfined` (`/proc/1/attr/current = "unconfined"`). `/sandbox` is fully writable.

Reported by QA in [NVIDIA/NemoClaw#1739](https://github.com/NVIDIA/NemoClaw/issues/1739) / [nvbug 6066573](https://nvbugspro.nvidia.com/bug/6066573).

**Previous issues [#584](https://github.com/NVIDIA/OpenShell/issues/584) and [#664](https://github.com/NVIDIA/OpenShell/issues/664) were closed as fixed (PRs [#599](https://github.com/NVIDIA/OpenShell/pull/599), [#677](https://github.com/NVIDIA/OpenShell/pull/677)), and those fixes shipped in v0.0.26, but the problem persists.**

v0.0.27 has no Landlock changes over v0.0.26.

## Environment (from QA)

All platforms affected:
- **Brev GPU**: kernel 5.15.0-107, kernel 6.8.0-57
- **Standard Ubuntu**: kernel 6.8.0-57
- `CONFIG_SECURITY_LANDLOCK=y`
- Landlock in LSM: `lockdown,capability,landlock,yama,apparmor`
- OpenShell: 0.0.26

Kernels are well above 5.13 (Landlock V1) and 5.19 (unprivileged Landlock). This is not a kernel compatibility issue.

## Reproduction

```bash
nemoclaw onboard
nemoclaw <name> connect
touch /sandbox/testfile  # Expected: Permission denied. Actual: succeeds
cat /proc/1/attr/current # Shows: "unconfined"
```

## Policy delivered correctly

`nemoclaw status --json` confirms:

```yaml
filesystem_policy:
  include_workdir: false
  read_only:
    - /sandbox
    - /sandbox/.openclaw
  read_write:
    - /tmp
    - /sandbox/.openclaw-data
    - /sandbox/.nemoclaw
landlock:
  compatibility: best_effort
```

## Root cause analysis

### Execution order in [process.rs#L184-L187](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/process.rs#L184):

```rust
drop_privileges(&policy)                       // line 184: switches to sandbox user (uid 998)
sandbox::apply(&policy, workdir.as_deref())    // line 187: tries to apply Landlock AS UNPRIVILEGED USER
```

### Inside [landlock.rs](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/sandbox/linux/landlock.rs):

1. `ruleset.create()` — [line 60](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/sandbox/linux/landlock.rs#L60)
2. `try_open_path()` for each policy path — [lines 63-80](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/sandbox/linux/landlock.rs#L63)
3. `restrict_self()` — [line 103](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/sandbox/linux/landlock.rs#L103)

If ANY step fails and `compatibility == BestEffort`, the error is caught at [line 107](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/sandbox/linux/landlock.rs#L107), an OCSF finding is logged, and **`Ok(())` is returned at [line 128](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/sandbox/linux/landlock.rs#L128)**. The caller sees success. The sandbox runs unconfined.

## Likely failure candidates

### 1. `try_open_path()` fails after `drop_privileges()` (MOST LIKELY)

`try_open_path()` does `open(path, O_PATH | O_CLOEXEC)` as the **sandbox user (uid 998)**, not root. Container runtimes, AppArmor profiles, or mount configurations may restrict `O_PATH` opens for non-root users. If ALL paths fail, `rules_applied == 0` at [line 83](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/sandbox/linux/landlock.rs#L83) → error → `best_effort` catches it → `Ok(())` → unconfined.

**Fix:** Open path FDs as root BEFORE `drop_privileges()`, then pass them into the Landlock ruleset builder.

### 2. Container runtime seccomp blocks Landlock syscalls

Docker's default seccomp profile may block syscalls 444 (`landlock_create_ruleset`), 445 (`landlock_add_rule`), 446 (`landlock_restrict_self`). This would cause `ruleset.create()` at [line 60](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/sandbox/linux/landlock.rs#L60) to fail before OpenShell's own seccomp runs.

**Fix:** Verify the container runtime permits Landlock syscalls, or document the requirement.

### 3. `landlock` crate ABI negotiation failure

The code uses `ABI::V2` at [line 33](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/sandbox/linux/landlock.rs#L33) with `CompatLevel::BestEffort`. The crate (v0.4.4) might fail ABI negotiation internally despite kernel support.

### 4. `PR_SET_NO_NEW_PRIVS` not set

`restrict_self()` requires `PR_SET_NO_NEW_PRIVS`. If Docker's `--security-opt no-new-privileges` isn't set and the code doesn't call `prctl()` explicitly, `restrict_self()` fails with EPERM.

## Suggested fixes

1. **Move path FD opening before `drop_privileges()`** — open `O_PATH` FDs as root, pass them into the ruleset builder. This is the most likely fix for candidate 1.

2. **Make the failure visible** — the OCSF finding at [line 125](https://github.com/NVIDIA/OpenShell/blob/main/crates/openshell-sandbox/src/sandbox/linux/landlock.rs#L125) goes to structured logs nobody reads. Add a visible stderr warning and/or set an env var like `OPENSHELL_LANDLOCK_ACTIVE=0` so downstream code can detect it.

3. **Log the specific error** — the OCSF finding includes the error string, but it's buried in structured logging. Print the actual failure reason to stderr so operators can diagnose which candidate is failing.

4. **Two-phase Landlock** — phase 1 (as root): open path FDs + create ruleset. Phase 2 (after `drop_privileges()`): call `restrict_self()` only.

## Impact

- NemoClaw's entire Landlock-based security model ([PR #1121](https://github.com/NVIDIA/NemoClaw/pull/1121)) is ineffective
- `nemoclaw onboard` displays "Landlock + seccomp + netns" which is misleading
- Only DAC protections (root:root, sticky bit, chmod 444) prevent filesystem abuse

## Related

- [NVIDIA/NemoClaw#1739](https://github.com/NVIDIA/NemoClaw/issues/1739) / [nvbug 6066573](https://nvbugspro.nvidia.com/bug/6066573)
- Closed (fixes in v0.0.26 but problem persists): [#584](https://github.com/NVIDIA/OpenShell/issues/584), [#664](https://github.com/NVIDIA/OpenShell/issues/664)
- Related: [#783](https://github.com/NVIDIA/OpenShell/issues/783) (prepare_filesystem chowns read_write dirs)


Discussion:
I will look into this. @prekshivyas for reproduction, please ensure it is a pure OpenShell reproduction so we can remove as many steps as possible.
**Confirmed on v0.0.25** (same behavior as reported on v0.0.26 — prior PRs for #584/#664 did not actually fix the regression on our platform).

**Host:** Ubuntu 24.04.3 LTS, kernel 6.8.0-107-generic. \`CONFIG_SECURITY_LANDLOCK=y\`, \`CONFIG_LSM=landlock,lockdown,yama,integrity,apparmor\` — Landlock is compiled in and enabled in the LSM stack.

**Observation from inside a running sandbox (OpenShell v0.0.25, \`claw-test\`):**

\`\`\`
$ cat /proc/1/attr/current
unconfined

$ grep Seccomp /proc/self/status
Seccomp:	2
Seccomp_filters:	1

$ touch /sandbox/landlock-test-\$\$
[no error — /sandbox is fully writable]

$ mount | grep sandbox
/dev/sdb on /sandbox type ext4 (rw,relatime,discard)
\`\`\`

Seccomp is enforced (filter mode, 1 filter) but Landlock clearly is not (\`unconfined\`). Matches the #803 signature exactly — silent \`best_effort → Ok()\` path has been shipping at least since v0.0.25.

Happy to test PR #810 when it's ready for external validation.



Patch:
diff --git a/crates/openshell-sandbox/src/process.rs b/crates/openshell-sandbox/src/process.rs
index b29682cf0..2b0181cfe 100644
--- a/crates/openshell-sandbox/src/process.rs
+++ b/crates/openshell-sandbox/src/process.rs
@@ -154,6 +154,19 @@ impl ProcessHandle {
             }
         }
 
+        // Probe Landlock availability and emit OCSF logs from the parent
+        // process where the tracing subscriber is functional. The child's
+        // pre_exec context cannot reliably emit structured logs.
+        #[cfg(target_os = "linux")]
+        sandbox::linux::log_sandbox_readiness(policy, workdir);
+
+        // Phase 1 (as root): Prepare Landlock ruleset by opening PathFds.
+        // This MUST happen before drop_privileges() so that root-only paths
+        // (e.g. mode 700 directories) can be opened. See issue #803.
+        #[cfg(target_os = "linux")]
+        let prepared_sandbox = sandbox::linux::prepare(policy, workdir)
+            .map_err(|err| miette::miette!("Failed to prepare sandbox: {err}"))?;
+
         // Set up process group for signal handling (non-interactive mode only).
         // In interactive mode, we inherit the parent's process group to maintain
         // proper terminal control for shells and interactive programs.
@@ -161,7 +174,10 @@ impl ProcessHandle {
         // setpgid and setns are async-signal-safe and safe to call in this context.
         {
             let policy = policy.clone();
-            let workdir = workdir.map(str::to_string);
+            // Wrap in Option so we can .take() it out of the FnMut closure.
+            // pre_exec is only called once (after fork, before exec).
+            #[cfg(target_os = "linux")]
+            let mut prepared_sandbox = Some(prepared_sandbox);
             #[allow(unsafe_code)]
             unsafe {
                 cmd.pre_exec(move || {
@@ -178,14 +194,20 @@ impl ProcessHandle {
                         }
                     }
 
-                    // Drop privileges before applying sandbox restrictions.
-                    // initgroups/setgid/setuid need access to /etc/group and /etc/passwd
-                    // which may be blocked by Landlock.
+                    // Drop privileges. initgroups/setgid/setuid need access to
+                    // /etc/group and /etc/passwd which would be blocked if
+                    // Landlock were already enforced.
                     drop_privileges(&policy)
                         .map_err(|err| std::io::Error::other(err.to_string()))?;
 
-                    sandbox::apply(&policy, workdir.as_deref())
-                        .map_err(|err| std::io::Error::other(err.to_string()))?;
+                    // Phase 2 (as unprivileged user): Enforce the prepared
+                    // Landlock ruleset via restrict_self() + apply seccomp.
+                    // restrict_self() does not require root.
+                    #[cfg(target_os = "linux")]
+                    if let Some(prepared) = prepared_sandbox.take() {
+                        sandbox::linux::enforce(prepared)
+                            .map_err(|err| std::io::Error::other(err.to_string()))?;
+                    }
 
                     Ok(())
                 });
diff --git a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs
index 4dcc55449..b982c5238 100644
--- a/crates/openshell-sandbox/src/sandbox/linux/landlock.rs
+++ b/crates/openshell-sandbox/src/sandbox/linux/landlock.rs
@@ -12,7 +12,97 @@ use miette::{IntoDiagnostic, Result};
 use std::path::{Path, PathBuf};
 use tracing::debug;
 
-pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
+/// 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,
+}
+
+/// 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>> {
     let read_only = policy.filesystem.read_only.clone();
     let mut read_write = policy.filesystem.read_write.clone();
 
@@ -26,7 +116,7 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
     }
 
     if read_only.is_empty() && read_write.is_empty() {
-        return Ok(());
+        return Ok(None);
     }
 
     let total_paths = read_only.len() + read_write.len();
@@ -47,7 +137,7 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
 
     let compatibility = &policy.landlock.compatibility;
 
-    let result: Result<()> = (|| {
+    let result: Result<PreparedRuleset> = (|| {
         let access_all = AccessFs::from_all(abi);
         let access_read = AccessFs::from_read(abi);
 
@@ -100,12 +190,56 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
                 .build()
         );
 
-        ruleset.restrict_self().into_diagnostic()?;
-        Ok(())
+        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(crate::ocsf_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!(compatibility, LandlockCompatibility::BestEffort) {
+        if matches!(prepared.compatibility, LandlockCompatibility::BestEffort) {
             openshell_ocsf::ocsf_emit!(
                 openshell_ocsf::DetectionFindingBuilder::new(crate::ocsf_ctx())
                     .activity(openshell_ocsf::ActivityId::Open)
@@ -114,22 +248,34 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
                     .is_alert(true)
                     .finding_info(
                         openshell_ocsf::FindingInfo::new(
-                            "landlock-unavailable",
-                            "Landlock Filesystem Sandbox Unavailable",
+                            "landlock-enforce-failed",
+                            "Landlock restrict_self Failed",
                         )
                         .with_desc(&format!(
-                            "Running WITHOUT filesystem restrictions: {err}. \
+                            "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 filesystem sandbox unavailable: {err}"))
+                    .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.
+pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
+    if let Some(prepared) = prepare(policy, workdir)? {
+        enforce(prepared)?;
+    }
     Ok(())
 }
 
@@ -311,4 +457,23 @@ mod tests {
         let err = PathFd::new("/nonexistent/openshell/classify/test").unwrap_err();
         assert_eq!(classify_path_fd_error(&err), "path does not exist");
     }
+
+    #[test]
+    fn probe_availability_returns_a_result() {
+        // The probe should not panic regardless of whether Landlock is available.
+        // On Linux hosts with Landlock, this returns Available; on Docker Desktop
+        // linuxkit or older kernels, it returns NotImplemented/NotEnabled/Blocked.
+        let result = probe_availability();
+        let display = format!("{result}");
+        assert!(
+            !display.is_empty(),
+            "probe_availability Display should produce output"
+        );
+        // Verify the Debug impl works too.
+        let debug = format!("{result:?}");
+        assert!(
+            !debug.is_empty(),
+            "probe_availability Debug should produce output"
+        );
+    }
 }
diff --git a/crates/openshell-sandbox/src/sandbox/linux/mod.rs b/crates/openshell-sandbox/src/sandbox/linux/mod.rs
index 867dbdc11..988aff1ca 100644
--- a/crates/openshell-sandbox/src/sandbox/linux/mod.rs
+++ b/crates/openshell-sandbox/src/sandbox/linux/mod.rs
@@ -9,9 +9,156 @@ mod seccomp;
 
 use crate::policy::SandboxPolicy;
 use miette::Result;
+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 as root.
+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 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(())
+}
+
+/// Legacy single-phase apply. Kept for backward compatibility.
+/// New callers should use [`prepare`] + [`enforce`] for correct privilege ordering.
 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 {
+        if 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 {
+        openshell_ocsf::ocsf_emit!(
+            openshell_ocsf::ConfigStateChangeBuilder::new(crate::ocsf_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();
+    match &availability {
+        landlock::LandlockAvailability::Available { abi } => {
+            openshell_ocsf::ocsf_emit!(
+                openshell_ocsf::ConfigStateChangeBuilder::new(crate::ocsf_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()
+            );
+        }
+        _ => {
+            // 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,
+                crate::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(crate::ocsf_ctx())
+                    .activity(openshell_ocsf::ActivityId::Open)
+                    .severity(openshell_ocsf::SeverityId::High)
+                    .confidence(openshell_ocsf::ConfidenceId::High)
+                    .is_alert(true)
+                    .finding_info(
+                        openshell_ocsf::FindingInfo::new(
+                            "landlock-unavailable",
+                            "Landlock Filesystem Sandbox Unavailable",
+                        )
+                        .with_desc(&desc),
+                    )
+                    .message(msg)
+                    .build()
+            );
+        }
+    }
+}
diff --git a/crates/openshell-sandbox/src/ssh.rs b/crates/openshell-sandbox/src/ssh.rs
index b9f947395..0bee2e2f2 100644
--- a/crates/openshell-sandbox/src/ssh.rs
+++ b/crates/openshell-sandbox/src/ssh.rs
@@ -898,6 +898,15 @@ fn spawn_pty_shell(
         cmd.current_dir(dir);
     }
 
+    // Probe Landlock availability from the parent process where tracing works.
+    #[cfg(target_os = "linux")]
+    sandbox::linux::log_sandbox_readiness(policy, workdir.as_deref());
+
+    // Phase 1 (as root): Prepare Landlock ruleset before drop_privileges.
+    #[cfg(target_os = "linux")]
+    let prepared_sandbox = sandbox::linux::prepare(policy, workdir.as_deref())
+        .map_err(|err| anyhow::anyhow!("Failed to prepare sandbox: {err}"))?;
+
     #[cfg(unix)]
     {
         unsafe_pty::install_pre_exec(
@@ -906,6 +915,8 @@ fn spawn_pty_shell(
             workdir.clone(),
             slave_fd,
             netns_fd,
+            #[cfg(target_os = "linux")]
+            prepared_sandbox,
         );
     }
 
@@ -1034,9 +1045,25 @@ fn spawn_pipe_exec(
         cmd.current_dir(dir);
     }
 
+    // Probe Landlock availability from the parent process where tracing works.
+    #[cfg(target_os = "linux")]
+    sandbox::linux::log_sandbox_readiness(policy, workdir.as_deref());
+
+    // Phase 1 (as root): Prepare Landlock ruleset before drop_privileges.
+    #[cfg(target_os = "linux")]
+    let prepared_sandbox = sandbox::linux::prepare(policy, workdir.as_deref())
+        .map_err(|err| anyhow::anyhow!("Failed to prepare sandbox: {err}"))?;
+
     #[cfg(unix)]
     {
-        unsafe_pty::install_pre_exec_no_pty(&mut cmd, policy.clone(), workdir.clone(), netns_fd);
+        unsafe_pty::install_pre_exec_no_pty(
+            &mut cmd,
+            policy.clone(),
+            workdir.clone(),
+            netns_fd,
+            #[cfg(target_os = "linux")]
+            prepared_sandbox,
+        );
     }
 
     let mut child = cmd.spawn()?;
@@ -1131,7 +1158,9 @@ fn spawn_pipe_exec(
 }
 
 mod unsafe_pty {
-    use super::{Command, RawFd, SandboxPolicy, Winsize, drop_privileges, sandbox, setsid};
+    #[cfg(not(target_os = "linux"))]
+    use super::sandbox;
+    use super::{Command, RawFd, SandboxPolicy, Winsize, drop_privileges, setsid};
     #[cfg(unix)]
     use std::os::unix::process::CommandExt;
 
@@ -1157,16 +1186,26 @@ mod unsafe_pty {
     pub fn install_pre_exec(
         cmd: &mut Command,
         policy: SandboxPolicy,
-        workdir: Option<String>,
+        _workdir: Option<String>,
         slave_fd: RawFd,
         netns_fd: Option<RawFd>,
+        #[cfg(target_os = "linux")] prepared: crate::sandbox::linux::PreparedSandbox,
     ) {
+        // Wrap in Option so we can .take() it out of the FnMut closure.
+        // pre_exec is only called once (after fork, before exec).
+        #[cfg(target_os = "linux")]
+        let mut prepared = Some(prepared);
         unsafe {
             cmd.pre_exec(move || {
                 setsid().map_err(|err| std::io::Error::other(err.to_string()))?;
                 set_controlling_tty(slave_fd)?;
 
-                enter_netns_and_sandbox(netns_fd, &policy, workdir.as_deref())
+                enter_netns_and_sandbox(
+                    netns_fd,
+                    &policy,
+                    #[cfg(target_os = "linux")]
+                    prepared.take(),
+                )
             });
         }
     }
@@ -1178,18 +1217,28 @@ mod unsafe_pty {
     pub fn install_pre_exec_no_pty(
         cmd: &mut Command,
         policy: SandboxPolicy,
-        workdir: Option<String>,
+        _workdir: Option<String>,
         netns_fd: Option<RawFd>,
+        #[cfg(target_os = "linux")] prepared: crate::sandbox::linux::PreparedSandbox,
     ) {
+        #[cfg(target_os = "linux")]
+        let mut prepared = Some(prepared);
         unsafe {
-            cmd.pre_exec(move || enter_netns_and_sandbox(netns_fd, &policy, workdir.as_deref()));
+            cmd.pre_exec(move || {
+                enter_netns_and_sandbox(
+                    netns_fd,
+                    &policy,
+                    #[cfg(target_os = "linux")]
+                    prepared.take(),
+                )
+            });
         }
     }
 
     fn enter_netns_and_sandbox(
         netns_fd: Option<RawFd>,
         policy: &SandboxPolicy,
-        workdir: Option<&str>,
+        #[cfg(target_os = "linux")] prepared: Option<crate::sandbox::linux::PreparedSandbox>,
     ) -> std::io::Result<()> {
         // Enter network namespace before dropping privileges.
         // This ensures SSH shell processes are isolated to the same
@@ -1207,11 +1256,21 @@ mod unsafe_pty {
         #[cfg(not(target_os = "linux"))]
         let _ = netns_fd;
 
-        // Drop privileges before applying sandbox restrictions.
-        // initgroups/setgid/setuid need access to /etc/group and /etc/passwd
-        // which may be blocked by Landlock.
+        // Drop privileges. initgroups/setgid/setuid need /etc/group and
+        // /etc/passwd which would be blocked if Landlock were already enforced.
         drop_privileges(policy).map_err(|err| std::io::Error::other(err.to_string()))?;
-        sandbox::apply(policy, workdir).map_err(|err| std::io::Error::other(err.to_string()))?;
+
+        // Phase 2: Enforce the prepared Landlock ruleset + seccomp.
+        // restrict_self() does not require root.
+        #[cfg(target_os = "linux")]
+        if let Some(prepared) = prepared {
+            crate::sandbox::linux::enforce(prepared)
+                .map_err(|err| std::io::Error::other(err.to_string()))?;
+        }
+
+        #[cfg(not(target_os = "linux"))]
+        sandbox::apply(policy, None).map_err(|err| std::io::Error::other(err.to_string()))?;
+
         Ok(())
     }
 }


Test patch:
diff --git a/e2e/python/test_sandbox_landlock.py b/e2e/python/test_sandbox_landlock.py
new file mode 100644
index 000000000..d0c63fc2c
--- /dev/null
+++ b/e2e/python/test_sandbox_landlock.py
@@ -0,0 +1,207 @@
+# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Tests for Landlock filesystem sandboxing.
+
+Verifies that:
+- Landlock availability is logged via OCSF in sandbox logs
+- Read-only paths block writes but allow reads
+- Read-write paths allow both reads and writes
+- Paths outside the policy are blocked entirely
+- Paths the sandbox user owns but are not in the policy are still blocked
+- best_effort mode skips inaccessible paths without crashing
+
+These tests require a Linux host with Landlock support (kernel 5.13+).
+GitHub Actions Linux runners satisfy this requirement. Docker Desktop
+linuxkit kernels also support Landlock (ABI v5+).
+
+Related: https://github.com/NVIDIA/OpenShell/issues/803
+"""
+
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from openshell._proto import datamodel_pb2, sandbox_pb2
+
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+    from openshell import Sandbox
+
+
+# =============================================================================
+# Policy helpers
+# =============================================================================
+
+_LANDLOCK_FILESYSTEM = sandbox_pb2.FilesystemPolicy(
+    include_workdir=True,
+    read_only=["/usr", "/lib", "/etc", "/proc", "/dev/urandom"],
+    read_write=["/sandbox", "/tmp"],
+)
+_LANDLOCK_BEST_EFFORT = sandbox_pb2.LandlockPolicy(compatibility="best_effort")
+_LANDLOCK_PROCESS = sandbox_pb2.ProcessPolicy(
+    run_as_user="sandbox", run_as_group="sandbox"
+)
+
+
+def _landlock_policy(
+    *,
+    filesystem: sandbox_pb2.FilesystemPolicy | None = None,
+    landlock: sandbox_pb2.LandlockPolicy | None = None,
+) -> sandbox_pb2.SandboxPolicy:
+    return sandbox_pb2.SandboxPolicy(
+        version=1,
+        filesystem=filesystem or _LANDLOCK_FILESYSTEM,
+        landlock=landlock or _LANDLOCK_BEST_EFFORT,
+        process=_LANDLOCK_PROCESS,
+        network_policies={},
+    )
+
+
+# =============================================================================
+# Closures for exec_python (serialized into the sandbox by cloudpickle)
+# =============================================================================
+
+
+def _try_write():
+    """Return a closure that attempts to write a file and returns the result."""
+
+    def fn(path):
+        import os
+
+        try:
+            with open(os.path.join(path, ".landlock-test"), "w") as f:
+                f.write("test")
+            return "OK"
+        except PermissionError:
+            return "EPERM"
+        except OSError as e:
+            return f"ERROR:{e.errno}"
+
+    return fn
+
+
+def _try_read():
+    """Return a closure that attempts to read a directory listing."""
+
+    def fn(path):
+        import os
+
+        try:
+            entries = os.listdir(path)
+            return f"OK:{len(entries)}"
+        except PermissionError:
+            return "EPERM"
+        except OSError as e:
+            return f"ERROR:{e.errno}"
+
+    return fn
+
+
+def _check_user_owns_path():
+    """Return a closure that checks if the current user owns a path."""
+
+    def fn(path):
+        import os
+
+        try:
+            st = os.stat(path)
+            uid = os.getuid()
+            return f"owner:{st.st_uid} me:{uid} match:{st.st_uid == uid}"
+        except OSError as e:
+            return f"ERROR:{e}"
+
+    return fn
+
+
+# =============================================================================
+# Landlock enforcement tests
+# =============================================================================
+
+
+def test_landlock_blocks_write_to_read_only_path(
+    sandbox: Callable[..., Sandbox],
+) -> None:
+    """Writes to read-only paths (/usr) are blocked by Landlock."""
+    spec = datamodel_pb2.SandboxSpec(policy=_landlock_policy())
+    with sandbox(spec=spec, delete_on_exit=True) as sb:
+        result = sb.exec_python(_try_write(), args=("/usr",))
+        assert result.exit_code == 0, result.stderr
+        assert result.stdout.strip() == "EPERM", (
+            f"Expected write to /usr to be denied, got: {result.stdout.strip()}"
+        )
+
+
+def test_landlock_allows_write_to_read_write_path(
+    sandbox: Callable[..., Sandbox],
+) -> None:
+    """Writes to read-write paths (/tmp, /sandbox) are allowed."""
+    spec = datamodel_pb2.SandboxSpec(policy=_landlock_policy())
+    with sandbox(spec=spec, delete_on_exit=True) as sb:
+        for path in ["/tmp", "/sandbox"]:
+            result = sb.exec_python(_try_write(), args=(path,))
+            assert result.exit_code == 0, result.stderr
+            assert result.stdout.strip() == "OK", (
+                f"Expected write to {path} to succeed, got: {result.stdout.strip()}"
+            )
+
+
+def test_landlock_allows_read_on_read_only_path(
+    sandbox: Callable[..., Sandbox],
+) -> None:
+    """Reads from read-only paths (/usr, /etc) are allowed."""
+    spec = datamodel_pb2.SandboxSpec(policy=_landlock_policy())
+    with sandbox(spec=spec, delete_on_exit=True) as sb:
+        for path in ["/usr", "/etc"]:
+            result = sb.exec_python(_try_read(), args=(path,))
+            assert result.exit_code == 0, result.stderr
+            assert result.stdout.strip().startswith("OK:"), (
+                f"Expected read from {path} to succeed, got: {result.stdout.strip()}"
+            )
+
+
+def test_landlock_blocks_access_outside_policy(
+    sandbox: Callable[..., Sandbox],
+) -> None:
+    """Paths not listed in the policy (/opt, /root) are blocked entirely.
+
+    When Landlock is enforced, any path not covered by a rule is denied
+    by default. This is the fundamental allowlist property.
+    """
+    spec = datamodel_pb2.SandboxSpec(policy=_landlock_policy())
+    with sandbox(spec=spec, delete_on_exit=True) as sb:
+        for path in ["/opt", "/root"]:
+            result = sb.exec_python(_try_read(), args=(path,))
+            assert result.exit_code == 0, result.stderr
+            assert (
+                "EPERM" in result.stdout.strip() or "ERROR:" in result.stdout.strip()
+            ), (
+                f"Expected access to {path} (outside policy) to be denied, "
+                f"got: {result.stdout.strip()}"
+            )
+
+
+def test_landlock_blocks_user_owned_path_outside_policy(
+    sandbox: Callable[..., Sandbox],
+) -> None:
+    """Landlock blocks access to /home/sandbox even though the sandbox user owns it.
+
+    This is the key distinction between Landlock and Unix DAC permissions:
+    the sandbox user has filesystem ownership of /home/sandbox, but because
+    /home is not in the Landlock policy, access is denied. This confirms
+    Landlock is enforcing independently of Unix permissions.
+    """
+    spec = datamodel_pb2.SandboxSpec(policy=_landlock_policy())
+    with sandbox(spec=spec, delete_on_exit=True) as sb:
+        # Verify the sandbox user owns /home/sandbox
+        own_result = sb.exec_python(_check_user_owns_path(), args=("/home/sandbox",))
+        # The path might not exist in all images, so only assert Landlock
+        # enforcement if the path is present and owned by us.
+        if own_result.exit_code == 0 and "match:True" in own_result.stdout:
+            write_result = sb.exec_python(_try_write(), args=("/home/sandbox",))
+            assert write_result.exit_code == 0, write_result.stderr
+            assert write_result.stdout.strip() == "EPERM", (
+                "Expected Landlock to block write to /home/sandbox despite user ownership. "
+                f"Got: {write_result.stdout.strip()}"
+            )