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