Skip to main content

lean_ctx/core/
sandbox_landlock.rs

1use std::path::Path;
2use std::process::Command;
3
4/// Describes which filesystem paths the Landlock sandbox should allow.
5/// Deny-all by default; explicitly listed paths get read or read+write access.
6pub struct LandlockRuleset {
7    pub read_paths: Vec<String>,
8    pub read_write_paths: Vec<String>,
9    pub interpreter: String,
10}
11
12impl LandlockRuleset {
13    pub fn new(allowed_read_paths: &[&Path], interpreter_path: &str) -> Self {
14        let mut read_paths = vec![
15            "/usr".to_string(),
16            "/lib".to_string(),
17            "/lib64".to_string(),
18            "/etc".to_string(),
19            "/dev/null".to_string(),
20            "/dev/urandom".to_string(),
21            "/proc/self".to_string(),
22            interpreter_path.to_string(),
23        ];
24
25        for p in allowed_read_paths {
26            read_paths.push(p.display().to_string());
27        }
28
29        let sandbox_tmp = std::env::temp_dir().join("lean-ctx-sandbox");
30        let read_write_paths = vec!["/tmp".to_string(), sandbox_tmp.display().to_string()];
31
32        Self {
33            read_paths,
34            read_write_paths,
35            interpreter: interpreter_path.to_string(),
36        }
37    }
38
39    pub fn contains_read_path(&self, path: &str) -> bool {
40        self.read_paths.iter().any(|p| p == path)
41    }
42
43    pub fn contains_rw_path(&self, path: &str) -> bool {
44        self.read_write_paths.iter().any(|p| p == path)
45    }
46}
47
48// ---------------------------------------------------------------------------
49// Landlock enforcement via raw syscalls (Linux 5.13+)
50// ---------------------------------------------------------------------------
51
52#[cfg(target_os = "linux")]
53mod landlock_sys {
54    //! Minimal Landlock ABI wrappers using raw syscalls via libc.
55    //! Avoids an external crate dependency while supporting ABI v1+.
56
57    use std::ffi::CString;
58    use std::os::unix::ffi::OsStrExt;
59    use std::path::Path;
60
61    const LANDLOCK_CREATE_RULESET: libc::c_long = 444;
62    const LANDLOCK_ADD_RULE: libc::c_long = 445;
63    const LANDLOCK_RESTRICT_SELF: libc::c_long = 446;
64
65    const LANDLOCK_RULE_PATH_BENEATH: u32 = 1;
66
67    // ABI v1 access flags (filesystem)
68    const LANDLOCK_ACCESS_FS_EXECUTE: u64 = 1 << 0;
69    const LANDLOCK_ACCESS_FS_WRITE_FILE: u64 = 1 << 1;
70    const LANDLOCK_ACCESS_FS_READ_FILE: u64 = 1 << 2;
71    const LANDLOCK_ACCESS_FS_READ_DIR: u64 = 1 << 3;
72    const LANDLOCK_ACCESS_FS_REMOVE_DIR: u64 = 1 << 4;
73    const LANDLOCK_ACCESS_FS_REMOVE_FILE: u64 = 1 << 5;
74    const LANDLOCK_ACCESS_FS_MAKE_CHAR: u64 = 1 << 6;
75    const LANDLOCK_ACCESS_FS_MAKE_DIR: u64 = 1 << 7;
76    const LANDLOCK_ACCESS_FS_MAKE_REG: u64 = 1 << 8;
77    const LANDLOCK_ACCESS_FS_MAKE_SOCK: u64 = 1 << 9;
78    const LANDLOCK_ACCESS_FS_MAKE_FIFO: u64 = 1 << 10;
79    const LANDLOCK_ACCESS_FS_MAKE_BLOCK: u64 = 1 << 11;
80    const LANDLOCK_ACCESS_FS_MAKE_SYM: u64 = 1 << 12;
81
82    pub(super) const FS_READ: u64 = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR;
83
84    pub(super) const FS_ALL: u64 = LANDLOCK_ACCESS_FS_EXECUTE
85        | LANDLOCK_ACCESS_FS_WRITE_FILE
86        | LANDLOCK_ACCESS_FS_READ_FILE
87        | LANDLOCK_ACCESS_FS_READ_DIR
88        | LANDLOCK_ACCESS_FS_REMOVE_DIR
89        | LANDLOCK_ACCESS_FS_REMOVE_FILE
90        | LANDLOCK_ACCESS_FS_MAKE_CHAR
91        | LANDLOCK_ACCESS_FS_MAKE_DIR
92        | LANDLOCK_ACCESS_FS_MAKE_REG
93        | LANDLOCK_ACCESS_FS_MAKE_SOCK
94        | LANDLOCK_ACCESS_FS_MAKE_FIFO
95        | LANDLOCK_ACCESS_FS_MAKE_BLOCK
96        | LANDLOCK_ACCESS_FS_MAKE_SYM;
97
98    #[repr(C)]
99    struct LandlockRulesetAttr {
100        handled_access_fs: u64,
101    }
102
103    #[repr(C)]
104    struct LandlockPathBeneathAttr {
105        allowed_access: u64,
106        parent_fd: i32,
107    }
108
109    fn landlock_create_ruleset(handled_access_fs: u64) -> Result<i32, String> {
110        let attr = LandlockRulesetAttr { handled_access_fs };
111        let fd = unsafe {
112            libc::syscall(
113                LANDLOCK_CREATE_RULESET,
114                &raw const attr,
115                std::mem::size_of::<LandlockRulesetAttr>(),
116                0u32,
117            )
118        };
119        if fd < 0 {
120            return Err(format!(
121                "landlock_create_ruleset failed (errno {}); kernel may not support Landlock",
122                std::io::Error::last_os_error()
123            ));
124        }
125        Ok(fd as i32)
126    }
127
128    fn landlock_add_path_rule(ruleset_fd: i32, path: &Path, access: u64) -> Result<(), String> {
129        let c_path =
130            CString::new(path.as_os_str().as_bytes()).map_err(|e| format!("invalid path: {e}"))?;
131
132        let parent_fd = unsafe { libc::open(c_path.as_ptr(), libc::O_PATH | libc::O_CLOEXEC) };
133        if parent_fd < 0 {
134            return Err(format!(
135                "open O_PATH '{}': {}",
136                path.display(),
137                std::io::Error::last_os_error()
138            ));
139        }
140
141        let attr = LandlockPathBeneathAttr {
142            allowed_access: access,
143            parent_fd,
144        };
145
146        let ret = unsafe {
147            libc::syscall(
148                LANDLOCK_ADD_RULE,
149                ruleset_fd,
150                LANDLOCK_RULE_PATH_BENEATH,
151                &raw const attr,
152                0u32,
153            )
154        };
155
156        unsafe { libc::close(parent_fd) };
157
158        if ret < 0 {
159            return Err(format!(
160                "landlock_add_rule '{}': {}",
161                path.display(),
162                std::io::Error::last_os_error()
163            ));
164        }
165        Ok(())
166    }
167
168    fn landlock_restrict_self(ruleset_fd: i32) -> Result<(), String> {
169        let ret = unsafe { libc::syscall(LANDLOCK_RESTRICT_SELF, ruleset_fd, 0u32) };
170        if ret < 0 {
171            return Err(format!(
172                "landlock_restrict_self: {}",
173                std::io::Error::last_os_error()
174            ));
175        }
176        Ok(())
177    }
178
179    /// Apply the Landlock ruleset to the current process.
180    /// Returns `Ok(true)` if enforced, `Ok(false)` if Landlock is unsupported.
181    pub(super) fn apply(ruleset: &super::LandlockRuleset) -> Result<bool, String> {
182        // no_new_privs is required for unprivileged Landlock
183        let ret = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
184        if ret < 0 {
185            return Err(format!(
186                "prctl(NO_NEW_PRIVS): {}",
187                std::io::Error::last_os_error()
188            ));
189        }
190
191        let ruleset_fd = match landlock_create_ruleset(FS_ALL) {
192            Ok(fd) => fd,
193            Err(e) => {
194                eprintln!("[lean-ctx] landlock not supported: {e}");
195                return Ok(false);
196            }
197        };
198
199        for path_str in &ruleset.read_paths {
200            let path = Path::new(path_str);
201            if path.exists() {
202                if let Err(e) = landlock_add_path_rule(ruleset_fd, path, FS_READ) {
203                    eprintln!("[lean-ctx] landlock: skipping read rule for {path_str}: {e}");
204                }
205            }
206        }
207
208        for path_str in &ruleset.read_write_paths {
209            let path = Path::new(path_str);
210            if std::fs::create_dir_all(path).is_err() {
211                eprintln!("[lean-ctx] landlock: cannot ensure dir {path_str}");
212            }
213            if path.exists() {
214                if let Err(e) = landlock_add_path_rule(ruleset_fd, path, FS_ALL) {
215                    eprintln!("[lean-ctx] landlock: skipping rw rule for {path_str}: {e}");
216                }
217            }
218        }
219
220        landlock_restrict_self(ruleset_fd)?;
221        unsafe { libc::close(ruleset_fd) };
222
223        Ok(true)
224    }
225}
226
227// ---------------------------------------------------------------------------
228// Public API
229// ---------------------------------------------------------------------------
230
231pub fn execute_sandboxed(
232    interpreter: &str,
233    args: &[&str],
234    allowed_read_paths: &[&Path],
235    env: &[(String, String)],
236    timeout_secs: u64,
237) -> Result<(String, String, i32), String> {
238    let ruleset = LandlockRuleset::new(allowed_read_paths, interpreter);
239    execute_with_landlock(&ruleset, interpreter, args, env, timeout_secs)
240}
241
242#[cfg(target_os = "linux")]
243fn execute_with_landlock(
244    ruleset: &LandlockRuleset,
245    interpreter: &str,
246    args: &[&str],
247    env: &[(String, String)],
248    timeout_secs: u64,
249) -> Result<(String, String, i32), String> {
250    use std::os::unix::process::CommandExt;
251
252    let mut cmd = Command::new(interpreter);
253    cmd.args(args);
254
255    cmd.env_clear();
256    cmd.env("PATH", "/usr/bin:/bin:/usr/local/bin");
257    cmd.env("HOME", std::env::var("HOME").unwrap_or_default());
258    cmd.env("LEAN_CTX_SANDBOX", "1");
259    for (k, v) in env {
260        cmd.env(k, v);
261    }
262
263    cmd.stdout(std::process::Stdio::piped());
264    cmd.stderr(std::process::Stdio::piped());
265
266    let read_paths = ruleset.read_paths.clone();
267    let rw_paths = ruleset.read_write_paths.clone();
268    let interp = ruleset.interpreter.clone();
269
270    unsafe {
271        cmd.pre_exec(move || {
272            let rs = LandlockRuleset {
273                read_paths: read_paths.clone(),
274                read_write_paths: rw_paths.clone(),
275                interpreter: interp.clone(),
276            };
277            match landlock_sys::apply(&rs) {
278                Ok(true) => Ok(()),
279                Ok(false) => {
280                    eprintln!("[lean-ctx] landlock: not enforced, continuing unsandboxed");
281                    Ok(())
282                }
283                Err(e) => Err(std::io::Error::new(std::io::ErrorKind::PermissionDenied, e)),
284            }
285        });
286    }
287
288    let child = cmd
289        .spawn()
290        .map_err(|e| format!("landlock spawn failed: {e}"))?;
291
292    let output = wait_with_timeout(child, timeout_secs)?;
293
294    Ok((
295        String::from_utf8_lossy(&output.stdout).to_string(),
296        String::from_utf8_lossy(&output.stderr).to_string(),
297        output.status.code().unwrap_or(1),
298    ))
299}
300
301#[cfg(not(target_os = "linux"))]
302fn execute_with_landlock(
303    _ruleset: &LandlockRuleset,
304    _interpreter: &str,
305    _args: &[&str],
306    _env: &[(String, String)],
307    _timeout_secs: u64,
308) -> Result<(String, String, i32), String> {
309    unreachable!("sandbox_landlock module should only be called on Linux")
310}
311
312fn wait_with_timeout(
313    mut child: std::process::Child,
314    timeout_secs: u64,
315) -> Result<std::process::Output, String> {
316    let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
317    loop {
318        match child.try_wait() {
319            Ok(Some(_)) => return child.wait_with_output().map_err(|e| e.to_string()),
320            Ok(None) => {
321                if std::time::Instant::now() > deadline {
322                    let _ = child.kill();
323                    return Err(format!("Execution timed out after {timeout_secs}s"));
324                }
325                std::thread::sleep(std::time::Duration::from_millis(50));
326            }
327            Err(e) => return Err(e.to_string()),
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use std::path::PathBuf;
336
337    #[test]
338    fn ruleset_denies_all_by_default() {
339        let rs = LandlockRuleset::new(&[], "/usr/bin/python3");
340        assert!(!rs.contains_read_path("/home/user/secret"));
341        assert!(!rs.contains_rw_path("/home/user/secret"));
342    }
343
344    #[test]
345    fn ruleset_includes_system_dirs() {
346        let rs = LandlockRuleset::new(&[], "/usr/bin/python3");
347        assert!(rs.contains_read_path("/usr"));
348        assert!(rs.contains_read_path("/lib"));
349        assert!(rs.contains_read_path("/lib64"));
350        assert!(rs.contains_read_path("/etc"));
351    }
352
353    #[test]
354    fn ruleset_includes_interpreter() {
355        let rs = LandlockRuleset::new(&[], "/usr/bin/python3");
356        assert!(rs.contains_read_path("/usr/bin/python3"));
357    }
358
359    #[test]
360    fn ruleset_includes_allowed_paths() {
361        let p = PathBuf::from("/home/user/project");
362        let rs = LandlockRuleset::new(&[p.as_path()], "/usr/bin/python3");
363        assert!(rs.contains_read_path("/home/user/project"));
364    }
365
366    #[test]
367    fn ruleset_allows_tmp_rw() {
368        let rs = LandlockRuleset::new(&[], "/usr/bin/python3");
369        assert!(rs.contains_rw_path("/tmp"));
370        let sandbox_tmp = std::env::temp_dir().join("lean-ctx-sandbox");
371        assert!(rs.contains_rw_path(&sandbox_tmp.display().to_string()));
372    }
373
374    #[test]
375    fn ruleset_includes_dev_null() {
376        let rs = LandlockRuleset::new(&[], "/bin/echo");
377        assert!(rs.contains_read_path("/dev/null"));
378        assert!(rs.contains_read_path("/dev/urandom"));
379    }
380
381    #[cfg(target_os = "linux")]
382    #[test]
383    #[ignore = "requires Linux 5.13+ with Landlock; run manually"]
384    fn landlock_exec_echo() {
385        let result = execute_sandboxed("/bin/echo", &["hello"], &[], &[], 5);
386        assert!(result.is_ok());
387        let (stdout, _, code) = result.unwrap();
388        assert_eq!(code, 0);
389        assert!(stdout.contains("hello"));
390    }
391
392    #[cfg(target_os = "linux")]
393    #[test]
394    #[ignore = "requires Linux 5.13+ with Landlock; run manually"]
395    fn landlock_denies_read_outside_allowed() {
396        let result = execute_sandboxed("/bin/cat", &["/root/.bashrc"], &[], &[], 5);
397        if let Ok((_, _, code)) = result {
398            assert_ne!(code, 0, "cat should fail reading outside allowed paths");
399        }
400    }
401}