1use std::path::{Path, PathBuf};
2
3const IDE_CONFIG_DIRS: &[&str] = &[
4 ".lean-ctx",
5 ".cursor",
6 ".claude",
7 ".codex",
8 ".codeium",
9 ".gemini",
10 ".qwen",
11 ".trae",
12 ".kiro",
13 ".verdent",
14 ".pi",
15 ".amp",
16 ".aider",
17 ".continue",
18 ".codebuddy",
19];
20
21pub fn expand_user_path(raw: &str) -> PathBuf {
27 let mut s = raw.to_string();
28
29 if (s == "~" || s.starts_with("~/"))
30 && let Some(home) = dirs::home_dir()
31 {
32 s = format!("{}{}", home.to_string_lossy(), &s[1..]);
33 }
34
35 while let Some(start) = s.find('$') {
36 let rest = &s[start + 1..];
37 let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
38 match stripped.find('}') {
39 Some(end) => (stripped[..end].to_string(), end + 3),
40 None => break,
41 }
42 } else {
43 let end = rest
44 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
45 .unwrap_or(rest.len());
46 (rest[..end].to_string(), end + 1)
47 };
48 if name.is_empty() {
49 break;
50 }
51 if let Ok(val) = std::env::var(&name) {
52 s.replace_range(start..start + token_len, &val);
53 } else {
54 tracing::warn!(
55 "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
56 );
57 break;
58 }
59 }
60
61 PathBuf::from(s)
62}
63
64pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
65 let mut out = Vec::new();
66 let cfg = crate::core::config::Config::load();
67
68 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
74 out.push(canonicalize_secure(&data_dir));
75 }
76
77 if let Some(home) = dirs::home_dir() {
78 let ide_dirs_allowed = cfg.allow_ide_config_dirs
79 || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
80 out.extend(home_allow_dirs(&home, ide_dirs_allowed));
81 }
82
83 for p in &cfg.allow_paths {
84 out.push(canonicalize_secure(&expand_user_path(p)));
85 }
86 for p in &cfg.extra_roots {
87 out.push(canonicalize_secure(&expand_user_path(p)));
88 }
89
90 let v = std::env::var("LCTX_ALLOW_PATH")
93 .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
94 .unwrap_or_default();
95 if !v.trim().is_empty() {
96 for p in std::env::split_paths(&v) {
97 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
98 }
99 }
100
101 let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
102 if !extra.trim().is_empty() {
103 for p in std::env::split_paths(&extra) {
104 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
105 }
106 }
107
108 out.extend(canonicalized_roots(
113 &cfg.read_only_roots,
114 "LEAN_CTX_READ_ONLY_ROOTS",
115 ));
116
117 out
118}
119
120fn canonicalized_roots(config_entries: &[String], env_var: &str) -> Vec<PathBuf> {
125 let mut out = Vec::new();
126 for p in config_entries {
127 out.push(canonicalize_secure(&expand_user_path(p)));
128 }
129 let v = std::env::var(env_var).unwrap_or_default();
130 if !v.trim().is_empty() {
131 for p in std::env::split_paths(&v) {
132 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
133 }
134 }
135 out
136}
137
138pub fn read_only_roots_from_env_and_config() -> Vec<PathBuf> {
143 let cfg = crate::core::config::Config::load();
144 canonicalized_roots(&cfg.read_only_roots, "LEAN_CTX_READ_ONLY_ROOTS")
145}
146
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct JailRelaxation {
155 pub source: &'static str,
157 pub detail: &'static str,
159}
160
161fn env_is_set(var: &str) -> bool {
162 std::env::var(var).is_ok_and(|v| !v.trim().is_empty())
163}
164
165#[must_use]
169pub fn active_relaxations() -> Vec<JailRelaxation> {
170 let mut out = Vec::new();
171
172 if cfg!(feature = "no-jail") {
173 out.push(JailRelaxation {
174 source: "no-jail (build feature)",
175 detail: "path jail compiled out — every tool path is allowed",
176 });
177 }
178
179 if crate::core::config::Config::load().path_jail == Some(false) {
180 out.push(JailRelaxation {
181 source: "path_jail = false (config.toml)",
182 detail: "path jail disabled — every tool path is allowed",
183 });
184 }
185
186 if env_is_set("LEAN_CTX_ALLOW_PATH") || env_is_set("LCTX_ALLOW_PATH") {
187 out.push(JailRelaxation {
188 source: "LEAN_CTX_ALLOW_PATH",
189 detail: "widens the read/write allow-list beyond the project root",
190 });
191 }
192
193 if env_is_set("LEAN_CTX_EXTRA_ROOTS") {
194 out.push(JailRelaxation {
195 source: "LEAN_CTX_EXTRA_ROOTS",
196 detail: "adds extra accessible roots beyond the project root",
197 });
198 }
199
200 let ide_env = std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
201 if ide_env || crate::core::config::Config::load().allow_ide_config_dirs {
202 out.push(JailRelaxation {
203 source: if ide_env {
204 "LEAN_CTX_ALLOW_IDE_DIRS=1"
205 } else {
206 "allow_ide_config_dirs = true (config.toml)"
207 },
208 detail: "exposes ~/.cursor, ~/.claude, … (other agents' sessions/credentials) to tools",
209 });
210 }
211
212 out
213}
214
215pub fn warn_if_relaxed() {
219 for relaxation in active_relaxations() {
220 tracing::warn!(
221 "[SECURITY] path jail relaxed via {}: {} — intended for trusted local use only",
222 relaxation.source,
223 relaxation.detail
224 );
225 }
226}
227
228pub fn is_read_only_path(candidate: &Path) -> bool {
239 let roots = read_only_roots_from_env_and_config();
240 if roots.is_empty() {
241 return false;
242 }
243
244 let base = match canonicalize_existing_ancestor(candidate) {
248 Some((base, remainder)) => {
249 let mut p = base;
250 for part in remainder.iter().rev() {
251 p.push(part);
252 }
253 p
254 }
255 None => canonicalize_or_self(candidate),
256 };
257
258 roots.iter().any(|r| is_under_prefix(&base, r))
259}
260
261pub fn enforce_writable(candidate: &Path) -> Result<(), String> {
270 if is_read_only_path(candidate) {
271 return Err(format!(
272 "path is inside a read-only root — writes are denied (read_only_roots): {}",
273 candidate.display()
274 ));
275 }
276 Ok(())
277}
278
279fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
285 let mut out = Vec::new();
286 for dir in IDE_CONFIG_DIRS {
287 if *dir != ".lean-ctx" && !ide_dirs_allowed {
288 continue;
289 }
290 let p = home.join(dir);
291 if p.exists() {
292 out.push(canonicalize_secure(&p));
293 }
294 }
295 out
296}
297
298fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
299 path.starts_with(prefix)
300}
301
302pub fn canonicalize_or_self(path: &Path) -> PathBuf {
306 super::pathutil::safe_canonicalize_bounded(path, 2000)
307}
308
309fn canonicalize_secure(path: &Path) -> PathBuf {
314 super::pathutil::canonicalize_secure_bounded(path, 2000)
315}
316
317fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
318 let mut cur = path.to_path_buf();
319 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
320 loop {
321 if cur.exists() {
322 return Some((canonicalize_secure(&cur), remainder));
323 }
324 let name = cur.file_name()?.to_os_string();
325 remainder.push(name);
326 if !cur.pop() {
327 return None;
328 }
329 }
330}
331
332pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
333 jail_path_with_roots(candidate, jail_root, &[])
334}
335
336pub fn jail_path_with_roots(
346 candidate: &Path,
347 jail_root: &Path,
348 extra_roots: &[String],
349) -> Result<PathBuf, String> {
350 if candidate.to_string_lossy().as_bytes().contains(&0) {
351 return Err("path contains null byte".to_string());
352 }
353
354 #[cfg(feature = "no-jail")]
355 {
356 let _ = (jail_root, extra_roots);
357 return Ok(canonicalize_or_self(candidate));
358 }
359
360 #[allow(unreachable_code)]
361 {
362 let cfg = crate::core::config::Config::load();
363 if cfg.path_jail == Some(false) {
364 return Ok(canonicalize_or_self(candidate));
365 }
366
367 let root = canonicalize_secure(jail_root);
368
369 let resolved: PathBuf;
374 let candidate: &Path = if candidate.is_absolute() {
375 candidate
376 } else {
377 resolved = root.join(candidate);
378 resolved.as_path()
379 };
380
381 let mut allow = allow_paths_from_env_and_config();
382 allow.extend(
384 extra_roots
385 .iter()
386 .filter(|r| !r.is_empty())
387 .map(|r| canonicalize_secure(Path::new(r))),
388 );
389
390 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
391 format!(
392 "path does not exist and has no existing ancestor: {}",
393 candidate.display()
394 )
395 })?;
396
397 let allowed =
398 is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
399
400 #[cfg(windows)]
401 let allowed = allowed || is_under_prefix_windows(&base, &root);
402
403 if !allowed {
404 let base_msg = format!(
405 "path escapes project root: {} (root: {})",
406 candidate.display(),
407 root.display(),
408 );
409 let mut hint = if crate::core::protocol::meta_visible() {
410 format!(
411 ". Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
412 candidate.parent().unwrap_or(candidate).display()
413 )
414 } else {
415 String::new()
416 };
417 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
421 hint.push_str(". ");
422 hint.push_str(¬ice);
423 }
424 if let Some(missing) = crate::core::config::Config::missing_config_path() {
428 hint.push_str(&format!(
429 ". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
430 allow_paths edit in a config.toml elsewhere is not read; \
431 `lean-ctx doctor` shows the path in effect",
432 missing.display()
433 ));
434 }
435 return Err(format!("{base_msg}{hint}"));
436 }
437
438 #[cfg(windows)]
439 reject_symlink_on_windows(candidate)?;
440
441 let mut out = base;
442 for part in remainder.iter().rev() {
443 out.push(part);
444 }
445
446 if out.exists() {
449 let final_canon = canonicalize_secure(&out);
450 let final_ok = is_under_prefix(&final_canon, &root)
451 || allow.iter().any(|p| is_under_prefix(&final_canon, p));
452 #[cfg(windows)]
453 let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
454 if !final_ok {
455 return Err(format!(
456 "post-canonicalize jail escape detected: {} resolves to {}",
457 candidate.display(),
458 final_canon.display()
459 ));
460 }
461 }
462
463 Ok(out)
464 }
465}
466
467#[cfg(windows)]
468fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
469 let path_str = normalize_windows_path(&path.to_string_lossy());
470 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
471 path_str.starts_with(&prefix_str)
472}
473
474#[cfg(windows)]
475fn normalize_windows_path(s: &str) -> String {
476 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
477 stripped.to_lowercase().replace('/', "\\")
478}
479
480#[cfg(windows)]
481fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
482 if let Ok(meta) = std::fs::symlink_metadata(path) {
483 if super::pathutil::is_symlink_or_reparse(&meta) {
486 return Err(format!(
487 "symlink not allowed in jailed path: {}",
488 path.display()
489 ));
490 }
491 }
492 Ok(())
493}
494
495#[cfg(test)]
496mod tests {
497 use super::*;
498
499 #[cfg(not(feature = "no-jail"))]
500 #[test]
501 fn rejects_path_outside_root() {
502 let _iso = crate::core::data_dir::isolated_data_dir();
507 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
508 let tmp = tempfile::tempdir().unwrap();
509 let root = tmp.path().join("root");
510 let other = tmp.path().join("other");
511 std::fs::create_dir_all(&root).unwrap();
512 std::fs::create_dir_all(&other).unwrap();
513 std::fs::write(root.join("a.txt"), "ok").unwrap();
514 std::fs::write(other.join("b.txt"), "no").unwrap();
515
516 let ok = jail_path(&root.join("a.txt"), &root);
517 assert!(ok.is_ok());
518
519 let bad = jail_path(&other.join("b.txt"), &root);
520 assert!(bad.is_err());
521 }
522
523 #[cfg(not(feature = "no-jail"))]
530 #[test]
531 fn read_only_roots_deny_writes_but_allow_reads() {
532 let _iso = crate::core::data_dir::isolated_data_dir();
533
534 let tmp = tempfile::tempdir().unwrap();
535 let project = tmp.path().join("project");
536 let refrepo = tmp.path().join("refrepo");
537 std::fs::create_dir_all(&project).unwrap();
538 std::fs::create_dir_all(refrepo.join("sub")).unwrap();
539 std::fs::write(refrepo.join("lib.rs"), "pub fn x() {}\n").unwrap();
540
541 let ro_canon = canonicalize_secure(&refrepo);
544 crate::test_env::set_var(
545 "LEAN_CTX_READ_ONLY_ROOTS",
546 ro_canon.to_string_lossy().as_ref(),
547 );
548
549 let existing = refrepo.join("lib.rs");
550 let new_file = refrepo.join("sub").join("new.rs");
551 let proj_file = project.join("main.rs");
552
553 let read_existing = jail_path(&existing, &project);
555 let deny_existing = enforce_writable(&existing);
556 let deny_new = enforce_writable(&new_file);
557 let allow_project = enforce_writable(&proj_file);
558 let ro_existing = is_read_only_path(&existing);
559 let ro_project = is_read_only_path(&proj_file);
560
561 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
562
563 assert!(
564 deny_existing.is_err(),
565 "write to an existing file in a read-only root must be denied"
566 );
567 assert!(
568 deny_new.is_err(),
569 "creating a new file in a read-only root must be denied"
570 );
571 assert!(
572 allow_project.is_ok(),
573 "writes into the project root must stay allowed: {allow_project:?}"
574 );
575 assert!(
576 read_existing.is_ok(),
577 "reads inside a read-only root must resolve (read allow-list): {read_existing:?}"
578 );
579 assert!(ro_existing, "the file is inside the read-only root");
580 assert!(!ro_project, "the project file is not read-only");
581 }
582
583 #[cfg(not(feature = "no-jail"))]
589 #[test]
590 fn honors_path_jail_false_after_mtime_preserving_edit() {
591 let _iso = crate::core::data_dir::isolated_data_dir();
592 let cfg_path = crate::core::config::Config::path().unwrap();
593 if let Some(parent) = cfg_path.parent() {
594 std::fs::create_dir_all(parent).unwrap();
595 }
596
597 let tmp = tempfile::tempdir().unwrap();
598 let root = tmp.path().join("project");
599 let outside = tmp.path().join("outside");
600 std::fs::create_dir_all(&root).unwrap();
601 std::fs::create_dir_all(&outside).unwrap();
602 let secret = outside.join("secret.txt");
603 std::fs::write(&secret, "x").unwrap();
604
605 std::fs::write(&cfg_path, "# jail on\n").unwrap();
607 let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
608 assert_eq!(crate::core::config::Config::load().path_jail, None);
609
610 std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
613 filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
614
615 assert!(
616 jail_path(&secret, &root).is_ok(),
617 "path_jail=false must take effect without a fresh process (#406)"
618 );
619 }
620
621 #[test]
622 fn allows_nonexistent_child_under_root() {
623 let tmp = tempfile::tempdir().unwrap();
624 let root = tmp.path().join("root");
625 std::fs::create_dir_all(&root).unwrap();
626 std::fs::write(root.join("a.txt"), "ok").unwrap();
627
628 let p = root.join("new").join("file.txt");
629 let ok = jail_path(&p, &root).unwrap();
630 assert!(ok.to_string_lossy().contains("file.txt"));
631 }
632
633 #[cfg(not(feature = "no-jail"))]
634 #[test]
635 fn relative_candidate_resolves_against_root_not_cwd() {
636 let _iso = crate::core::data_dir::isolated_data_dir();
639 let tmp = tempfile::tempdir().unwrap();
640 let root = tmp.path().join("project");
641 std::fs::create_dir_all(root.join("sub")).unwrap();
642 std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
643
644 let jailed = jail_path(Path::new("sub/file.rs"), &root)
645 .expect("relative candidate should resolve under the jail root");
646 assert!(jailed.ends_with("sub/file.rs"));
647 assert!(
648 is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
649 "resolved path must live under the jail root: {jailed:?}"
650 );
651 }
652
653 #[test]
654 fn ide_config_dirs_list_is_not_empty() {
655 assert!(IDE_CONFIG_DIRS.len() >= 10);
656 assert!(IDE_CONFIG_DIRS.contains(&".codex"));
657 assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
658 assert!(IDE_CONFIG_DIRS.contains(&".claude"));
659 assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
660 }
661
662 #[test]
665 fn ide_config_dirs_are_excluded_by_default() {
666 let home = tempfile::tempdir().unwrap();
667 for d in [".lean-ctx", ".cursor", ".claude", ".codex"] {
668 std::fs::create_dir_all(home.path().join(d)).unwrap();
669 }
670
671 let denied = home_allow_dirs(home.path(), false);
672 assert_eq!(
673 denied.len(),
674 1,
675 "only ~/.lean-ctx may be allowed: {denied:?}"
676 );
677 assert!(denied[0].ends_with(".lean-ctx"));
678
679 let allowed = home_allow_dirs(home.path(), true);
680 assert_eq!(allowed.len(), 4, "opt-in must allow all existing IDE dirs");
681 }
682
683 #[test]
684 fn canonicalize_or_self_strips_verbatim() {
685 let tmp = tempfile::tempdir().unwrap();
686 let dir = tmp.path().join("project");
687 std::fs::create_dir_all(&dir).unwrap();
688
689 let result = canonicalize_or_self(&dir);
690 let s = result.to_string_lossy();
691 assert!(
692 !s.starts_with(r"\\?\"),
693 "canonicalize_or_self should strip verbatim prefix, got: {s}"
694 );
695 }
696
697 #[test]
698 fn jail_path_accepts_same_dir_different_format() {
699 let tmp = tempfile::tempdir().unwrap();
700 let root = tmp.path().join("project");
701 std::fs::create_dir_all(&root).unwrap();
702 std::fs::write(root.join("file.rs"), "ok").unwrap();
703
704 let result = jail_path(&root.join("file.rs"), &root);
705 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
706 }
707
708 #[cfg(not(feature = "no-jail"))]
709 #[test]
710 fn error_message_contains_escape_info() {
711 let _iso = crate::core::data_dir::isolated_data_dir();
714 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
715 let tmp = tempfile::tempdir().unwrap();
716 let root = tmp.path().join("root");
717 let other = tmp.path().join("other");
718 std::fs::create_dir_all(&root).unwrap();
719 std::fs::create_dir_all(&other).unwrap();
720 std::fs::write(other.join("b.txt"), "no").unwrap();
721
722 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
723 assert!(
724 err.contains("path escapes project root"),
725 "error should mention escape: {err}"
726 );
727 }
728
729 #[test]
732 fn expand_user_path_expands_tilde_and_vars() {
733 let home = dirs::home_dir().expect("home dir");
734 let home_s = home.to_string_lossy().to_string();
735
736 assert_eq!(expand_user_path("~"), home);
737 assert_eq!(expand_user_path("~/code"), home.join("code"));
738 assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
739 assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
740 crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
742 assert_eq!(
743 expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
744 PathBuf::from(format!("{home_s}/sub/x"))
745 );
746 crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
747 assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
749 }
750
751 #[test]
752 fn expand_user_path_leaves_unset_vars_verbatim() {
753 crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
754 let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
755 assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
756 }
757
758 static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
761
762 #[cfg(unix)]
765 #[test]
766 fn allow_path_root_slash_permits_everything() {
767 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
768 let tmp = tempfile::tempdir().unwrap();
769 let root = tmp.path().join("root");
770 let other = tmp.path().join("other");
771 std::fs::create_dir_all(&root).unwrap();
772 std::fs::create_dir_all(&other).unwrap();
773 std::fs::write(other.join("b.txt"), "allowed").unwrap();
774
775 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
776 let result = jail_path(&other.join("b.txt"), &root);
777 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
778
779 assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
780 }
781
782 #[test]
785 fn active_relaxations_detects_allow_path_env() {
786 let _iso = crate::core::data_dir::isolated_data_dir();
787 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
788 crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
789 crate::test_env::remove_var("LEAN_CTX_ALLOW_IDE_DIRS");
790 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/tmp");
791
792 let relaxed = active_relaxations();
793
794 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
795
796 assert!(
797 relaxed.iter().any(|r| r.source == "LEAN_CTX_ALLOW_PATH"),
798 "LEAN_CTX_ALLOW_PATH must be reported as a jail relaxation: {relaxed:?}"
799 );
800 }
801
802 #[cfg(not(feature = "no-jail"))]
803 #[test]
804 fn active_relaxations_empty_when_jail_intact() {
805 let _iso = crate::core::data_dir::isolated_data_dir();
806 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
807 for var in [
808 "LEAN_CTX_ALLOW_PATH",
809 "LCTX_ALLOW_PATH",
810 "LEAN_CTX_EXTRA_ROOTS",
811 "LEAN_CTX_ALLOW_IDE_DIRS",
812 ] {
813 crate::test_env::remove_var(var);
814 }
815
816 assert!(
817 active_relaxations().is_empty(),
818 "an intact jail (clean config, no relaxation env) must report no relaxations: {:?}",
819 active_relaxations()
820 );
821 }
822
823 #[test]
824 fn allow_path_env_permits_outside_root() {
825 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
826 let tmp = tempfile::tempdir().unwrap();
827 let root = tmp.path().join("root");
828 let other = tmp.path().join("other");
829 std::fs::create_dir_all(&root).unwrap();
830 std::fs::create_dir_all(&other).unwrap();
831 std::fs::write(other.join("b.txt"), "allowed").unwrap();
832
833 let canon = canonicalize_or_self(&other);
834 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
835 let result = jail_path(&other.join("b.txt"), &root);
836 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
837
838 assert!(
839 result.is_ok(),
840 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
841 );
842 }
843
844 #[cfg(all(unix, not(feature = "no-jail")))]
845 #[test]
846 fn rejects_symlink_escape_on_unix() {
847 use std::os::unix::fs::symlink;
848
849 let _iso = crate::core::data_dir::isolated_data_dir();
852 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
853 let tmp = tempfile::tempdir().unwrap();
854 let root = tmp.path().join("root");
855 let other = tmp.path().join("other");
856 std::fs::create_dir_all(&root).unwrap();
857 std::fs::create_dir_all(&other).unwrap();
858 std::fs::write(other.join("secret.txt"), "no").unwrap();
859
860 let link = root.join("link.txt");
861 symlink(other.join("secret.txt"), &link).unwrap();
862
863 let bad = jail_path(&link, &root);
864 assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
865 }
866
867 #[test]
868 fn rejects_null_byte_in_path() {
869 let tmp = tempfile::tempdir().unwrap();
870 let root = tmp.path().join("root");
871 std::fs::create_dir_all(&root).unwrap();
872
873 let bad_path = PathBuf::from("file\0.txt");
874 let result = jail_path(&bad_path, &root);
875 assert!(result.is_err(), "null byte in path must be rejected");
876 assert!(
877 result.unwrap_err().contains("null byte"),
878 "error must mention null byte"
879 );
880 }
881
882 #[cfg(not(feature = "no-jail"))]
888 #[test]
889 fn extra_roots_permit_paths_outside_jail() {
890 let _iso = crate::core::data_dir::isolated_data_dir();
891 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
892
893 let tmp = tempfile::tempdir().unwrap();
894 let root = tmp.path().join("project");
895 let worktree = tmp.path().join("worktree");
896 let elsewhere = tmp.path().join("elsewhere");
897 for d in [&root, &worktree, &elsewhere] {
898 std::fs::create_dir_all(d).unwrap();
899 }
900 let in_worktree = worktree.join("a.txt");
901 std::fs::write(&in_worktree, "x").unwrap();
902 let outside = elsewhere.join("b.txt");
903 std::fs::write(&outside, "y").unwrap();
904
905 assert!(jail_path(&in_worktree, &root).is_err());
907 assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
908
909 let extra = vec![worktree.to_string_lossy().to_string()];
912 assert!(
913 jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
914 "path under a session extra_root must resolve (#403)"
915 );
916
917 assert!(
919 jail_path_with_roots(&outside, &root, &extra).is_err(),
920 "paths outside ALL roots must still be rejected"
921 );
922
923 assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
925 }
926}