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 let mut roots = canonicalized_roots(&cfg.read_only_roots, "LEAN_CTX_READ_ONLY_ROOTS");
135 roots.extend(session_read_only_roots());
140 roots
141}
142
143static SESSION_READ_ONLY_ROOTS: std::sync::OnceLock<std::sync::Mutex<Vec<PathBuf>>> =
144 std::sync::OnceLock::new();
145
146fn session_read_only_roots_cell() -> &'static std::sync::Mutex<Vec<PathBuf>> {
147 SESSION_READ_ONLY_ROOTS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
148}
149
150pub fn session_read_only_roots() -> Vec<PathBuf> {
152 session_read_only_roots_cell()
153 .lock()
154 .map(|g| g.clone())
155 .unwrap_or_default()
156}
157
158pub fn register_session_read_only_root(root: &Path) -> bool {
168 let canon = canonicalize_secure(root);
169 let mut guard = match session_read_only_roots_cell().lock() {
170 Ok(g) => g,
171 Err(poisoned) => poisoned.into_inner(),
172 };
173 if guard.iter().any(|r| r == &canon) {
174 return false;
175 }
176 guard.push(canon);
177 true
178}
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub struct JailRelaxation {
188 pub source: &'static str,
190 pub detail: &'static str,
192}
193
194fn env_is_set(var: &str) -> bool {
195 std::env::var(var).is_ok_and(|v| !v.trim().is_empty())
196}
197
198#[must_use]
202pub fn active_relaxations() -> Vec<JailRelaxation> {
203 let mut out = Vec::new();
204
205 if cfg!(feature = "no-jail") {
206 out.push(JailRelaxation {
207 source: "no-jail (build feature)",
208 detail: "path jail compiled out — every tool path is allowed",
209 });
210 }
211
212 if crate::core::config::Config::load().path_jail == Some(false) {
213 out.push(JailRelaxation {
214 source: "path_jail = false (config.toml)",
215 detail: "path jail disabled — every tool path is allowed",
216 });
217 }
218
219 if env_is_set("LEAN_CTX_ALLOW_PATH") || env_is_set("LCTX_ALLOW_PATH") {
220 out.push(JailRelaxation {
221 source: "LEAN_CTX_ALLOW_PATH",
222 detail: "widens the read/write allow-list beyond the project root",
223 });
224 }
225
226 if env_is_set("LEAN_CTX_EXTRA_ROOTS") {
227 out.push(JailRelaxation {
228 source: "LEAN_CTX_EXTRA_ROOTS",
229 detail: "adds extra accessible roots beyond the project root",
230 });
231 }
232
233 let ide_env = std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
234 if ide_env
235 || crate::core::config::Config::load()
236 .allow_ide_config_dirs
237 .unwrap_or(false)
238 {
239 out.push(JailRelaxation {
240 source: if ide_env {
241 "LEAN_CTX_ALLOW_IDE_DIRS=1"
242 } else {
243 "allow_ide_config_dirs = true (config.toml)"
244 },
245 detail: "exposes ~/.cursor, ~/.claude, … (other agents' sessions/credentials) to tools",
246 });
247 }
248
249 out
250}
251
252pub fn warn_if_relaxed() {
256 for relaxation in active_relaxations() {
257 tracing::warn!(
258 "[SECURITY] path jail relaxed via {}: {} — intended for trusted local use only",
259 relaxation.source,
260 relaxation.detail
261 );
262 }
263}
264
265pub fn is_read_only_path(candidate: &Path) -> bool {
276 let roots = read_only_roots_from_env_and_config();
277 if roots.is_empty() {
278 return false;
279 }
280
281 let base = match canonicalize_existing_ancestor(candidate) {
285 Some((base, remainder)) => {
286 let mut p = base;
287 for part in remainder.iter().rev() {
288 p.push(part);
289 }
290 p
291 }
292 None => canonicalize_or_self(candidate),
293 };
294
295 roots.iter().any(|r| is_under_prefix(&base, r))
296}
297
298pub fn enforce_writable(candidate: &Path) -> Result<(), String> {
307 if is_read_only_path(candidate) {
308 return Err(format!(
309 "path is inside a read-only root — writes are denied (read_only_roots): {}",
310 candidate.display()
311 ));
312 }
313 Ok(())
314}
315
316fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
327 let mut out = Vec::new();
328 if ide_dirs_allowed {
329 let targets = crate::core::editor_registry::build_targets(home);
330 collect_ide_allow_dirs(home, &targets, &mut out);
331 }
332 out
333}
334
335fn collect_ide_allow_dirs(
344 home: &Path,
345 targets: &[crate::core::editor_registry::EditorTarget],
346 out: &mut Vec<PathBuf>,
347) {
348 let mut seen: std::collections::HashSet<PathBuf> = out.iter().cloned().collect();
349 for target in targets {
350 let candidates = [
351 target.config_path.parent().map(Path::to_path_buf),
352 Some(target.detect_path.clone()),
353 ];
354 for cand in candidates.into_iter().flatten() {
355 if cand.as_path() == home || !cand.starts_with(home) || !cand.is_dir() {
356 continue;
357 }
358 let resolved = canonicalize_secure(&cand);
359 if seen.insert(resolved.clone()) {
360 out.push(resolved);
361 }
362 }
363 }
364}
365
366fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
367 path.starts_with(prefix)
368}
369
370pub fn canonicalize_or_self(path: &Path) -> PathBuf {
374 super::pathutil::safe_canonicalize_bounded(path, 2000)
375}
376
377fn canonicalize_secure(path: &Path) -> PathBuf {
382 super::pathutil::canonicalize_secure_bounded(path, 2000)
383}
384
385fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
386 let mut cur = path.to_path_buf();
387 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
388 loop {
389 if cur.exists() {
390 return Some((canonicalize_secure(&cur), remainder));
391 }
392 let name = cur.file_name()?.to_os_string();
393 remainder.push(name);
394 if !cur.pop() {
395 return None;
396 }
397 }
398}
399
400pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, PathJailError> {
401 jail_path_with_roots(candidate, jail_root, &[])
402}
403
404const LANGUAGE_CACHE_PATTERNS: &[(&str, &str, &str)] = &[
409 ("/go/pkg/mod/", "Go module cache", "~/go/pkg/mod"),
410 (
411 "/.cargo/registry/",
412 "Rust crate registry",
413 "~/.cargo/registry",
414 ),
415 (
416 "/site-packages/",
417 "Python site-packages",
418 "<venv>/lib/pythonX.Y/site-packages",
419 ),
420 ("/node_modules/", "Node modules", "<project>/node_modules"),
421 (
422 "/.m2/repository/",
423 "Maven local repository",
424 "~/.m2/repository",
425 ),
426 ("/.gradle/caches/", "Gradle cache", "~/.gradle/caches"),
427 (
428 "/.nuget/packages/",
429 "NuGet package cache",
430 "~/.nuget/packages",
431 ),
432];
433
434fn detected_cache_hint(candidate: &std::path::Path) -> Option<String> {
438 let s = candidate.to_string_lossy();
439 for &(pattern, name, example) in LANGUAGE_CACHE_PATTERNS {
440 if s.contains(pattern) {
441 return Some(format!(
442 ". Detected {name} — add read_only_roots = [\"{example}\"] to \
443 ~/.config/lean-ctx/config.toml for cached, compressed reads without write access"
444 ));
445 }
446 }
447 None
448}
449
450pub fn detect_language_cache_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
455 let s = candidate.to_string_lossy().replace('\\', "/");
456 for &(marker, label, _) in LANGUAGE_CACHE_PATTERNS {
457 if let Some(idx) = s.find(marker) {
458 let end = idx + marker.len() - 1; return Some((label, PathBuf::from(&s[..end])));
460 }
461 }
462 None
463}
464
465pub fn jail_path_with_roots(
475 candidate: &Path,
476 jail_root: &Path,
477 extra_roots: &[String],
478) -> Result<PathBuf, PathJailError> {
479 if candidate.to_string_lossy().as_bytes().contains(&0) {
480 return Err(PathJailError::NullByte);
481 }
482
483 #[cfg(feature = "no-jail")]
484 {
485 let _ = (jail_root, extra_roots);
486 return Ok(canonicalize_or_self(candidate));
487 }
488
489 #[allow(unreachable_code)]
490 {
491 let cfg = crate::core::config::Config::load();
492 if cfg.path_jail == Some(false) {
493 return Ok(canonicalize_or_self(candidate));
494 }
495
496 let root = canonicalize_secure(jail_root);
497
498 let resolved: PathBuf;
503 let candidate: &Path = if candidate.is_absolute() {
504 candidate
505 } else {
506 resolved = root.join(candidate);
507 resolved.as_path()
508 };
509
510 let mut allow = allow_paths_from_env_and_config();
511 allow.extend(
513 extra_roots
514 .iter()
515 .filter(|r| !r.is_empty())
516 .map(|r| canonicalize_secure(Path::new(r))),
517 );
518
519 if let Ok(state) = crate::core::paths::state_dir() {
523 allow.push(canonicalize_secure(&state));
524 }
525 allow.extend(
528 read_only_roots_from_env_and_config()
529 .into_iter()
530 .map(|p| canonicalize_secure(&p)),
531 );
532
533 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
534 PathJailError::NoExistingAncestor {
535 path: candidate.to_path_buf(),
536 }
537 })?;
538
539 let allowed =
540 is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
541
542 #[cfg(windows)]
543 let allowed = allowed || is_under_prefix_windows(&base, &root);
544
545 if !allowed {
546 let mut hint = if crate::core::protocol::meta_visible() {
547 let dir = candidate.parent().unwrap_or(candidate).display();
548 format!(
549 ". Hint: set LEAN_CTX_ALLOW_PATH={dir} for read-write access \
550 (colon-separated for multiple: /path/a:/path/b), \
551 LEAN_CTX_READ_ONLY_ROOTS={dir} for read-only, \
552 or add entries to allow_paths = [\"{dir}\"] or extra_roots = [\"{dir}\"] \
553 in ~/.config/lean-ctx/config.toml"
554 )
555 } else {
556 ". Fix (additive): add the directory to extra_roots or allow_paths in \
561 ~/.config/lean-ctx/config.toml — `lean-ctx doctor` shows the config in effect"
562 .to_string()
563 };
564 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
568 hint.push_str(". ");
569 hint.push_str(¬ice);
570 }
571 if let Some(missing) = crate::core::config::Config::missing_config_path() {
575 hint.push_str(&format!(
576 ". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
577 allow_paths edit in a config.toml elsewhere is not read; \
578 `lean-ctx doctor` shows the path in effect",
579 missing.display()
580 ));
581 }
582 if let Some(cache_hint) = detected_cache_hint(candidate) {
583 hint.push_str(&cache_hint);
584 }
585 return Err(PathJailError::EscapesRoot {
586 path: candidate.to_path_buf(),
587 root,
588 hint,
589 });
590 }
591
592 #[cfg(windows)]
593 reject_symlink_on_windows(candidate)?;
594
595 let mut out = base;
596 for part in remainder.iter().rev() {
597 out.push(part);
598 }
599
600 if out.exists() {
603 let final_canon = canonicalize_secure(&out);
604 let final_ok = is_under_prefix(&final_canon, &root)
605 || allow.iter().any(|p| is_under_prefix(&final_canon, p));
606 #[cfg(windows)]
607 let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
608 if !final_ok {
609 return Err(PathJailError::PostCanonicalizeEscape {
610 path: candidate.to_path_buf(),
611 resolved: final_canon,
612 });
613 }
614 }
615
616 Ok(out)
617 }
618}
619
620#[cfg(windows)]
621fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
622 let path_str = normalize_windows_path(&path.to_string_lossy());
623 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
624 path_str.starts_with(&prefix_str)
625}
626
627#[cfg(windows)]
628fn normalize_windows_path(s: &str) -> String {
629 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
630 stripped.to_lowercase().replace('/', "\\")
631}
632
633#[cfg(windows)]
634fn reject_symlink_on_windows(path: &Path) -> Result<(), PathJailError> {
635 if let Ok(meta) = std::fs::symlink_metadata(path) {
636 if super::pathutil::is_symlink_or_reparse(&meta) {
639 return Err(PathJailError::Symlink {
640 path: path.to_path_buf(),
641 });
642 }
643 }
644 Ok(())
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 #[cfg(not(feature = "no-jail"))]
652 #[test]
653 fn rejects_path_outside_root() {
654 let _iso = crate::core::data_dir::isolated_data_dir();
659 let tmp = tempfile::tempdir().unwrap();
660 let root = tmp.path().join("root");
661 let other = tmp.path().join("other");
662 std::fs::create_dir_all(&root).unwrap();
663 std::fs::create_dir_all(&other).unwrap();
664 std::fs::write(root.join("a.txt"), "ok").unwrap();
665 std::fs::write(other.join("b.txt"), "no").unwrap();
666
667 let ok = jail_path(&root.join("a.txt"), &root);
668 assert!(ok.is_ok());
669
670 let bad = jail_path(&other.join("b.txt"), &root);
671 assert!(bad.is_err());
672 }
673
674 #[cfg(not(feature = "no-jail"))]
681 #[test]
682 fn read_only_roots_deny_writes_but_allow_reads() {
683 let _iso = crate::core::data_dir::isolated_data_dir();
684
685 let tmp = tempfile::tempdir().unwrap();
686 let project = tmp.path().join("project");
687 let refrepo = tmp.path().join("refrepo");
688 std::fs::create_dir_all(&project).unwrap();
689 std::fs::create_dir_all(refrepo.join("sub")).unwrap();
690 std::fs::write(refrepo.join("lib.rs"), "pub fn x() {}\n").unwrap();
691
692 let ro_canon = canonicalize_secure(&refrepo);
695 crate::test_env::set_var(
696 "LEAN_CTX_READ_ONLY_ROOTS",
697 ro_canon.to_string_lossy().as_ref(),
698 );
699
700 let existing = refrepo.join("lib.rs");
701 let new_file = refrepo.join("sub").join("new.rs");
702 let proj_file = project.join("main.rs");
703
704 let read_existing = jail_path(&existing, &project);
706 let deny_existing = enforce_writable(&existing);
707 let deny_new = enforce_writable(&new_file);
708 let allow_project = enforce_writable(&proj_file);
709 let ro_existing = is_read_only_path(&existing);
710 let ro_project = is_read_only_path(&proj_file);
711
712 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
713
714 assert!(
715 deny_existing.is_err(),
716 "write to an existing file in a read-only root must be denied"
717 );
718 assert!(
719 deny_new.is_err(),
720 "creating a new file in a read-only root must be denied"
721 );
722 assert!(
723 allow_project.is_ok(),
724 "writes into the project root must stay allowed: {allow_project:?}"
725 );
726 assert!(
727 read_existing.is_ok(),
728 "reads inside a read-only root must resolve (read allow-list): {read_existing:?}"
729 );
730 assert!(ro_existing, "the file is inside the read-only root");
731 assert!(!ro_project, "the project file is not read-only");
732 }
733
734 #[cfg(not(feature = "no-jail"))]
740 #[test]
741 fn honors_path_jail_false_after_mtime_preserving_edit() {
742 let _iso = crate::core::data_dir::isolated_data_dir();
743 let cfg_path = crate::core::config::Config::path().unwrap();
744 if let Some(parent) = cfg_path.parent() {
745 std::fs::create_dir_all(parent).unwrap();
746 }
747
748 let tmp = tempfile::tempdir().unwrap();
749 let root = tmp.path().join("project");
750 let outside = tmp.path().join("outside");
751 std::fs::create_dir_all(&root).unwrap();
752 std::fs::create_dir_all(&outside).unwrap();
753 let secret = outside.join("secret.txt");
754 std::fs::write(&secret, "x").unwrap();
755
756 std::fs::write(&cfg_path, "# jail on\n").unwrap();
758 let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
759 assert_eq!(crate::core::config::Config::load().path_jail, None);
760
761 std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
764 filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
765
766 assert!(
767 jail_path(&secret, &root).is_ok(),
768 "path_jail=false must take effect without a fresh process (#406)"
769 );
770 }
771
772 #[test]
773 fn allows_nonexistent_child_under_root() {
774 let tmp = tempfile::tempdir().unwrap();
775 let root = tmp.path().join("root");
776 std::fs::create_dir_all(&root).unwrap();
777 std::fs::write(root.join("a.txt"), "ok").unwrap();
778
779 let p = root.join("new").join("file.txt");
780 let ok = jail_path(&p, &root).unwrap();
781 assert!(ok.to_string_lossy().contains("file.txt"));
782 }
783
784 #[cfg(not(feature = "no-jail"))]
785 #[test]
786 fn relative_candidate_resolves_against_root_not_cwd() {
787 let _iso = crate::core::data_dir::isolated_data_dir();
790 let tmp = tempfile::tempdir().unwrap();
791 let root = tmp.path().join("project");
792 std::fs::create_dir_all(root.join("sub")).unwrap();
793 std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
794
795 let jailed = jail_path(Path::new("sub/file.rs"), &root)
796 .expect("relative candidate should resolve under the jail root");
797 assert!(jailed.ends_with("sub/file.rs"));
798 assert!(
799 is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
800 "resolved path must live under the jail root: {jailed:?}"
801 );
802 }
803
804 #[test]
805 fn ide_allow_dirs_are_registry_derived_and_skip_home() {
806 use crate::core::editor_registry::{ConfigType, EditorTarget};
807
808 let home = tempfile::tempdir().unwrap();
809 let h = home.path();
810 std::fs::create_dir_all(h.join("Library/Application Support/Code/User")).unwrap();
813 std::fs::create_dir_all(h.join(".cursor")).unwrap();
814
815 let targets = vec![
816 EditorTarget {
817 name: "VS Code",
818 agent_key: "vscode".into(),
819 config_path: h.join("Library/Application Support/Code/User/mcp.json"),
820 detect_path: h.join("Library/Application Support/Code"),
821 config_type: ConfigType::VsCodeMcp,
822 },
823 EditorTarget {
824 name: "Cursor",
825 agent_key: "cursor".into(),
826 config_path: h.join(".cursor/mcp.json"),
827 detect_path: h.join(".cursor"),
828 config_type: ConfigType::McpJson,
829 },
830 EditorTarget {
832 name: "Claude Code",
833 agent_key: "claude".into(),
834 config_path: h.join(".claude.json"),
835 detect_path: h.join(".no-such-dir"),
836 config_type: ConfigType::McpJson,
837 },
838 ];
839
840 let mut out = Vec::new();
841 collect_ide_allow_dirs(h, &targets, &mut out);
842
843 assert!(
844 out.iter().any(|p| p.ends_with("Code/User")),
845 "non-dotfile VS Code dir must be covered: {out:?}"
846 );
847 assert!(out.iter().any(|p| p.ends_with(".cursor")), "{out:?}");
848 let home_canon = canonicalize_secure(h);
849 assert!(
850 !out.contains(&home_canon),
851 "must never widen the jail to $HOME: {out:?}"
852 );
853 }
854
855 #[test]
860 fn ide_config_dirs_are_excluded_by_default() {
861 let home = tempfile::tempdir().unwrap();
862 for d in [".lean-ctx", ".cursor", ".codex"] {
863 std::fs::create_dir_all(home.path().join(d)).unwrap();
864 }
865
866 let denied = home_allow_dirs(home.path(), false);
867 assert!(
868 denied.is_empty(),
869 "foreign editor dirs must stay jailed by default: {denied:?}"
870 );
871
872 let allowed = home_allow_dirs(home.path(), true);
877 assert!(
878 allowed.iter().any(|p| p.ends_with(".cursor")),
879 "opt-in must expose editor dirs: {allowed:?}"
880 );
881 assert!(
882 !allowed.iter().any(|p| p.ends_with(".lean-ctx")),
883 "lean-ctx's own dir is covered by the data_dir root, not home_allow_dirs: {allowed:?}"
884 );
885 }
886
887 #[test]
888 fn canonicalize_or_self_strips_verbatim() {
889 let tmp = tempfile::tempdir().unwrap();
890 let dir = tmp.path().join("project");
891 std::fs::create_dir_all(&dir).unwrap();
892
893 let result = canonicalize_or_self(&dir);
894 let s = result.to_string_lossy();
895 assert!(
896 !s.starts_with(r"\\?\"),
897 "canonicalize_or_self should strip verbatim prefix, got: {s}"
898 );
899 }
900
901 #[test]
902 fn jail_path_accepts_same_dir_different_format() {
903 let tmp = tempfile::tempdir().unwrap();
904 let root = tmp.path().join("project");
905 std::fs::create_dir_all(&root).unwrap();
906 std::fs::write(root.join("file.rs"), "ok").unwrap();
907
908 let result = jail_path(&root.join("file.rs"), &root);
909 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
910 }
911
912 #[cfg(not(feature = "no-jail"))]
913 #[test]
914 fn error_message_contains_escape_info() {
915 let _iso = crate::core::data_dir::isolated_data_dir();
918 let tmp = tempfile::tempdir().unwrap();
919 let root = tmp.path().join("root");
920 let other = tmp.path().join("other");
921 std::fs::create_dir_all(&root).unwrap();
922 std::fs::create_dir_all(&other).unwrap();
923 std::fs::write(other.join("b.txt"), "no").unwrap();
924
925 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
926 assert!(
927 err.to_string().contains("path escapes project root"),
928 "error should mention escape: {err}"
929 );
930 }
931
932 #[cfg(not(feature = "no-jail"))]
936 #[test]
937 fn escape_error_names_config_keys_without_meta() {
938 let _iso = crate::core::data_dir::isolated_data_dir();
939 crate::test_env::remove_var("LEAN_CTX_META");
940 crate::test_env::remove_var("LEAN_CTX_DIAGNOSTICS");
941 let tmp = tempfile::tempdir().unwrap();
942 let root = tmp.path().join("root");
943 let other = tmp.path().join("other");
944 std::fs::create_dir_all(&root).unwrap();
945 std::fs::create_dir_all(&other).unwrap();
946 std::fs::write(other.join("b.txt"), "no").unwrap();
947
948 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
949 let msg = err.to_string();
950 assert!(
951 msg.contains("extra_roots") && msg.contains("allow_paths"),
952 "agent-visible escape error should name the config keys: {msg}"
953 );
954 }
955
956 #[test]
959 fn expand_user_path_expands_tilde_and_vars() {
960 let home = dirs::home_dir().expect("home dir");
961 let home_s = home.to_string_lossy().to_string();
962
963 assert_eq!(expand_user_path("~"), home);
964 assert_eq!(expand_user_path("~/code"), home.join("code"));
965 assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
966 assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
967 crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
969 assert_eq!(
970 expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
971 PathBuf::from(format!("{home_s}/sub/x"))
972 );
973 crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
974 assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
976 }
977
978 #[test]
979 fn expand_user_path_leaves_unset_vars_verbatim() {
980 crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
981 let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
982 assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
983 }
984
985 #[cfg(unix)]
998 #[test]
999 fn allow_path_root_slash_permits_everything() {
1000 let _guard = crate::core::data_dir::test_env_lock();
1001 let tmp = tempfile::tempdir().unwrap();
1002 let root = tmp.path().join("root");
1003 let other = tmp.path().join("other");
1004 std::fs::create_dir_all(&root).unwrap();
1005 std::fs::create_dir_all(&other).unwrap();
1006 std::fs::write(other.join("b.txt"), "allowed").unwrap();
1007
1008 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
1009 let result = jail_path(&other.join("b.txt"), &root);
1010 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
1011
1012 assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
1013 }
1014
1015 #[test]
1018 fn active_relaxations_detects_allow_path_env() {
1019 let _iso = crate::core::data_dir::isolated_data_dir();
1020 crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
1021 crate::test_env::remove_var("LEAN_CTX_ALLOW_IDE_DIRS");
1022 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/tmp");
1023
1024 let relaxed = active_relaxations();
1025
1026 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
1027
1028 assert!(
1029 relaxed.iter().any(|r| r.source == "LEAN_CTX_ALLOW_PATH"),
1030 "LEAN_CTX_ALLOW_PATH must be reported as a jail relaxation: {relaxed:?}"
1031 );
1032 }
1033
1034 #[cfg(not(feature = "no-jail"))]
1035 #[test]
1036 fn active_relaxations_empty_when_jail_intact() {
1037 let _iso = crate::core::data_dir::isolated_data_dir();
1038 for var in [
1039 "LEAN_CTX_ALLOW_PATH",
1040 "LCTX_ALLOW_PATH",
1041 "LEAN_CTX_EXTRA_ROOTS",
1042 "LEAN_CTX_ALLOW_IDE_DIRS",
1043 ] {
1044 crate::test_env::remove_var(var);
1045 }
1046
1047 assert!(
1048 active_relaxations().is_empty(),
1049 "an intact jail (clean config, no relaxation env) must report no relaxations: {:?}",
1050 active_relaxations()
1051 );
1052 }
1053
1054 #[test]
1055 fn allow_path_env_permits_outside_root() {
1056 let _guard = crate::core::data_dir::test_env_lock();
1057 let tmp = tempfile::tempdir().unwrap();
1058 let root = tmp.path().join("root");
1059 let other = tmp.path().join("other");
1060 std::fs::create_dir_all(&root).unwrap();
1061 std::fs::create_dir_all(&other).unwrap();
1062 std::fs::write(other.join("b.txt"), "allowed").unwrap();
1063
1064 let canon = canonicalize_or_self(&other);
1065 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
1066 let result = jail_path(&other.join("b.txt"), &root);
1067 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
1068
1069 assert!(
1070 result.is_ok(),
1071 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
1072 );
1073 }
1074
1075 #[cfg(all(unix, not(feature = "no-jail")))]
1076 #[test]
1077 fn rejects_symlink_escape_on_unix() {
1078 use std::os::unix::fs::symlink;
1079
1080 let _iso = crate::core::data_dir::isolated_data_dir();
1083 let tmp = tempfile::tempdir().unwrap();
1084 let root = tmp.path().join("root");
1085 let other = tmp.path().join("other");
1086 std::fs::create_dir_all(&root).unwrap();
1087 std::fs::create_dir_all(&other).unwrap();
1088 std::fs::write(other.join("secret.txt"), "no").unwrap();
1089
1090 let link = root.join("link.txt");
1091 symlink(other.join("secret.txt"), &link).unwrap();
1092
1093 let bad = jail_path(&link, &root);
1094 assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
1095 }
1096
1097 #[test]
1098 fn rejects_null_byte_in_path() {
1099 let tmp = tempfile::tempdir().unwrap();
1100 let root = tmp.path().join("root");
1101 std::fs::create_dir_all(&root).unwrap();
1102
1103 let bad_path = PathBuf::from("file\0.txt");
1104 let result = jail_path(&bad_path, &root);
1105 assert!(result.is_err(), "null byte in path must be rejected");
1106 assert!(
1107 result.unwrap_err().to_string().contains("null byte"),
1108 "error must mention null byte"
1109 );
1110 }
1111
1112 #[cfg(not(feature = "no-jail"))]
1118 #[test]
1119 fn extra_roots_permit_paths_outside_jail() {
1120 let _iso = crate::core::data_dir::isolated_data_dir();
1121
1122 let tmp = tempfile::tempdir().unwrap();
1123 let root = tmp.path().join("project");
1124 let worktree = tmp.path().join("worktree");
1125 let elsewhere = tmp.path().join("elsewhere");
1126 for d in [&root, &worktree, &elsewhere] {
1127 std::fs::create_dir_all(d).unwrap();
1128 }
1129 let in_worktree = worktree.join("a.txt");
1130 std::fs::write(&in_worktree, "x").unwrap();
1131 let outside = elsewhere.join("b.txt");
1132 std::fs::write(&outside, "y").unwrap();
1133
1134 assert!(jail_path(&in_worktree, &root).is_err());
1136 assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
1137
1138 let extra = vec![worktree.to_string_lossy().to_string()];
1141 assert!(
1142 jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
1143 "path under a session extra_root must resolve (#403)"
1144 );
1145
1146 assert!(
1148 jail_path_with_roots(&outside, &root, &extra).is_err(),
1149 "paths outside ALL roots must still be rejected"
1150 );
1151
1152 assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
1154 }
1155
1156 #[test]
1158 fn state_dir_tee_files_pass_jail() {
1159 let _lock = crate::core::data_dir::test_env_lock();
1160 let state = crate::core::paths::state_dir().expect("state_dir must be available");
1161 let tee_path = state.join("tee").join("some_command_deadbeef.log");
1162 let fake_root = std::env::temp_dir().join("pathjail_test_820_root");
1164 std::fs::create_dir_all(&fake_root).ok();
1165 let result = jail_path_with_roots(&tee_path, &fake_root, &[]);
1168 if state.exists() {
1172 assert!(
1173 result.is_ok(),
1174 "tee-file path under lean-ctx state dir must be auto-allowed: {result:?}"
1175 );
1176 }
1177 std::fs::remove_dir_all(&fake_root).ok();
1178 }
1179
1180 #[test]
1181 fn detected_cache_hint_recognizes_go_cargo_python() {
1182 use std::path::Path;
1183 let go = detected_cache_hint(Path::new("/Users/x/go/pkg/mod/github.com/foo/bar/main.go"));
1184 assert!(go.is_some(), "Go module cache should be detected");
1185 assert!(go.unwrap().contains("Go module cache"));
1186
1187 let cargo = detected_cache_hint(Path::new(
1188 "/home/x/.cargo/registry/src/crates.io/serde-1.0/lib.rs",
1189 ));
1190 assert!(cargo.is_some(), "Rust cargo registry should be detected");
1191 assert!(cargo.unwrap().contains("Rust crate registry"));
1192
1193 let py = detected_cache_hint(Path::new(
1194 "/usr/lib/python3.12/site-packages/requests/api.py",
1195 ));
1196 assert!(py.is_some(), "Python site-packages should be detected");
1197
1198 let normal = detected_cache_hint(Path::new("/home/x/projects/myapp/src/main.rs"));
1199 assert!(normal.is_none(), "Normal project path should not match");
1200 }
1201
1202 #[test]
1203 fn detect_cache_root_extracts_marker_dir() {
1204 let cases = [
1205 (
1206 "/Users/x/go/pkg/mod/github.com/foo/bar@v1.2.3/baz.go",
1207 "Go module cache",
1208 "/Users/x/go/pkg/mod",
1209 ),
1210 (
1211 "/home/u/.cargo/registry/src/index-abc/serde-1.0/src/lib.rs",
1212 "Rust crate registry",
1213 "/home/u/.cargo/registry",
1214 ),
1215 (
1216 "/opt/venv/lib/python3.12/site-packages/requests/api.py",
1217 "Python site-packages",
1218 "/opt/venv/lib/python3.12/site-packages",
1219 ),
1220 (
1221 "/w/app/node_modules/react/index.js",
1222 "Node modules",
1223 "/w/app/node_modules",
1224 ),
1225 ];
1226 for (path, want_label, want_root) in cases {
1227 let (label, root) = detect_language_cache_root(Path::new(path))
1228 .unwrap_or_else(|| panic!("expected cache match for {path}"));
1229 assert_eq!(label, want_label, "label for {path}");
1230 assert_eq!(root, PathBuf::from(want_root), "root for {path}");
1231 }
1232 assert!(
1233 detect_language_cache_root(Path::new("/home/u/proj/src/main.rs")).is_none(),
1234 "a normal project path is not a cache"
1235 );
1236 }
1237
1238 #[cfg(not(feature = "no-jail"))]
1242 #[test]
1243 fn registered_cache_root_reads_allow_writes_deny() {
1244 let _iso = crate::core::data_dir::isolated_data_dir();
1245
1246 let tmp = tempfile::tempdir().unwrap();
1247 let dep = tmp.path().join("go/pkg/mod/example.com/lib@v1");
1249 std::fs::create_dir_all(&dep).unwrap();
1250 let file = dep.join("lib.go");
1251 std::fs::write(&file, "package lib").unwrap();
1252
1253 let project = tmp.path().join("project");
1255 std::fs::create_dir_all(&project).unwrap();
1256
1257 assert!(jail_path_with_roots(&file, &project, &[]).is_err());
1259
1260 let (_, root) = detect_language_cache_root(&file).expect("cache match");
1262 assert!(
1263 register_session_read_only_root(&root),
1264 "first register is new"
1265 );
1266 assert!(
1267 !register_session_read_only_root(&root),
1268 "re-register is a no-op"
1269 );
1270
1271 assert!(
1273 jail_path_with_roots(&file, &project, &[]).is_ok(),
1274 "registered cache root must be readable"
1275 );
1276 assert!(is_read_only_path(&file), "cache file is read-only");
1277 assert!(
1278 enforce_writable(&file).is_err(),
1279 "writes into the cache root must be denied"
1280 );
1281 }
1282}