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 _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
533 let tmp = tempfile::tempdir().unwrap();
534 let root = tmp.path().join("root");
535 let other = tmp.path().join("other");
536 std::fs::create_dir_all(&root).unwrap();
537 std::fs::create_dir_all(&other).unwrap();
538 std::fs::write(root.join("a.txt"), "ok").unwrap();
539 std::fs::write(other.join("b.txt"), "no").unwrap();
540
541 let ok = jail_path(&root.join("a.txt"), &root);
542 assert!(ok.is_ok());
543
544 let bad = jail_path(&other.join("b.txt"), &root);
545 assert!(bad.is_err());
546 }
547
548 #[cfg(not(feature = "no-jail"))]
555 #[test]
556 fn read_only_roots_deny_writes_but_allow_reads() {
557 let _iso = crate::core::data_dir::isolated_data_dir();
558
559 let tmp = tempfile::tempdir().unwrap();
560 let project = tmp.path().join("project");
561 let refrepo = tmp.path().join("refrepo");
562 std::fs::create_dir_all(&project).unwrap();
563 std::fs::create_dir_all(refrepo.join("sub")).unwrap();
564 std::fs::write(refrepo.join("lib.rs"), "pub fn x() {}\n").unwrap();
565
566 let ro_canon = canonicalize_secure(&refrepo);
569 crate::test_env::set_var(
570 "LEAN_CTX_READ_ONLY_ROOTS",
571 ro_canon.to_string_lossy().as_ref(),
572 );
573
574 let existing = refrepo.join("lib.rs");
575 let new_file = refrepo.join("sub").join("new.rs");
576 let proj_file = project.join("main.rs");
577
578 let read_existing = jail_path(&existing, &project);
580 let deny_existing = enforce_writable(&existing);
581 let deny_new = enforce_writable(&new_file);
582 let allow_project = enforce_writable(&proj_file);
583 let ro_existing = is_read_only_path(&existing);
584 let ro_project = is_read_only_path(&proj_file);
585
586 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
587
588 assert!(
589 deny_existing.is_err(),
590 "write to an existing file in a read-only root must be denied"
591 );
592 assert!(
593 deny_new.is_err(),
594 "creating a new file in a read-only root must be denied"
595 );
596 assert!(
597 allow_project.is_ok(),
598 "writes into the project root must stay allowed: {allow_project:?}"
599 );
600 assert!(
601 read_existing.is_ok(),
602 "reads inside a read-only root must resolve (read allow-list): {read_existing:?}"
603 );
604 assert!(ro_existing, "the file is inside the read-only root");
605 assert!(!ro_project, "the project file is not read-only");
606 }
607
608 #[cfg(not(feature = "no-jail"))]
614 #[test]
615 fn honors_path_jail_false_after_mtime_preserving_edit() {
616 let _iso = crate::core::data_dir::isolated_data_dir();
617 let cfg_path = crate::core::config::Config::path().unwrap();
618 if let Some(parent) = cfg_path.parent() {
619 std::fs::create_dir_all(parent).unwrap();
620 }
621
622 let tmp = tempfile::tempdir().unwrap();
623 let root = tmp.path().join("project");
624 let outside = tmp.path().join("outside");
625 std::fs::create_dir_all(&root).unwrap();
626 std::fs::create_dir_all(&outside).unwrap();
627 let secret = outside.join("secret.txt");
628 std::fs::write(&secret, "x").unwrap();
629
630 std::fs::write(&cfg_path, "# jail on\n").unwrap();
632 let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
633 assert_eq!(crate::core::config::Config::load().path_jail, None);
634
635 std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
638 filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
639
640 assert!(
641 jail_path(&secret, &root).is_ok(),
642 "path_jail=false must take effect without a fresh process (#406)"
643 );
644 }
645
646 #[test]
647 fn allows_nonexistent_child_under_root() {
648 let tmp = tempfile::tempdir().unwrap();
649 let root = tmp.path().join("root");
650 std::fs::create_dir_all(&root).unwrap();
651 std::fs::write(root.join("a.txt"), "ok").unwrap();
652
653 let p = root.join("new").join("file.txt");
654 let ok = jail_path(&p, &root).unwrap();
655 assert!(ok.to_string_lossy().contains("file.txt"));
656 }
657
658 #[cfg(not(feature = "no-jail"))]
659 #[test]
660 fn relative_candidate_resolves_against_root_not_cwd() {
661 let _iso = crate::core::data_dir::isolated_data_dir();
664 let tmp = tempfile::tempdir().unwrap();
665 let root = tmp.path().join("project");
666 std::fs::create_dir_all(root.join("sub")).unwrap();
667 std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
668
669 let jailed = jail_path(Path::new("sub/file.rs"), &root)
670 .expect("relative candidate should resolve under the jail root");
671 assert!(jailed.ends_with("sub/file.rs"));
672 assert!(
673 is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
674 "resolved path must live under the jail root: {jailed:?}"
675 );
676 }
677
678 #[test]
679 fn ide_allow_dirs_are_registry_derived_and_skip_home() {
680 use crate::core::editor_registry::{ConfigType, EditorTarget};
681
682 let home = tempfile::tempdir().unwrap();
683 let h = home.path();
684 std::fs::create_dir_all(h.join("Library/Application Support/Code/User")).unwrap();
687 std::fs::create_dir_all(h.join(".cursor")).unwrap();
688
689 let targets = vec![
690 EditorTarget {
691 name: "VS Code",
692 agent_key: "vscode".into(),
693 config_path: h.join("Library/Application Support/Code/User/mcp.json"),
694 detect_path: h.join("Library/Application Support/Code"),
695 config_type: ConfigType::VsCodeMcp,
696 },
697 EditorTarget {
698 name: "Cursor",
699 agent_key: "cursor".into(),
700 config_path: h.join(".cursor/mcp.json"),
701 detect_path: h.join(".cursor"),
702 config_type: ConfigType::McpJson,
703 },
704 EditorTarget {
706 name: "Claude Code",
707 agent_key: "claude".into(),
708 config_path: h.join(".claude.json"),
709 detect_path: h.join(".no-such-dir"),
710 config_type: ConfigType::McpJson,
711 },
712 ];
713
714 let mut out = Vec::new();
715 collect_ide_allow_dirs(h, &targets, &mut out);
716
717 assert!(
718 out.iter().any(|p| p.ends_with("Code/User")),
719 "non-dotfile VS Code dir must be covered: {out:?}"
720 );
721 assert!(out.iter().any(|p| p.ends_with(".cursor")), "{out:?}");
722 let home_canon = canonicalize_secure(h);
723 assert!(
724 !out.contains(&home_canon),
725 "must never widen the jail to $HOME: {out:?}"
726 );
727 }
728
729 #[test]
734 fn ide_config_dirs_are_excluded_by_default() {
735 let home = tempfile::tempdir().unwrap();
736 for d in [".lean-ctx", ".cursor", ".codex"] {
737 std::fs::create_dir_all(home.path().join(d)).unwrap();
738 }
739
740 let denied = home_allow_dirs(home.path(), false);
741 assert!(
742 denied.is_empty(),
743 "foreign editor dirs must stay jailed by default: {denied:?}"
744 );
745
746 let allowed = home_allow_dirs(home.path(), true);
751 assert!(
752 allowed.iter().any(|p| p.ends_with(".cursor")),
753 "opt-in must expose editor dirs: {allowed:?}"
754 );
755 assert!(
756 !allowed.iter().any(|p| p.ends_with(".lean-ctx")),
757 "lean-ctx's own dir is covered by the data_dir root, not home_allow_dirs: {allowed:?}"
758 );
759 }
760
761 #[test]
762 fn canonicalize_or_self_strips_verbatim() {
763 let tmp = tempfile::tempdir().unwrap();
764 let dir = tmp.path().join("project");
765 std::fs::create_dir_all(&dir).unwrap();
766
767 let result = canonicalize_or_self(&dir);
768 let s = result.to_string_lossy();
769 assert!(
770 !s.starts_with(r"\\?\"),
771 "canonicalize_or_self should strip verbatim prefix, got: {s}"
772 );
773 }
774
775 #[test]
776 fn jail_path_accepts_same_dir_different_format() {
777 let tmp = tempfile::tempdir().unwrap();
778 let root = tmp.path().join("project");
779 std::fs::create_dir_all(&root).unwrap();
780 std::fs::write(root.join("file.rs"), "ok").unwrap();
781
782 let result = jail_path(&root.join("file.rs"), &root);
783 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
784 }
785
786 #[cfg(not(feature = "no-jail"))]
787 #[test]
788 fn error_message_contains_escape_info() {
789 let _iso = crate::core::data_dir::isolated_data_dir();
792 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
793 let tmp = tempfile::tempdir().unwrap();
794 let root = tmp.path().join("root");
795 let other = tmp.path().join("other");
796 std::fs::create_dir_all(&root).unwrap();
797 std::fs::create_dir_all(&other).unwrap();
798 std::fs::write(other.join("b.txt"), "no").unwrap();
799
800 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
801 assert!(
802 err.contains("path escapes project root"),
803 "error should mention escape: {err}"
804 );
805 }
806
807 #[test]
810 fn expand_user_path_expands_tilde_and_vars() {
811 let home = dirs::home_dir().expect("home dir");
812 let home_s = home.to_string_lossy().to_string();
813
814 assert_eq!(expand_user_path("~"), home);
815 assert_eq!(expand_user_path("~/code"), home.join("code"));
816 assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
817 assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
818 crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
820 assert_eq!(
821 expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
822 PathBuf::from(format!("{home_s}/sub/x"))
823 );
824 crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
825 assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
827 }
828
829 #[test]
830 fn expand_user_path_leaves_unset_vars_verbatim() {
831 crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
832 let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
833 assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
834 }
835
836 static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
839
840 #[cfg(unix)]
843 #[test]
844 fn allow_path_root_slash_permits_everything() {
845 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
846 let tmp = tempfile::tempdir().unwrap();
847 let root = tmp.path().join("root");
848 let other = tmp.path().join("other");
849 std::fs::create_dir_all(&root).unwrap();
850 std::fs::create_dir_all(&other).unwrap();
851 std::fs::write(other.join("b.txt"), "allowed").unwrap();
852
853 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
854 let result = jail_path(&other.join("b.txt"), &root);
855 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
856
857 assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
858 }
859
860 #[test]
863 fn active_relaxations_detects_allow_path_env() {
864 let _iso = crate::core::data_dir::isolated_data_dir();
865 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
866 crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
867 crate::test_env::remove_var("LEAN_CTX_ALLOW_IDE_DIRS");
868 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/tmp");
869
870 let relaxed = active_relaxations();
871
872 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
873
874 assert!(
875 relaxed.iter().any(|r| r.source == "LEAN_CTX_ALLOW_PATH"),
876 "LEAN_CTX_ALLOW_PATH must be reported as a jail relaxation: {relaxed:?}"
877 );
878 }
879
880 #[cfg(not(feature = "no-jail"))]
881 #[test]
882 fn active_relaxations_empty_when_jail_intact() {
883 let _iso = crate::core::data_dir::isolated_data_dir();
884 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
885 for var in [
886 "LEAN_CTX_ALLOW_PATH",
887 "LCTX_ALLOW_PATH",
888 "LEAN_CTX_EXTRA_ROOTS",
889 "LEAN_CTX_ALLOW_IDE_DIRS",
890 ] {
891 crate::test_env::remove_var(var);
892 }
893
894 assert!(
895 active_relaxations().is_empty(),
896 "an intact jail (clean config, no relaxation env) must report no relaxations: {:?}",
897 active_relaxations()
898 );
899 }
900
901 #[test]
902 fn allow_path_env_permits_outside_root() {
903 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
904 let tmp = tempfile::tempdir().unwrap();
905 let root = tmp.path().join("root");
906 let other = tmp.path().join("other");
907 std::fs::create_dir_all(&root).unwrap();
908 std::fs::create_dir_all(&other).unwrap();
909 std::fs::write(other.join("b.txt"), "allowed").unwrap();
910
911 let canon = canonicalize_or_self(&other);
912 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
913 let result = jail_path(&other.join("b.txt"), &root);
914 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
915
916 assert!(
917 result.is_ok(),
918 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
919 );
920 }
921
922 #[cfg(all(unix, not(feature = "no-jail")))]
923 #[test]
924 fn rejects_symlink_escape_on_unix() {
925 use std::os::unix::fs::symlink;
926
927 let _iso = crate::core::data_dir::isolated_data_dir();
930 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
931 let tmp = tempfile::tempdir().unwrap();
932 let root = tmp.path().join("root");
933 let other = tmp.path().join("other");
934 std::fs::create_dir_all(&root).unwrap();
935 std::fs::create_dir_all(&other).unwrap();
936 std::fs::write(other.join("secret.txt"), "no").unwrap();
937
938 let link = root.join("link.txt");
939 symlink(other.join("secret.txt"), &link).unwrap();
940
941 let bad = jail_path(&link, &root);
942 assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
943 }
944
945 #[test]
946 fn rejects_null_byte_in_path() {
947 let tmp = tempfile::tempdir().unwrap();
948 let root = tmp.path().join("root");
949 std::fs::create_dir_all(&root).unwrap();
950
951 let bad_path = PathBuf::from("file\0.txt");
952 let result = jail_path(&bad_path, &root);
953 assert!(result.is_err(), "null byte in path must be rejected");
954 assert!(
955 result.unwrap_err().contains("null byte"),
956 "error must mention null byte"
957 );
958 }
959
960 #[cfg(not(feature = "no-jail"))]
966 #[test]
967 fn extra_roots_permit_paths_outside_jail() {
968 let _iso = crate::core::data_dir::isolated_data_dir();
969 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
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}