sandbox_linux.rs

  1//! Linux Landlock sandbox implementation.
  2//!
  3//! Uses the Landlock LSM to restrict filesystem access for the current process.
  4//! Must be called after fork(), before exec().
  5
  6use landlock::{
  7    ABI, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr,
  8    RulesetStatus,
  9};
 10use std::io::{Error, Result};
 11use std::path::Path;
 12
 13use crate::terminal_settings::SandboxConfig;
 14
 15const TARGET_ABI: ABI = ABI::V5;
 16
 17fn fs_read() -> AccessFs {
 18    AccessFs::ReadFile | AccessFs::ReadDir
 19}
 20
 21fn fs_read_exec() -> AccessFs {
 22    fs_read() | AccessFs::Execute
 23}
 24
 25fn fs_all() -> AccessFs {
 26    AccessFs::from_all(TARGET_ABI)
 27}
 28
 29fn add_path_rule(
 30    ruleset: landlock::RulesetCreated,
 31    path: &Path,
 32    access: AccessFs,
 33) -> std::result::Result<landlock::RulesetCreated, landlock::RulesetError> {
 34    match PathFd::new(path) {
 35        Ok(fd) => ruleset.add_rule(PathBeneath::new(fd, access)),
 36        Err(e) => {
 37            // Path doesn't exist — skip it (e.g., /opt/homebrew on non-Homebrew systems)
 38            log::debug!(
 39                "Landlock: skipping nonexistent path {}: {e}",
 40                path.display()
 41            );
 42            Ok(ruleset)
 43        }
 44    }
 45}
 46
 47/// Apply a Landlock sandbox to the current process.
 48/// Must be called after fork(), before exec().
 49pub fn apply_sandbox(config: &SandboxConfig) -> Result<()> {
 50    // PR_SET_NO_NEW_PRIVS is required before landlock_restrict_self.
 51    // It prevents the process from gaining privileges via setuid binaries.
 52    let ret = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
 53    if ret != 0 {
 54        return Err(Error::last_os_error());
 55    }
 56
 57    let mut ruleset = Ruleset::default()
 58        .handle_access(AccessFs::from_all(TARGET_ABI))
 59        .map_err(|e| Error::other(format!("landlock ruleset create: {e}")))?
 60        .create()
 61        .map_err(|e| Error::other(format!("landlock ruleset init: {e}")))?;
 62
 63    // System executable paths (read + execute)
 64    for path in &config.system_paths.executable {
 65        ruleset = add_path_rule(ruleset, path, fs_read_exec())
 66            .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
 67    }
 68
 69    // System read-only paths
 70    for path in &config.system_paths.read_only {
 71        ruleset = add_path_rule(ruleset, path, fs_read())
 72            .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
 73    }
 74
 75    // System read+write paths
 76    for path in &config.system_paths.read_write {
 77        ruleset = add_path_rule(ruleset, path, fs_all())
 78            .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
 79    }
 80
 81    // Project directory: full access
 82    ruleset = add_path_rule(ruleset, &config.project_dir, fs_all())
 83        .map_err(|e| Error::other(format!("landlock project rule: {e}")))?;
 84
 85    // User-configured paths
 86    for path in &config.additional_executable_paths {
 87        ruleset = add_path_rule(ruleset, path, fs_read_exec())
 88            .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
 89    }
 90    for path in &config.additional_read_only_paths {
 91        ruleset = add_path_rule(ruleset, path, fs_read())
 92            .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
 93    }
 94    for path in &config.additional_read_write_paths {
 95        ruleset = add_path_rule(ruleset, path, fs_all())
 96            .map_err(|e| Error::other(format!("landlock rule: {e}")))?;
 97    }
 98
 99    // Shell config dotfiles: read-only
100    if let Ok(home) = std::env::var("HOME") {
101        let home = Path::new(&home);
102        for dotfile in &[
103            ".bashrc",
104            ".bash_profile",
105            ".bash_login",
106            ".profile",
107            ".zshrc",
108            ".zshenv",
109            ".zprofile",
110            ".zlogin",
111            ".zlogout",
112            ".inputrc",
113            ".terminfo",
114            ".gitconfig",
115        ] {
116            let path = home.join(dotfile);
117            if path.exists() {
118                ruleset = add_path_rule(ruleset, &path, fs_read())
119                    .map_err(|e| Error::other(format!("landlock dotfile rule: {e}")))?;
120            }
121        }
122        let config_dir = home.join(".config");
123        if config_dir.exists() {
124            ruleset = add_path_rule(ruleset, &config_dir, fs_read())
125                .map_err(|e| Error::other(format!("landlock .config rule: {e}")))?;
126        }
127        // /proc/self for bash process substitution
128        let proc_self = Path::new("/proc/self");
129        if proc_self.exists() {
130            ruleset = add_path_rule(ruleset, proc_self, fs_read())
131                .map_err(|e| Error::other(format!("landlock /proc/self rule: {e}")))?;
132        }
133    }
134
135    let status = ruleset
136        .restrict_self()
137        .map_err(|e| Error::other(format!("landlock restrict_self: {e}")))?;
138
139    match status.ruleset {
140        RulesetStatus::FullyEnforced => {
141            log::info!("Landlock sandbox fully enforced");
142        }
143        RulesetStatus::PartiallyEnforced => {
144            log::warn!("Landlock sandbox partially enforced (older kernel ABI)");
145        }
146        RulesetStatus::NotEnforced => {
147            log::warn!("Landlock not supported on this kernel; running unsandboxed");
148        }
149    }
150
151    Ok(())
152}