1use std::path::Path;
2use std::process::Command;
3
4pub 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#[cfg(target_os = "linux")]
53mod landlock_sys {
54 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 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 pub(super) fn apply(ruleset: &super::LandlockRuleset) -> Result<bool, String> {
182 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
227pub 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}