1use std::path::{Path, PathBuf};
2
3pub fn expand_user_path(raw: &str) -> PathBuf {
9 let mut s = raw.to_string();
10
11 if (s == "~" || s.starts_with("~/"))
12 && let Some(home) = dirs::home_dir()
13 {
14 s = format!("{}{}", home.to_string_lossy(), &s[1..]);
15 }
16
17 while let Some(start) = s.find('$') {
18 let rest = &s[start + 1..];
19 let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
20 match stripped.find('}') {
21 Some(end) => (stripped[..end].to_string(), end + 3),
22 None => break,
23 }
24 } else {
25 let end = rest
26 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
27 .unwrap_or(rest.len());
28 (rest[..end].to_string(), end + 1)
29 };
30 if name.is_empty() {
31 break;
32 }
33 if let Ok(val) = std::env::var(&name) {
34 s.replace_range(start..start + token_len, &val);
35 } else {
36 tracing::warn!(
37 "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
38 );
39 break;
40 }
41 }
42
43 PathBuf::from(s)
44}
45
46pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
47 let mut out = Vec::new();
48 let cfg = crate::core::config::Config::load();
49
50 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
62 out.push(canonicalize_secure(&data_dir));
63 }
64
65 if let Some(home) = dirs::home_dir() {
66 let ide_dirs_allowed = cfg.allow_ide_config_dirs.unwrap_or(false)
67 || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
68 out.extend(home_allow_dirs(&home, ide_dirs_allowed));
69 }
70
71 for p in &cfg.allow_paths {
72 out.push(canonicalize_secure(&expand_user_path(p)));
73 }
74 for p in &cfg.extra_roots {
75 out.push(canonicalize_secure(&expand_user_path(p)));
76 }
77
78 let v = std::env::var("LCTX_ALLOW_PATH")
81 .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
82 .unwrap_or_default();
83 if !v.trim().is_empty() {
84 for p in std::env::split_paths(&v) {
85 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
86 }
87 }
88
89 let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
90 if !extra.trim().is_empty() {
91 for p in std::env::split_paths(&extra) {
92 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
93 }
94 }
95
96 out.extend(canonicalized_roots(
101 &cfg.read_only_roots,
102 "LEAN_CTX_READ_ONLY_ROOTS",
103 ));
104
105 out
106}
107
108fn canonicalized_roots(config_entries: &[String], env_var: &str) -> Vec<PathBuf> {
113 let mut out = Vec::new();
114 for p in config_entries {
115 out.push(canonicalize_secure(&expand_user_path(p)));
116 }
117 let v = std::env::var(env_var).unwrap_or_default();
118 if !v.trim().is_empty() {
119 for p in std::env::split_paths(&v) {
120 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
121 }
122 }
123 out
124}
125
126pub fn read_only_roots_from_env_and_config() -> Vec<PathBuf> {
131 let cfg = crate::core::config::Config::load();
132 canonicalized_roots(&cfg.read_only_roots, "LEAN_CTX_READ_ONLY_ROOTS")
133}
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
142pub struct JailRelaxation {
143 pub source: &'static str,
145 pub detail: &'static str,
147}
148
149fn env_is_set(var: &str) -> bool {
150 std::env::var(var).is_ok_and(|v| !v.trim().is_empty())
151}
152
153#[must_use]
157pub fn active_relaxations() -> Vec<JailRelaxation> {
158 let mut out = Vec::new();
159
160 if cfg!(feature = "no-jail") {
161 out.push(JailRelaxation {
162 source: "no-jail (build feature)",
163 detail: "path jail compiled out — every tool path is allowed",
164 });
165 }
166
167 if crate::core::config::Config::load().path_jail == Some(false) {
168 out.push(JailRelaxation {
169 source: "path_jail = false (config.toml)",
170 detail: "path jail disabled — every tool path is allowed",
171 });
172 }
173
174 if env_is_set("LEAN_CTX_ALLOW_PATH") || env_is_set("LCTX_ALLOW_PATH") {
175 out.push(JailRelaxation {
176 source: "LEAN_CTX_ALLOW_PATH",
177 detail: "widens the read/write allow-list beyond the project root",
178 });
179 }
180
181 if env_is_set("LEAN_CTX_EXTRA_ROOTS") {
182 out.push(JailRelaxation {
183 source: "LEAN_CTX_EXTRA_ROOTS",
184 detail: "adds extra accessible roots beyond the project root",
185 });
186 }
187
188 let ide_env = std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
189 if ide_env
190 || crate::core::config::Config::load()
191 .allow_ide_config_dirs
192 .unwrap_or(false)
193 {
194 out.push(JailRelaxation {
195 source: if ide_env {
196 "LEAN_CTX_ALLOW_IDE_DIRS=1"
197 } else {
198 "allow_ide_config_dirs = true (config.toml)"
199 },
200 detail: "exposes ~/.cursor, ~/.claude, … (other agents' sessions/credentials) to tools",
201 });
202 }
203
204 out
205}
206
207pub fn warn_if_relaxed() {
211 for relaxation in active_relaxations() {
212 tracing::warn!(
213 "[SECURITY] path jail relaxed via {}: {} — intended for trusted local use only",
214 relaxation.source,
215 relaxation.detail
216 );
217 }
218}
219
220pub fn is_read_only_path(candidate: &Path) -> bool {
231 let roots = read_only_roots_from_env_and_config();
232 if roots.is_empty() {
233 return false;
234 }
235
236 let base = match canonicalize_existing_ancestor(candidate) {
240 Some((base, remainder)) => {
241 let mut p = base;
242 for part in remainder.iter().rev() {
243 p.push(part);
244 }
245 p
246 }
247 None => canonicalize_or_self(candidate),
248 };
249
250 roots.iter().any(|r| is_under_prefix(&base, r))
251}
252
253pub fn enforce_writable(candidate: &Path) -> Result<(), String> {
262 if is_read_only_path(candidate) {
263 return Err(format!(
264 "path is inside a read-only root — writes are denied (read_only_roots): {}",
265 candidate.display()
266 ));
267 }
268 Ok(())
269}
270
271fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
282 let mut out = Vec::new();
283 if ide_dirs_allowed {
284 let targets = crate::core::editor_registry::build_targets(home);
285 collect_ide_allow_dirs(home, &targets, &mut out);
286 }
287 out
288}
289
290fn collect_ide_allow_dirs(
299 home: &Path,
300 targets: &[crate::core::editor_registry::EditorTarget],
301 out: &mut Vec<PathBuf>,
302) {
303 let mut seen: std::collections::HashSet<PathBuf> = out.iter().cloned().collect();
304 for target in targets {
305 let candidates = [
306 target.config_path.parent().map(Path::to_path_buf),
307 Some(target.detect_path.clone()),
308 ];
309 for cand in candidates.into_iter().flatten() {
310 if cand.as_path() == home || !cand.starts_with(home) || !cand.is_dir() {
311 continue;
312 }
313 let resolved = canonicalize_secure(&cand);
314 if seen.insert(resolved.clone()) {
315 out.push(resolved);
316 }
317 }
318 }
319}
320
321fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
322 path.starts_with(prefix)
323}
324
325pub fn canonicalize_or_self(path: &Path) -> PathBuf {
329 super::pathutil::safe_canonicalize_bounded(path, 2000)
330}
331
332fn canonicalize_secure(path: &Path) -> PathBuf {
337 super::pathutil::canonicalize_secure_bounded(path, 2000)
338}
339
340fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
341 let mut cur = path.to_path_buf();
342 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
343 loop {
344 if cur.exists() {
345 return Some((canonicalize_secure(&cur), remainder));
346 }
347 let name = cur.file_name()?.to_os_string();
348 remainder.push(name);
349 if !cur.pop() {
350 return None;
351 }
352 }
353}
354
355pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
356 jail_path_with_roots(candidate, jail_root, &[])
357}
358
359pub fn jail_path_with_roots(
369 candidate: &Path,
370 jail_root: &Path,
371 extra_roots: &[String],
372) -> Result<PathBuf, String> {
373 if candidate.to_string_lossy().as_bytes().contains(&0) {
374 return Err("path contains null byte".to_string());
375 }
376
377 #[cfg(feature = "no-jail")]
378 {
379 let _ = (jail_root, extra_roots);
380 return Ok(canonicalize_or_self(candidate));
381 }
382
383 #[allow(unreachable_code)]
384 {
385 let cfg = crate::core::config::Config::load();
386 if cfg.path_jail == Some(false) {
387 return Ok(canonicalize_or_self(candidate));
388 }
389
390 let root = canonicalize_secure(jail_root);
391
392 let resolved: PathBuf;
397 let candidate: &Path = if candidate.is_absolute() {
398 candidate
399 } else {
400 resolved = root.join(candidate);
401 resolved.as_path()
402 };
403
404 let mut allow = allow_paths_from_env_and_config();
405 allow.extend(
407 extra_roots
408 .iter()
409 .filter(|r| !r.is_empty())
410 .map(|r| canonicalize_secure(Path::new(r))),
411 );
412
413 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
414 format!(
415 "path does not exist and has no existing ancestor: {}",
416 candidate.display()
417 )
418 })?;
419
420 let allowed =
421 is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
422
423 #[cfg(windows)]
424 let allowed = allowed || is_under_prefix_windows(&base, &root);
425
426 if !allowed {
427 let base_msg = format!(
428 "path escapes project root: {} (root: {})",
429 candidate.display(),
430 root.display(),
431 );
432 let mut hint = if crate::core::protocol::meta_visible() {
433 let dir = candidate.parent().unwrap_or(candidate).display();
434 format!(
435 ". Hint: set LEAN_CTX_READ_ONLY_ROOTS={dir} for read-only access, \
436 or LEAN_CTX_ALLOW_PATH={dir} for read-write access, \
437 or add entries to read_only_roots/allow_paths in ~/.config/lean-ctx/config.toml"
438 )
439 } else {
440 String::new()
441 };
442 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
446 hint.push_str(". ");
447 hint.push_str(¬ice);
448 }
449 if let Some(missing) = crate::core::config::Config::missing_config_path() {
453 hint.push_str(&format!(
454 ". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
455 allow_paths edit in a config.toml elsewhere is not read; \
456 `lean-ctx doctor` shows the path in effect",
457 missing.display()
458 ));
459 }
460 return Err(format!("{base_msg}{hint}"));
461 }
462
463 #[cfg(windows)]
464 reject_symlink_on_windows(candidate)?;
465
466 let mut out = base;
467 for part in remainder.iter().rev() {
468 out.push(part);
469 }
470
471 if out.exists() {
474 let final_canon = canonicalize_secure(&out);
475 let final_ok = is_under_prefix(&final_canon, &root)
476 || allow.iter().any(|p| is_under_prefix(&final_canon, p));
477 #[cfg(windows)]
478 let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
479 if !final_ok {
480 return Err(format!(
481 "post-canonicalize jail escape detected: {} resolves to {}",
482 candidate.display(),
483 final_canon.display()
484 ));
485 }
486 }
487
488 Ok(out)
489 }
490}
491
492#[cfg(windows)]
493fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
494 let path_str = normalize_windows_path(&path.to_string_lossy());
495 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
496 path_str.starts_with(&prefix_str)
497}
498
499#[cfg(windows)]
500fn normalize_windows_path(s: &str) -> String {
501 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
502 stripped.to_lowercase().replace('/', "\\")
503}
504
505#[cfg(windows)]
506fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
507 if let Ok(meta) = std::fs::symlink_metadata(path) {
508 if super::pathutil::is_symlink_or_reparse(&meta) {
511 return Err(format!(
512 "symlink not allowed in jailed path: {}",
513 path.display()
514 ));
515 }
516 }
517 Ok(())
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523
524 #[cfg(not(feature = "no-jail"))]
525 #[test]
526 fn rejects_path_outside_root() {
527 let _iso = crate::core::data_dir::isolated_data_dir();
532 let tmp = tempfile::tempdir().unwrap();
533 let root = tmp.path().join("root");
534 let other = tmp.path().join("other");
535 std::fs::create_dir_all(&root).unwrap();
536 std::fs::create_dir_all(&other).unwrap();
537 std::fs::write(root.join("a.txt"), "ok").unwrap();
538 std::fs::write(other.join("b.txt"), "no").unwrap();
539
540 let ok = jail_path(&root.join("a.txt"), &root);
541 assert!(ok.is_ok());
542
543 let bad = jail_path(&other.join("b.txt"), &root);
544 assert!(bad.is_err());
545 }
546
547 #[cfg(not(feature = "no-jail"))]
554 #[test]
555 fn read_only_roots_deny_writes_but_allow_reads() {
556 let _iso = crate::core::data_dir::isolated_data_dir();
557
558 let tmp = tempfile::tempdir().unwrap();
559 let project = tmp.path().join("project");
560 let refrepo = tmp.path().join("refrepo");
561 std::fs::create_dir_all(&project).unwrap();
562 std::fs::create_dir_all(refrepo.join("sub")).unwrap();
563 std::fs::write(refrepo.join("lib.rs"), "pub fn x() {}\n").unwrap();
564
565 let ro_canon = canonicalize_secure(&refrepo);
568 crate::test_env::set_var(
569 "LEAN_CTX_READ_ONLY_ROOTS",
570 ro_canon.to_string_lossy().as_ref(),
571 );
572
573 let existing = refrepo.join("lib.rs");
574 let new_file = refrepo.join("sub").join("new.rs");
575 let proj_file = project.join("main.rs");
576
577 let read_existing = jail_path(&existing, &project);
579 let deny_existing = enforce_writable(&existing);
580 let deny_new = enforce_writable(&new_file);
581 let allow_project = enforce_writable(&proj_file);
582 let ro_existing = is_read_only_path(&existing);
583 let ro_project = is_read_only_path(&proj_file);
584
585 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
586
587 assert!(
588 deny_existing.is_err(),
589 "write to an existing file in a read-only root must be denied"
590 );
591 assert!(
592 deny_new.is_err(),
593 "creating a new file in a read-only root must be denied"
594 );
595 assert!(
596 allow_project.is_ok(),
597 "writes into the project root must stay allowed: {allow_project:?}"
598 );
599 assert!(
600 read_existing.is_ok(),
601 "reads inside a read-only root must resolve (read allow-list): {read_existing:?}"
602 );
603 assert!(ro_existing, "the file is inside the read-only root");
604 assert!(!ro_project, "the project file is not read-only");
605 }
606
607 #[cfg(not(feature = "no-jail"))]
613 #[test]
614 fn honors_path_jail_false_after_mtime_preserving_edit() {
615 let _iso = crate::core::data_dir::isolated_data_dir();
616 let cfg_path = crate::core::config::Config::path().unwrap();
617 if let Some(parent) = cfg_path.parent() {
618 std::fs::create_dir_all(parent).unwrap();
619 }
620
621 let tmp = tempfile::tempdir().unwrap();
622 let root = tmp.path().join("project");
623 let outside = tmp.path().join("outside");
624 std::fs::create_dir_all(&root).unwrap();
625 std::fs::create_dir_all(&outside).unwrap();
626 let secret = outside.join("secret.txt");
627 std::fs::write(&secret, "x").unwrap();
628
629 std::fs::write(&cfg_path, "# jail on\n").unwrap();
631 let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
632 assert_eq!(crate::core::config::Config::load().path_jail, None);
633
634 std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
637 filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
638
639 assert!(
640 jail_path(&secret, &root).is_ok(),
641 "path_jail=false must take effect without a fresh process (#406)"
642 );
643 }
644
645 #[test]
646 fn allows_nonexistent_child_under_root() {
647 let tmp = tempfile::tempdir().unwrap();
648 let root = tmp.path().join("root");
649 std::fs::create_dir_all(&root).unwrap();
650 std::fs::write(root.join("a.txt"), "ok").unwrap();
651
652 let p = root.join("new").join("file.txt");
653 let ok = jail_path(&p, &root).unwrap();
654 assert!(ok.to_string_lossy().contains("file.txt"));
655 }
656
657 #[cfg(not(feature = "no-jail"))]
658 #[test]
659 fn relative_candidate_resolves_against_root_not_cwd() {
660 let _iso = crate::core::data_dir::isolated_data_dir();
663 let tmp = tempfile::tempdir().unwrap();
664 let root = tmp.path().join("project");
665 std::fs::create_dir_all(root.join("sub")).unwrap();
666 std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
667
668 let jailed = jail_path(Path::new("sub/file.rs"), &root)
669 .expect("relative candidate should resolve under the jail root");
670 assert!(jailed.ends_with("sub/file.rs"));
671 assert!(
672 is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
673 "resolved path must live under the jail root: {jailed:?}"
674 );
675 }
676
677 #[test]
678 fn ide_allow_dirs_are_registry_derived_and_skip_home() {
679 use crate::core::editor_registry::{ConfigType, EditorTarget};
680
681 let home = tempfile::tempdir().unwrap();
682 let h = home.path();
683 std::fs::create_dir_all(h.join("Library/Application Support/Code/User")).unwrap();
686 std::fs::create_dir_all(h.join(".cursor")).unwrap();
687
688 let targets = vec![
689 EditorTarget {
690 name: "VS Code",
691 agent_key: "vscode".into(),
692 config_path: h.join("Library/Application Support/Code/User/mcp.json"),
693 detect_path: h.join("Library/Application Support/Code"),
694 config_type: ConfigType::VsCodeMcp,
695 },
696 EditorTarget {
697 name: "Cursor",
698 agent_key: "cursor".into(),
699 config_path: h.join(".cursor/mcp.json"),
700 detect_path: h.join(".cursor"),
701 config_type: ConfigType::McpJson,
702 },
703 EditorTarget {
705 name: "Claude Code",
706 agent_key: "claude".into(),
707 config_path: h.join(".claude.json"),
708 detect_path: h.join(".no-such-dir"),
709 config_type: ConfigType::McpJson,
710 },
711 ];
712
713 let mut out = Vec::new();
714 collect_ide_allow_dirs(h, &targets, &mut out);
715
716 assert!(
717 out.iter().any(|p| p.ends_with("Code/User")),
718 "non-dotfile VS Code dir must be covered: {out:?}"
719 );
720 assert!(out.iter().any(|p| p.ends_with(".cursor")), "{out:?}");
721 let home_canon = canonicalize_secure(h);
722 assert!(
723 !out.contains(&home_canon),
724 "must never widen the jail to $HOME: {out:?}"
725 );
726 }
727
728 #[test]
733 fn ide_config_dirs_are_excluded_by_default() {
734 let home = tempfile::tempdir().unwrap();
735 for d in [".lean-ctx", ".cursor", ".codex"] {
736 std::fs::create_dir_all(home.path().join(d)).unwrap();
737 }
738
739 let denied = home_allow_dirs(home.path(), false);
740 assert!(
741 denied.is_empty(),
742 "foreign editor dirs must stay jailed by default: {denied:?}"
743 );
744
745 let allowed = home_allow_dirs(home.path(), true);
750 assert!(
751 allowed.iter().any(|p| p.ends_with(".cursor")),
752 "opt-in must expose editor dirs: {allowed:?}"
753 );
754 assert!(
755 !allowed.iter().any(|p| p.ends_with(".lean-ctx")),
756 "lean-ctx's own dir is covered by the data_dir root, not home_allow_dirs: {allowed:?}"
757 );
758 }
759
760 #[test]
761 fn canonicalize_or_self_strips_verbatim() {
762 let tmp = tempfile::tempdir().unwrap();
763 let dir = tmp.path().join("project");
764 std::fs::create_dir_all(&dir).unwrap();
765
766 let result = canonicalize_or_self(&dir);
767 let s = result.to_string_lossy();
768 assert!(
769 !s.starts_with(r"\\?\"),
770 "canonicalize_or_self should strip verbatim prefix, got: {s}"
771 );
772 }
773
774 #[test]
775 fn jail_path_accepts_same_dir_different_format() {
776 let tmp = tempfile::tempdir().unwrap();
777 let root = tmp.path().join("project");
778 std::fs::create_dir_all(&root).unwrap();
779 std::fs::write(root.join("file.rs"), "ok").unwrap();
780
781 let result = jail_path(&root.join("file.rs"), &root);
782 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
783 }
784
785 #[cfg(not(feature = "no-jail"))]
786 #[test]
787 fn error_message_contains_escape_info() {
788 let _iso = crate::core::data_dir::isolated_data_dir();
791 let tmp = tempfile::tempdir().unwrap();
792 let root = tmp.path().join("root");
793 let other = tmp.path().join("other");
794 std::fs::create_dir_all(&root).unwrap();
795 std::fs::create_dir_all(&other).unwrap();
796 std::fs::write(other.join("b.txt"), "no").unwrap();
797
798 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
799 assert!(
800 err.contains("path escapes project root"),
801 "error should mention escape: {err}"
802 );
803 }
804
805 #[test]
808 fn expand_user_path_expands_tilde_and_vars() {
809 let home = dirs::home_dir().expect("home dir");
810 let home_s = home.to_string_lossy().to_string();
811
812 assert_eq!(expand_user_path("~"), home);
813 assert_eq!(expand_user_path("~/code"), home.join("code"));
814 assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
815 assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
816 crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
818 assert_eq!(
819 expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
820 PathBuf::from(format!("{home_s}/sub/x"))
821 );
822 crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
823 assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
825 }
826
827 #[test]
828 fn expand_user_path_leaves_unset_vars_verbatim() {
829 crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
830 let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
831 assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
832 }
833
834 #[cfg(unix)]
847 #[test]
848 fn allow_path_root_slash_permits_everything() {
849 let _guard = crate::core::data_dir::test_env_lock();
850 let tmp = tempfile::tempdir().unwrap();
851 let root = tmp.path().join("root");
852 let other = tmp.path().join("other");
853 std::fs::create_dir_all(&root).unwrap();
854 std::fs::create_dir_all(&other).unwrap();
855 std::fs::write(other.join("b.txt"), "allowed").unwrap();
856
857 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
858 let result = jail_path(&other.join("b.txt"), &root);
859 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
860
861 assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
862 }
863
864 #[test]
867 fn active_relaxations_detects_allow_path_env() {
868 let _iso = crate::core::data_dir::isolated_data_dir();
869 crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
870 crate::test_env::remove_var("LEAN_CTX_ALLOW_IDE_DIRS");
871 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/tmp");
872
873 let relaxed = active_relaxations();
874
875 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
876
877 assert!(
878 relaxed.iter().any(|r| r.source == "LEAN_CTX_ALLOW_PATH"),
879 "LEAN_CTX_ALLOW_PATH must be reported as a jail relaxation: {relaxed:?}"
880 );
881 }
882
883 #[cfg(not(feature = "no-jail"))]
884 #[test]
885 fn active_relaxations_empty_when_jail_intact() {
886 let _iso = crate::core::data_dir::isolated_data_dir();
887 for var in [
888 "LEAN_CTX_ALLOW_PATH",
889 "LCTX_ALLOW_PATH",
890 "LEAN_CTX_EXTRA_ROOTS",
891 "LEAN_CTX_ALLOW_IDE_DIRS",
892 ] {
893 crate::test_env::remove_var(var);
894 }
895
896 assert!(
897 active_relaxations().is_empty(),
898 "an intact jail (clean config, no relaxation env) must report no relaxations: {:?}",
899 active_relaxations()
900 );
901 }
902
903 #[test]
904 fn allow_path_env_permits_outside_root() {
905 let _guard = crate::core::data_dir::test_env_lock();
906 let tmp = tempfile::tempdir().unwrap();
907 let root = tmp.path().join("root");
908 let other = tmp.path().join("other");
909 std::fs::create_dir_all(&root).unwrap();
910 std::fs::create_dir_all(&other).unwrap();
911 std::fs::write(other.join("b.txt"), "allowed").unwrap();
912
913 let canon = canonicalize_or_self(&other);
914 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
915 let result = jail_path(&other.join("b.txt"), &root);
916 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
917
918 assert!(
919 result.is_ok(),
920 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
921 );
922 }
923
924 #[cfg(all(unix, not(feature = "no-jail")))]
925 #[test]
926 fn rejects_symlink_escape_on_unix() {
927 use std::os::unix::fs::symlink;
928
929 let _iso = crate::core::data_dir::isolated_data_dir();
932 let tmp = tempfile::tempdir().unwrap();
933 let root = tmp.path().join("root");
934 let other = tmp.path().join("other");
935 std::fs::create_dir_all(&root).unwrap();
936 std::fs::create_dir_all(&other).unwrap();
937 std::fs::write(other.join("secret.txt"), "no").unwrap();
938
939 let link = root.join("link.txt");
940 symlink(other.join("secret.txt"), &link).unwrap();
941
942 let bad = jail_path(&link, &root);
943 assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
944 }
945
946 #[test]
947 fn rejects_null_byte_in_path() {
948 let tmp = tempfile::tempdir().unwrap();
949 let root = tmp.path().join("root");
950 std::fs::create_dir_all(&root).unwrap();
951
952 let bad_path = PathBuf::from("file\0.txt");
953 let result = jail_path(&bad_path, &root);
954 assert!(result.is_err(), "null byte in path must be rejected");
955 assert!(
956 result.unwrap_err().contains("null byte"),
957 "error must mention null byte"
958 );
959 }
960
961 #[cfg(not(feature = "no-jail"))]
967 #[test]
968 fn extra_roots_permit_paths_outside_jail() {
969 let _iso = crate::core::data_dir::isolated_data_dir();
970
971 let tmp = tempfile::tempdir().unwrap();
972 let root = tmp.path().join("project");
973 let worktree = tmp.path().join("worktree");
974 let elsewhere = tmp.path().join("elsewhere");
975 for d in [&root, &worktree, &elsewhere] {
976 std::fs::create_dir_all(d).unwrap();
977 }
978 let in_worktree = worktree.join("a.txt");
979 std::fs::write(&in_worktree, "x").unwrap();
980 let outside = elsewhere.join("b.txt");
981 std::fs::write(&outside, "y").unwrap();
982
983 assert!(jail_path(&in_worktree, &root).is_err());
985 assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
986
987 let extra = vec![worktree.to_string_lossy().to_string()];
990 assert!(
991 jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
992 "path under a session extra_root must resolve (#403)"
993 );
994
995 assert!(
997 jail_path_with_roots(&outside, &root, &extra).is_err(),
998 "paths outside ALL roots must still be rejected"
999 );
1000
1001 assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
1003 }
1004}