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 format!(
434 ". Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
435 candidate.parent().unwrap_or(candidate).display()
436 )
437 } else {
438 String::new()
439 };
440 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
444 hint.push_str(". ");
445 hint.push_str(¬ice);
446 }
447 if let Some(missing) = crate::core::config::Config::missing_config_path() {
451 hint.push_str(&format!(
452 ". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
453 allow_paths edit in a config.toml elsewhere is not read; \
454 `lean-ctx doctor` shows the path in effect",
455 missing.display()
456 ));
457 }
458 return Err(format!("{base_msg}{hint}"));
459 }
460
461 #[cfg(windows)]
462 reject_symlink_on_windows(candidate)?;
463
464 let mut out = base;
465 for part in remainder.iter().rev() {
466 out.push(part);
467 }
468
469 if out.exists() {
472 let final_canon = canonicalize_secure(&out);
473 let final_ok = is_under_prefix(&final_canon, &root)
474 || allow.iter().any(|p| is_under_prefix(&final_canon, p));
475 #[cfg(windows)]
476 let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
477 if !final_ok {
478 return Err(format!(
479 "post-canonicalize jail escape detected: {} resolves to {}",
480 candidate.display(),
481 final_canon.display()
482 ));
483 }
484 }
485
486 Ok(out)
487 }
488}
489
490#[cfg(windows)]
491fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
492 let path_str = normalize_windows_path(&path.to_string_lossy());
493 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
494 path_str.starts_with(&prefix_str)
495}
496
497#[cfg(windows)]
498fn normalize_windows_path(s: &str) -> String {
499 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
500 stripped.to_lowercase().replace('/', "\\")
501}
502
503#[cfg(windows)]
504fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
505 if let Ok(meta) = std::fs::symlink_metadata(path) {
506 if super::pathutil::is_symlink_or_reparse(&meta) {
509 return Err(format!(
510 "symlink not allowed in jailed path: {}",
511 path.display()
512 ));
513 }
514 }
515 Ok(())
516}
517
518#[cfg(test)]
519mod tests {
520 use super::*;
521
522 #[cfg(not(feature = "no-jail"))]
523 #[test]
524 fn rejects_path_outside_root() {
525 let _iso = crate::core::data_dir::isolated_data_dir();
530 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
531 let tmp = tempfile::tempdir().unwrap();
532 let root = tmp.path().join("root");
533 let other = tmp.path().join("other");
534 std::fs::create_dir_all(&root).unwrap();
535 std::fs::create_dir_all(&other).unwrap();
536 std::fs::write(root.join("a.txt"), "ok").unwrap();
537 std::fs::write(other.join("b.txt"), "no").unwrap();
538
539 let ok = jail_path(&root.join("a.txt"), &root);
540 assert!(ok.is_ok());
541
542 let bad = jail_path(&other.join("b.txt"), &root);
543 assert!(bad.is_err());
544 }
545
546 #[cfg(not(feature = "no-jail"))]
553 #[test]
554 fn read_only_roots_deny_writes_but_allow_reads() {
555 let _iso = crate::core::data_dir::isolated_data_dir();
556
557 let tmp = tempfile::tempdir().unwrap();
558 let project = tmp.path().join("project");
559 let refrepo = tmp.path().join("refrepo");
560 std::fs::create_dir_all(&project).unwrap();
561 std::fs::create_dir_all(refrepo.join("sub")).unwrap();
562 std::fs::write(refrepo.join("lib.rs"), "pub fn x() {}\n").unwrap();
563
564 let ro_canon = canonicalize_secure(&refrepo);
567 crate::test_env::set_var(
568 "LEAN_CTX_READ_ONLY_ROOTS",
569 ro_canon.to_string_lossy().as_ref(),
570 );
571
572 let existing = refrepo.join("lib.rs");
573 let new_file = refrepo.join("sub").join("new.rs");
574 let proj_file = project.join("main.rs");
575
576 let read_existing = jail_path(&existing, &project);
578 let deny_existing = enforce_writable(&existing);
579 let deny_new = enforce_writable(&new_file);
580 let allow_project = enforce_writable(&proj_file);
581 let ro_existing = is_read_only_path(&existing);
582 let ro_project = is_read_only_path(&proj_file);
583
584 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
585
586 assert!(
587 deny_existing.is_err(),
588 "write to an existing file in a read-only root must be denied"
589 );
590 assert!(
591 deny_new.is_err(),
592 "creating a new file in a read-only root must be denied"
593 );
594 assert!(
595 allow_project.is_ok(),
596 "writes into the project root must stay allowed: {allow_project:?}"
597 );
598 assert!(
599 read_existing.is_ok(),
600 "reads inside a read-only root must resolve (read allow-list): {read_existing:?}"
601 );
602 assert!(ro_existing, "the file is inside the read-only root");
603 assert!(!ro_project, "the project file is not read-only");
604 }
605
606 #[cfg(not(feature = "no-jail"))]
612 #[test]
613 fn honors_path_jail_false_after_mtime_preserving_edit() {
614 let _iso = crate::core::data_dir::isolated_data_dir();
615 let cfg_path = crate::core::config::Config::path().unwrap();
616 if let Some(parent) = cfg_path.parent() {
617 std::fs::create_dir_all(parent).unwrap();
618 }
619
620 let tmp = tempfile::tempdir().unwrap();
621 let root = tmp.path().join("project");
622 let outside = tmp.path().join("outside");
623 std::fs::create_dir_all(&root).unwrap();
624 std::fs::create_dir_all(&outside).unwrap();
625 let secret = outside.join("secret.txt");
626 std::fs::write(&secret, "x").unwrap();
627
628 std::fs::write(&cfg_path, "# jail on\n").unwrap();
630 let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
631 assert_eq!(crate::core::config::Config::load().path_jail, None);
632
633 std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
636 filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
637
638 assert!(
639 jail_path(&secret, &root).is_ok(),
640 "path_jail=false must take effect without a fresh process (#406)"
641 );
642 }
643
644 #[test]
645 fn allows_nonexistent_child_under_root() {
646 let tmp = tempfile::tempdir().unwrap();
647 let root = tmp.path().join("root");
648 std::fs::create_dir_all(&root).unwrap();
649 std::fs::write(root.join("a.txt"), "ok").unwrap();
650
651 let p = root.join("new").join("file.txt");
652 let ok = jail_path(&p, &root).unwrap();
653 assert!(ok.to_string_lossy().contains("file.txt"));
654 }
655
656 #[cfg(not(feature = "no-jail"))]
657 #[test]
658 fn relative_candidate_resolves_against_root_not_cwd() {
659 let _iso = crate::core::data_dir::isolated_data_dir();
662 let tmp = tempfile::tempdir().unwrap();
663 let root = tmp.path().join("project");
664 std::fs::create_dir_all(root.join("sub")).unwrap();
665 std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
666
667 let jailed = jail_path(Path::new("sub/file.rs"), &root)
668 .expect("relative candidate should resolve under the jail root");
669 assert!(jailed.ends_with("sub/file.rs"));
670 assert!(
671 is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
672 "resolved path must live under the jail root: {jailed:?}"
673 );
674 }
675
676 #[test]
677 fn ide_allow_dirs_are_registry_derived_and_skip_home() {
678 use crate::core::editor_registry::{ConfigType, EditorTarget};
679
680 let home = tempfile::tempdir().unwrap();
681 let h = home.path();
682 std::fs::create_dir_all(h.join("Library/Application Support/Code/User")).unwrap();
685 std::fs::create_dir_all(h.join(".cursor")).unwrap();
686
687 let targets = vec![
688 EditorTarget {
689 name: "VS Code",
690 agent_key: "vscode".into(),
691 config_path: h.join("Library/Application Support/Code/User/mcp.json"),
692 detect_path: h.join("Library/Application Support/Code"),
693 config_type: ConfigType::VsCodeMcp,
694 },
695 EditorTarget {
696 name: "Cursor",
697 agent_key: "cursor".into(),
698 config_path: h.join(".cursor/mcp.json"),
699 detect_path: h.join(".cursor"),
700 config_type: ConfigType::McpJson,
701 },
702 EditorTarget {
704 name: "Claude Code",
705 agent_key: "claude".into(),
706 config_path: h.join(".claude.json"),
707 detect_path: h.join(".no-such-dir"),
708 config_type: ConfigType::McpJson,
709 },
710 ];
711
712 let mut out = Vec::new();
713 collect_ide_allow_dirs(h, &targets, &mut out);
714
715 assert!(
716 out.iter().any(|p| p.ends_with("Code/User")),
717 "non-dotfile VS Code dir must be covered: {out:?}"
718 );
719 assert!(out.iter().any(|p| p.ends_with(".cursor")), "{out:?}");
720 let home_canon = canonicalize_secure(h);
721 assert!(
722 !out.contains(&home_canon),
723 "must never widen the jail to $HOME: {out:?}"
724 );
725 }
726
727 #[test]
732 fn ide_config_dirs_are_excluded_by_default() {
733 let home = tempfile::tempdir().unwrap();
734 for d in [".lean-ctx", ".cursor", ".codex"] {
735 std::fs::create_dir_all(home.path().join(d)).unwrap();
736 }
737
738 let denied = home_allow_dirs(home.path(), false);
739 assert!(
740 denied.is_empty(),
741 "foreign editor dirs must stay jailed by default: {denied:?}"
742 );
743
744 let allowed = home_allow_dirs(home.path(), true);
749 assert!(
750 allowed.iter().any(|p| p.ends_with(".cursor")),
751 "opt-in must expose editor dirs: {allowed:?}"
752 );
753 assert!(
754 !allowed.iter().any(|p| p.ends_with(".lean-ctx")),
755 "lean-ctx's own dir is covered by the data_dir root, not home_allow_dirs: {allowed:?}"
756 );
757 }
758
759 #[test]
760 fn canonicalize_or_self_strips_verbatim() {
761 let tmp = tempfile::tempdir().unwrap();
762 let dir = tmp.path().join("project");
763 std::fs::create_dir_all(&dir).unwrap();
764
765 let result = canonicalize_or_self(&dir);
766 let s = result.to_string_lossy();
767 assert!(
768 !s.starts_with(r"\\?\"),
769 "canonicalize_or_self should strip verbatim prefix, got: {s}"
770 );
771 }
772
773 #[test]
774 fn jail_path_accepts_same_dir_different_format() {
775 let tmp = tempfile::tempdir().unwrap();
776 let root = tmp.path().join("project");
777 std::fs::create_dir_all(&root).unwrap();
778 std::fs::write(root.join("file.rs"), "ok").unwrap();
779
780 let result = jail_path(&root.join("file.rs"), &root);
781 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
782 }
783
784 #[cfg(not(feature = "no-jail"))]
785 #[test]
786 fn error_message_contains_escape_info() {
787 let _iso = crate::core::data_dir::isolated_data_dir();
790 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
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 static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
837
838 #[cfg(unix)]
841 #[test]
842 fn allow_path_root_slash_permits_everything() {
843 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
844 let tmp = tempfile::tempdir().unwrap();
845 let root = tmp.path().join("root");
846 let other = tmp.path().join("other");
847 std::fs::create_dir_all(&root).unwrap();
848 std::fs::create_dir_all(&other).unwrap();
849 std::fs::write(other.join("b.txt"), "allowed").unwrap();
850
851 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
852 let result = jail_path(&other.join("b.txt"), &root);
853 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
854
855 assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
856 }
857
858 #[test]
861 fn active_relaxations_detects_allow_path_env() {
862 let _iso = crate::core::data_dir::isolated_data_dir();
863 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
864 crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
865 crate::test_env::remove_var("LEAN_CTX_ALLOW_IDE_DIRS");
866 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/tmp");
867
868 let relaxed = active_relaxations();
869
870 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
871
872 assert!(
873 relaxed.iter().any(|r| r.source == "LEAN_CTX_ALLOW_PATH"),
874 "LEAN_CTX_ALLOW_PATH must be reported as a jail relaxation: {relaxed:?}"
875 );
876 }
877
878 #[cfg(not(feature = "no-jail"))]
879 #[test]
880 fn active_relaxations_empty_when_jail_intact() {
881 let _iso = crate::core::data_dir::isolated_data_dir();
882 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
883 for var in [
884 "LEAN_CTX_ALLOW_PATH",
885 "LCTX_ALLOW_PATH",
886 "LEAN_CTX_EXTRA_ROOTS",
887 "LEAN_CTX_ALLOW_IDE_DIRS",
888 ] {
889 crate::test_env::remove_var(var);
890 }
891
892 assert!(
893 active_relaxations().is_empty(),
894 "an intact jail (clean config, no relaxation env) must report no relaxations: {:?}",
895 active_relaxations()
896 );
897 }
898
899 #[test]
900 fn allow_path_env_permits_outside_root() {
901 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
902 let tmp = tempfile::tempdir().unwrap();
903 let root = tmp.path().join("root");
904 let other = tmp.path().join("other");
905 std::fs::create_dir_all(&root).unwrap();
906 std::fs::create_dir_all(&other).unwrap();
907 std::fs::write(other.join("b.txt"), "allowed").unwrap();
908
909 let canon = canonicalize_or_self(&other);
910 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
911 let result = jail_path(&other.join("b.txt"), &root);
912 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
913
914 assert!(
915 result.is_ok(),
916 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
917 );
918 }
919
920 #[cfg(all(unix, not(feature = "no-jail")))]
921 #[test]
922 fn rejects_symlink_escape_on_unix() {
923 use std::os::unix::fs::symlink;
924
925 let _iso = crate::core::data_dir::isolated_data_dir();
928 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
929 let tmp = tempfile::tempdir().unwrap();
930 let root = tmp.path().join("root");
931 let other = tmp.path().join("other");
932 std::fs::create_dir_all(&root).unwrap();
933 std::fs::create_dir_all(&other).unwrap();
934 std::fs::write(other.join("secret.txt"), "no").unwrap();
935
936 let link = root.join("link.txt");
937 symlink(other.join("secret.txt"), &link).unwrap();
938
939 let bad = jail_path(&link, &root);
940 assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
941 }
942
943 #[test]
944 fn rejects_null_byte_in_path() {
945 let tmp = tempfile::tempdir().unwrap();
946 let root = tmp.path().join("root");
947 std::fs::create_dir_all(&root).unwrap();
948
949 let bad_path = PathBuf::from("file\0.txt");
950 let result = jail_path(&bad_path, &root);
951 assert!(result.is_err(), "null byte in path must be rejected");
952 assert!(
953 result.unwrap_err().contains("null byte"),
954 "error must mention null byte"
955 );
956 }
957
958 #[cfg(not(feature = "no-jail"))]
964 #[test]
965 fn extra_roots_permit_paths_outside_jail() {
966 let _iso = crate::core::data_dir::isolated_data_dir();
967 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
968
969 let tmp = tempfile::tempdir().unwrap();
970 let root = tmp.path().join("project");
971 let worktree = tmp.path().join("worktree");
972 let elsewhere = tmp.path().join("elsewhere");
973 for d in [&root, &worktree, &elsewhere] {
974 std::fs::create_dir_all(d).unwrap();
975 }
976 let in_worktree = worktree.join("a.txt");
977 std::fs::write(&in_worktree, "x").unwrap();
978 let outside = elsewhere.join("b.txt");
979 std::fs::write(&outside, "y").unwrap();
980
981 assert!(jail_path(&in_worktree, &root).is_err());
983 assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
984
985 let extra = vec![worktree.to_string_lossy().to_string()];
988 assert!(
989 jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
990 "path under a session extra_root must resolve (#403)"
991 );
992
993 assert!(
995 jail_path_with_roots(&outside, &root, &extra).is_err(),
996 "paths outside ALL roots must still be rejected"
997 );
998
999 assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
1001 }
1002}