Skip to main content

nucleus/security/
landlock.rs

1use crate::error::{NucleusError, Result};
2use landlock::{
3    Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, RulesetError,
4    RulesetStatus, ABI,
5};
6use std::path::PathBuf;
7use tracing::{debug, info, warn};
8
9/// Target ABI – covers up to Linux 6.12 features (Truncate, IoctlDev, Refer, etc.).
10/// The landlock crate gracefully degrades for older kernels.
11const TARGET_ABI: ABI = ABI::V5;
12
13/// Minimum Landlock ABI version required for production mode.
14///
15/// V3 adds LANDLOCK_ACCESS_FS_TRUNCATE which prevents silent data truncation
16/// that V1/V2 cannot control. This is the minimum we consider safe for
17/// production workloads.
18const MINIMUM_PRODUCTION_ABI: ABI = ABI::V3;
19
20#[derive(Debug, Clone, Copy)]
21#[allow(clippy::enum_variant_names)]
22enum PathAccessMode {
23    ReadOnly,
24    ReadExecute,
25    ReadWrite,
26    ReadWriteExecute,
27}
28
29/// Landlock filesystem access-control manager
30///
31/// Implements fine-grained, path-based filesystem restrictions as an additional
32/// defense layer on top of namespaces, capabilities, and seccomp.
33///
34/// Properties (matching Nucleus security invariants):
35/// - Irreversible: once restrict_self() is called, restrictions cannot be lifted
36/// - Stackable: layered with seccomp and capability dropping
37/// - Unprivileged: works in rootless mode
38pub struct LandlockManager {
39    applied: bool,
40    /// Additional paths to grant read+write access to (e.g. volume mounts).
41    extra_paths: Vec<(String, PathAccessMode)>,
42    /// Access policy for the first-class `/workspace` path.
43    workspace_access: PathAccessMode,
44}
45
46impl LandlockManager {
47    pub fn new() -> Self {
48        Self {
49            applied: false,
50            extra_paths: Vec::new(),
51            workspace_access: PathAccessMode::ReadWrite,
52        }
53    }
54
55    /// Register additional paths that need read+write access.
56    /// Used for volume mounts that aren't under the default allowed paths.
57    pub fn add_rw_path(&mut self, path: &str) {
58        self.extra_paths
59            .push((path.to_string(), PathAccessMode::ReadWrite));
60    }
61
62    /// Register additional paths that need read-only access.
63    pub fn add_ro_path(&mut self, path: &str) {
64        self.extra_paths
65            .push((path.to_string(), PathAccessMode::ReadOnly));
66    }
67
68    /// Register additional paths that need read+execute access.
69    pub fn add_read_exec_path(&mut self, path: &str) {
70        self.extra_paths
71            .push((path.to_string(), PathAccessMode::ReadExecute));
72    }
73
74    /// Register additional paths that need read+write+execute access.
75    pub fn add_rw_exec_path(&mut self, path: &str) {
76        self.extra_paths
77            .push((path.to_string(), PathAccessMode::ReadWriteExecute));
78    }
79
80    /// Configure Landlock access for `/workspace`.
81    pub fn set_workspace_access(&mut self, read_only: bool, allow_execute: bool) {
82        self.workspace_access = match (read_only, allow_execute) {
83            (true, false) => PathAccessMode::ReadOnly,
84            (true, true) => PathAccessMode::ReadExecute,
85            (false, false) => PathAccessMode::ReadWrite,
86            (false, true) => PathAccessMode::ReadWriteExecute,
87        };
88    }
89
90    /// Apply the container Landlock policy.
91    ///
92    /// Rules:
93    /// - `/` (root):         read-only traversal (ReadDir) so path resolution works
94    /// - `/bin`, `/usr`:     read + execute (for running agent binaries)
95    /// - `/lib`, `/lib64`:   read (shared libraries)
96    /// - `/etc`:             read (config / resolv.conf / nsswitch)
97    /// - `/dev`:             read (already minimal device nodes)
98    /// - `/proc`:            read (already mounted read-only)
99    /// - `/tmp`:             read + write + create + remove (agent scratch space)
100    /// - `/context`:         read-only (pre-populated agent data)
101    /// - `/workspace`:       workspace policy (read/write by default, execute opt-in)
102    /// - configured home:    registered by runtime as read/write, no execute
103    ///
104    /// Everything else is denied by the ruleset.
105    pub fn apply_container_policy(&mut self) -> Result<bool> {
106        self.apply_container_policy_with_mode(false)
107    }
108
109    /// Assert that the kernel supports at least the minimum Landlock ABI version
110    /// required for production workloads.
111    ///
112    /// Returns Ok(()) if the ABI is sufficient, or Err if the kernel is too old.
113    /// In best-effort mode, a too-old kernel is logged but not fatal.
114    pub fn assert_minimum_abi(&self, production_mode: bool) -> Result<()> {
115        // Probe the kernel's Landlock ABI version by attempting to create a ruleset
116        // with the minimum ABI's access rights. If the kernel doesn't support the
117        // minimum ABI, the ruleset will be NotEnforced or PartiallyEnforced.
118        let min_access = AccessFs::from_all(MINIMUM_PRODUCTION_ABI);
119        let target_access = AccessFs::from_all(TARGET_ABI);
120
121        // If the minimum access set equals the target, the kernel supports everything
122        // If the minimum is a subset, check that at least the minimum rights are present
123        if min_access != target_access {
124            info!(
125                "Landlock ABI: target={:?}, minimum_production={:?}",
126                TARGET_ABI, MINIMUM_PRODUCTION_ABI
127            );
128        }
129
130        // The actual enforcement check happens in build_and_restrict().
131        // Here we do a lightweight check: if the kernel supports the target ABI,
132        // it certainly supports the minimum. The landlock crate handles this
133        // gracefully, but we want an explicit assertion for production.
134        match Ruleset::default().handle_access(AccessFs::from_all(MINIMUM_PRODUCTION_ABI)) {
135            Ok(_) => {
136                info!("Landlock ABI >= V3 confirmed");
137                Ok(())
138            }
139            Err(e) => {
140                let msg = format!(
141                    "Kernel Landlock ABI is below minimum required version (V3): {}",
142                    e
143                );
144                if production_mode {
145                    Err(ll_err(e))
146                } else {
147                    warn!("{}", msg);
148                    Ok(())
149                }
150            }
151        }
152    }
153
154    /// Apply with configurable failure behavior.
155    ///
156    /// When `best_effort` is true, failures (e.g. kernel without Landlock) are
157    /// logged and execution continues.
158    pub fn apply_container_policy_with_mode(&mut self, best_effort: bool) -> Result<bool> {
159        if self.applied {
160            debug!("Landlock policy already applied, skipping");
161            return Ok(true);
162        }
163
164        info!("Applying Landlock filesystem policy");
165
166        match self.build_and_restrict() {
167            Ok(status) => match status {
168                RulesetStatus::FullyEnforced => {
169                    self.applied = true;
170                    info!("Landlock policy fully enforced");
171                    Ok(true)
172                }
173                RulesetStatus::PartiallyEnforced => {
174                    if best_effort {
175                        self.applied = true;
176                        info!(
177                            "Landlock policy partially enforced (kernel lacks some access rights)"
178                        );
179                        Ok(true)
180                    } else {
181                        Err(NucleusError::LandlockError(
182                            "Landlock policy only partially enforced; strict mode requires full target ABI support".to_string(),
183                        ))
184                    }
185                }
186                RulesetStatus::NotEnforced => {
187                    if best_effort {
188                        warn!("Landlock not enforced (kernel does not support Landlock)");
189                        Ok(false)
190                    } else {
191                        Err(NucleusError::LandlockError(
192                            "Landlock not enforced (kernel does not support Landlock)".to_string(),
193                        ))
194                    }
195                }
196            },
197            Err(e) => {
198                if best_effort {
199                    warn!(
200                        "Failed to apply Landlock policy: {} (continuing without Landlock)",
201                        e
202                    );
203                    Ok(false)
204                } else {
205                    Err(e)
206                }
207            }
208        }
209    }
210
211    /// Apply an execute-only allowlist for host-side supervisor processes.
212    ///
213    /// This policy handles only `LANDLOCK_ACCESS_FS_EXECUTE`, leaving normal
214    /// read/write access untouched. It is intended for runtimes like gVisor
215    /// that need a narrow post-namespace executable allowlist while still
216    /// blocking arbitrary host executable and setuid-wrapper execs after the
217    /// supervisor has entered its setup namespace.
218    pub fn apply_execute_allowlist_policy(
219        &mut self,
220        allowed_roots: &[PathBuf],
221        best_effort: bool,
222    ) -> Result<bool> {
223        if self.applied {
224            debug!("Landlock execute allowlist already applied, skipping");
225            return Ok(true);
226        }
227
228        info!(
229            allowed_roots = ?allowed_roots,
230            "Applying Landlock execute allowlist policy"
231        );
232
233        match self.build_execute_allowlist_and_restrict(allowed_roots) {
234            Ok(status) => match status {
235                RulesetStatus::FullyEnforced => {
236                    self.applied = true;
237                    info!("Landlock execute allowlist fully enforced");
238                    Ok(true)
239                }
240                RulesetStatus::PartiallyEnforced => {
241                    if best_effort {
242                        self.applied = true;
243                        info!("Landlock execute allowlist partially enforced");
244                        Ok(true)
245                    } else {
246                        Err(NucleusError::LandlockError(
247                            "Landlock execute allowlist only partially enforced; strict mode requires full enforcement".to_string(),
248                        ))
249                    }
250                }
251                RulesetStatus::NotEnforced => {
252                    if best_effort {
253                        warn!("Landlock execute allowlist not enforced");
254                        Ok(false)
255                    } else {
256                        Err(NucleusError::LandlockError(
257                            "Landlock execute allowlist not enforced".to_string(),
258                        ))
259                    }
260                }
261            },
262            Err(e) => {
263                if best_effort {
264                    warn!(
265                        "Failed to apply Landlock execute allowlist: {} (continuing without it)",
266                        e
267                    );
268                    Ok(false)
269                } else {
270                    Err(e)
271                }
272            }
273        }
274    }
275
276    /// Build the ruleset and call restrict_self().
277    fn build_and_restrict(&self) -> Result<RulesetStatus> {
278        let access_all = AccessFs::from_all(TARGET_ABI);
279        let access_read = AccessFs::from_read(TARGET_ABI);
280
281        // Read + execute for binary paths
282        let access_read_exec = access_read | AccessFs::Execute;
283
284        // Write access set for /tmp – full read+write but no execute.
285        // Executing from /tmp is a common attack pattern (drop-and-exec).
286        let mut access_tmp = access_all;
287        access_tmp.remove(AccessFs::Execute);
288        let access_rw_exec = access_tmp | AccessFs::Execute;
289
290        let mut ruleset = Ruleset::default()
291            .handle_access(access_all)
292            .map_err(ll_err)?
293            .create()
294            .map_err(ll_err)?;
295
296        // Root directory: minimal traversal only
297        // We add ReadDir so that path resolution through / works
298        if let Ok(fd) = PathFd::new("/") {
299            ruleset = ruleset
300                .add_rule(PathBeneath::new(fd, AccessFs::ReadDir))
301                .map_err(ll_err)?;
302        }
303
304        // M13: Mandatory paths that must exist for a functional container.
305        // Warn (or error in strict mode) when these are missing.
306        const MANDATORY_PATHS: &[&str] = &["/bin", "/usr", "/lib", "/etc"];
307        for path in MANDATORY_PATHS {
308            if !std::path::Path::new(path).exists() {
309                warn!(
310                    "Landlock: mandatory path {} does not exist; container may not function correctly",
311                    path
312                );
313            }
314        }
315
316        // Binary paths: read + execute
317        for path in &["/bin", "/usr", "/sbin"] {
318            if let Ok(fd) = PathFd::new(path) {
319                ruleset = ruleset
320                    .add_rule(PathBeneath::new(fd, access_read_exec))
321                    .map_err(ll_err)?;
322            }
323        }
324
325        // Shared libraries: read
326        for path in &["/lib", "/lib64", "/lib32"] {
327            if let Ok(fd) = PathFd::new(path) {
328                ruleset = ruleset
329                    .add_rule(PathBeneath::new(fd, access_read))
330                    .map_err(ll_err)?;
331            }
332        }
333
334        // Config/device/proc: read
335        for path in &["/etc", "/dev", "/proc"] {
336            if let Ok(fd) = PathFd::new(path) {
337                ruleset = ruleset
338                    .add_rule(PathBeneath::new(fd, access_read))
339                    .map_err(ll_err)?;
340            }
341        }
342
343        // /dev/shm: read+write for POSIX shared memory (shm_open).
344        // Required by PostgreSQL, Redis, and other programs.
345        // No execute – same policy as /tmp.
346        if let Ok(fd) = PathFd::new("/dev/shm") {
347            ruleset = ruleset
348                .add_rule(PathBeneath::new(fd, access_tmp))
349                .map_err(ll_err)?;
350        }
351
352        // /tmp: full read+write+create+remove
353        if let Ok(fd) = PathFd::new("/tmp") {
354            ruleset = ruleset
355                .add_rule(PathBeneath::new(fd, access_tmp))
356                .map_err(ll_err)?;
357        }
358
359        // /nix/store: read + execute (NixOS binaries and libraries)
360        if let Ok(fd) = PathFd::new("/nix/store") {
361            ruleset = ruleset
362                .add_rule(PathBeneath::new(fd, access_read_exec))
363                .map_err(ll_err)?;
364        }
365
366        // /run/secrets: read-only (container secrets mounted on tmpfs)
367        if let Ok(fd) = PathFd::new("/run/secrets") {
368            ruleset = ruleset
369                .add_rule(PathBeneath::new(fd, access_read))
370                .map_err(ll_err)?;
371        }
372
373        // /context: read-only (agent data)
374        if let Ok(fd) = PathFd::new("/context") {
375            ruleset = ruleset
376                .add_rule(PathBeneath::new(fd, access_read))
377                .map_err(ll_err)?;
378        }
379
380        // /workspace: stable agent workspace. Writable by default, but not
381        // executable unless --workspace-exec explicitly opts into that risk.
382        if let Ok(fd) = PathFd::new("/workspace") {
383            let access = Self::access_for_mode(
384                self.workspace_access,
385                access_read,
386                access_read_exec,
387                access_tmp,
388                access_rw_exec,
389            );
390            ruleset = ruleset
391                .add_rule(PathBeneath::new(fd, access))
392                .map_err(ll_err)?;
393        }
394
395        // Volume mounts and other dynamically registered paths.
396        for (path, mode) in &self.extra_paths {
397            if let Ok(fd) = PathFd::new(path) {
398                debug!("Landlock: granting {:?} access to path {:?}", mode, path);
399                let access = Self::access_for_mode(
400                    *mode,
401                    access_read,
402                    access_read_exec,
403                    access_tmp,
404                    access_rw_exec,
405                );
406                ruleset = ruleset
407                    .add_rule(PathBeneath::new(fd, access))
408                    .map_err(ll_err)?;
409            }
410        }
411
412        let status = ruleset.restrict_self().map_err(ll_err)?;
413        Ok(status.ruleset)
414    }
415
416    fn access_for_mode(
417        mode: PathAccessMode,
418        access_read: landlock::BitFlags<AccessFs>,
419        access_read_exec: landlock::BitFlags<AccessFs>,
420        access_read_write: landlock::BitFlags<AccessFs>,
421        access_read_write_exec: landlock::BitFlags<AccessFs>,
422    ) -> landlock::BitFlags<AccessFs> {
423        match mode {
424            PathAccessMode::ReadOnly => access_read,
425            PathAccessMode::ReadExecute => access_read_exec,
426            PathAccessMode::ReadWrite => access_read_write,
427            PathAccessMode::ReadWriteExecute => access_read_write_exec,
428        }
429    }
430
431    fn build_execute_allowlist_and_restrict(
432        &self,
433        allowed_roots: &[PathBuf],
434    ) -> Result<RulesetStatus> {
435        let access_execute = AccessFs::Execute;
436        let mut ruleset = Ruleset::default()
437            .handle_access(access_execute)
438            .map_err(ll_err)?
439            .create()
440            .map_err(ll_err)?
441            // The gVisor systrap supervisor re-execs runsc after this policy is
442            // installed. Do not silently reintroduce no_new_privs here; callers
443            // that need this host-side allowlist must already be in a context
444            // where Landlock can be restricted without it.
445            .set_no_new_privs(false);
446
447        let mut added_rules = 0usize;
448        for root in allowed_roots {
449            let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.clone());
450            match PathFd::new(canonical.as_path()) {
451                Ok(fd) => {
452                    ruleset = ruleset
453                        .add_rule(PathBeneath::new(fd, access_execute))
454                        .map_err(ll_err)?;
455                    added_rules += 1;
456                }
457                Err(err) => {
458                    warn!(
459                        "Landlock execute allowlist skipped {:?}: {}",
460                        canonical, err
461                    );
462                }
463            }
464        }
465        if added_rules == 0 {
466            return Err(NucleusError::LandlockError(
467                "Landlock execute allowlist has no valid executable roots".to_string(),
468            ));
469        }
470
471        let status = ruleset.restrict_self().map_err(ll_err)?;
472        Ok(status.ruleset)
473    }
474
475    /// Check if Landlock policy has been applied
476    pub fn is_applied(&self) -> bool {
477        self.applied
478    }
479}
480
481impl Default for LandlockManager {
482    fn default() -> Self {
483        Self::new()
484    }
485}
486
487/// Convert a landlock RulesetError into NucleusError::LandlockError
488fn ll_err(e: RulesetError) -> NucleusError {
489    NucleusError::LandlockError(e.to_string())
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    #[test]
497    fn test_landlock_manager_initial_state() {
498        let mgr = LandlockManager::new();
499        assert!(!mgr.is_applied());
500    }
501
502    #[test]
503    fn test_apply_idempotent() {
504        let mut mgr = LandlockManager::new();
505        // Best-effort so it succeeds even without Landlock support
506        let _ = mgr.apply_container_policy_with_mode(true);
507        // Second call should be a no-op
508        let result = mgr.apply_container_policy_with_mode(true);
509        assert!(result.is_ok());
510    }
511
512    #[test]
513    fn test_best_effort_on_unsupported_kernel() {
514        let mut mgr = LandlockManager::new();
515        // Should not error even if kernel has no Landlock
516        let result = mgr.apply_container_policy_with_mode(true);
517        assert!(result.is_ok());
518    }
519
520    /// Extract the body of a function from source text by brace-matching,
521    /// avoiding fragile hardcoded character-window offsets (SEC-MED-03).
522    fn extract_fn_body<'a>(source: &'a str, fn_signature: &str) -> &'a str {
523        let fn_start = source
524            .find(fn_signature)
525            .unwrap_or_else(|| panic!("function '{}' not found in source", fn_signature));
526        let after = &source[fn_start..];
527        let open = after
528            .find('{')
529            .unwrap_or_else(|| panic!("no opening brace found for '{}'", fn_signature));
530        let mut depth = 0u32;
531        let mut end = open;
532        for (i, ch) in after[open..].char_indices() {
533            match ch {
534                '{' => depth += 1,
535                '}' => {
536                    depth -= 1;
537                    if depth == 0 {
538                        end = open + i + 1;
539                        break;
540                    }
541                }
542                _ => {}
543            }
544        }
545        &after[..end]
546    }
547
548    #[test]
549    fn test_policy_covers_nix_store_and_secrets() {
550        // Landlock policy must include rules for /nix/store (read+exec) and
551        // /run/secrets (read) so NixOS binaries can execute and secrets are readable.
552        // NOTE: The Landlock API does not expose the ruleset for inspection, so
553        // this remains a source-text check – but uses brace-matched function
554        // body extraction instead of hardcoded char offsets.
555        let source = include_str!("landlock.rs");
556        let fn_body = extract_fn_body(source, "fn build_and_restrict");
557        assert!(
558            fn_body.contains("\"/nix/store\"") || fn_body.contains("\"/nix\""),
559            "Landlock build_and_restrict must include a rule for /nix/store or /nix"
560        );
561        assert!(
562            fn_body.contains("\"/run/secrets\"") || fn_body.contains("\"/run\""),
563            "Landlock build_and_restrict must include a rule for /run/secrets"
564        );
565    }
566
567    #[test]
568    fn test_policy_covers_workspace_without_default_execute() {
569        let source = include_str!("landlock.rs");
570        let fn_body = extract_fn_body(source, "fn build_and_restrict");
571        assert!(
572            fn_body.contains("\"/workspace\""),
573            "Landlock build_and_restrict must include /workspace"
574        );
575        assert!(
576            source.contains("set_workspace_access"),
577            "LandlockManager must expose explicit workspace access selection"
578        );
579    }
580
581    #[test]
582    fn test_tmp_access_excludes_execute() {
583        // L-5: /tmp should have read+write but NOT execute permission.
584        // Verify at the type level that our access_tmp definition
585        // does not include Execute.
586        let access_all = AccessFs::from_all(TARGET_ABI);
587        let mut access_tmp = access_all;
588        access_tmp.remove(AccessFs::Execute);
589        assert!(!access_tmp.contains(AccessFs::Execute));
590        // But it should still have write capabilities
591        assert!(access_tmp.contains(AccessFs::WriteFile));
592        assert!(access_tmp.contains(AccessFs::RemoveFile));
593    }
594
595    #[test]
596    fn test_execute_allowlist_handles_only_execute() {
597        let source = include_str!("landlock.rs");
598        let fn_body = extract_fn_body(source, "fn build_execute_allowlist_and_restrict");
599        assert!(
600            fn_body.contains("let access_execute = AccessFs::Execute"),
601            "execute allowlist must handle only execute access"
602        );
603        assert!(
604            fn_body.contains("handle_access(access_execute)"),
605            "execute allowlist must not handle read/write filesystem rights"
606        );
607        assert!(
608            !fn_body.contains("from_all"),
609            "execute allowlist must not accidentally become a broad filesystem policy"
610        );
611    }
612
613    #[test]
614    fn test_execute_allowlist_disables_no_new_privs_for_gvisor_supervisor() {
615        let source = include_str!("landlock.rs");
616        let fn_body = extract_fn_body(source, "fn build_execute_allowlist_and_restrict");
617        assert!(
618            fn_body.contains(".set_no_new_privs(false)"),
619            "gVisor supervisor execute allowlist must not force no_new_privs"
620        );
621    }
622
623    #[test]
624    fn test_container_policy_keeps_default_no_new_privs() {
625        let source = include_str!("landlock.rs");
626        let fn_body = extract_fn_body(source, "fn build_and_restrict");
627        assert!(
628            !fn_body.contains(".set_no_new_privs(false)"),
629            "container Landlock policy must retain the landlock crate default no_new_privs setting"
630        );
631    }
632
633    #[test]
634    fn test_not_enforced_returns_error_in_strict_mode() {
635        // SEC-11: When best_effort=false, NotEnforced must return Err, not Ok(false)
636        let source = include_str!("landlock.rs");
637        let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
638        // Find the NotEnforced match arm within the function body
639        let not_enforced_start = fn_body
640            .find("NotEnforced")
641            .expect("function must handle NotEnforced status");
642        // Search from NotEnforced to the next match arm ('=>' after a '}')
643        let rest = &fn_body[not_enforced_start..];
644        let arm_end = rest
645            .find("RestrictionStatus::")
646            .unwrap_or(rest.len().min(500));
647        let not_enforced_block = &rest[..arm_end];
648        assert!(
649            not_enforced_block.contains("best_effort") && not_enforced_block.contains("Err"),
650            "NotEnforced must return Err when best_effort=false. Block: {}",
651            not_enforced_block
652        );
653    }
654
655    #[test]
656    fn test_partially_enforced_returns_error_in_strict_mode() {
657        let source = include_str!("landlock.rs");
658        let fn_body = extract_fn_body(source, "fn apply_container_policy_with_mode");
659        let partial_start = fn_body
660            .find("PartiallyEnforced")
661            .expect("function must handle PartiallyEnforced status");
662        let rest = &fn_body[partial_start..];
663        let arm_end = rest.find("NotEnforced").unwrap_or(rest.len().min(500));
664        let partial_block = &rest[..arm_end];
665        assert!(
666            partial_block.contains("best_effort") && partial_block.contains("Err"),
667            "PartiallyEnforced must return Err when best_effort=false. Block: {}",
668            partial_block
669        );
670    }
671}