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 for path in crate::core::runtime_flags::allow_paths() {
81 out.push(canonicalize_secure(&path));
82 }
83 let v = std::env::var("LCTX_ALLOW_PATH")
86 .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
87 .unwrap_or_default();
88 if !v.trim().is_empty() {
89 for p in std::env::split_paths(&v) {
90 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
91 }
92 }
93
94 let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
95 if !extra.trim().is_empty() {
96 for p in std::env::split_paths(&extra) {
97 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
98 }
99 }
100
101 out.extend(canonicalized_roots(
106 &cfg.read_only_roots,
107 "LEAN_CTX_READ_ONLY_ROOTS",
108 ));
109
110 out
111}
112
113fn canonicalized_roots(config_entries: &[String], env_var: &str) -> Vec<PathBuf> {
118 let mut out = Vec::new();
119 for p in config_entries {
120 out.push(canonicalize_secure(&expand_user_path(p)));
121 }
122 let v = std::env::var(env_var).unwrap_or_default();
123 if !v.trim().is_empty() {
124 for p in std::env::split_paths(&v) {
125 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
126 }
127 }
128 out
129}
130
131pub fn read_only_roots_from_env_and_config() -> Vec<PathBuf> {
136 let cfg = crate::core::config::Config::load();
137 let mut roots = canonicalized_roots(&cfg.read_only_roots, "LEAN_CTX_READ_ONLY_ROOTS");
138 roots.extend(session_read_only_roots());
143 roots
144}
145
146static SESSION_READ_ONLY_ROOTS: std::sync::OnceLock<std::sync::Mutex<Vec<PathBuf>>> =
147 std::sync::OnceLock::new();
148
149fn session_read_only_roots_cell() -> &'static std::sync::Mutex<Vec<PathBuf>> {
150 SESSION_READ_ONLY_ROOTS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
151}
152
153pub fn session_read_only_roots() -> Vec<PathBuf> {
155 session_read_only_roots_cell()
156 .lock()
157 .map(|g| g.clone())
158 .unwrap_or_default()
159}
160
161pub fn register_session_read_only_root(root: &Path) -> bool {
171 let canon = canonicalize_secure(root);
172 let mut guard = match session_read_only_roots_cell().lock() {
173 Ok(g) => g,
174 Err(poisoned) => poisoned.into_inner(),
175 };
176 if guard.iter().any(|r| r == &canon) {
177 return false;
178 }
179 guard.push(canon);
180 true
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
190pub struct JailRelaxation {
191 pub source: &'static str,
193 pub detail: &'static str,
195}
196
197fn env_is_set(var: &str) -> bool {
198 std::env::var(var).is_ok_and(|v| !v.trim().is_empty())
199}
200
201#[must_use]
205pub fn active_relaxations() -> Vec<JailRelaxation> {
206 let mut out = Vec::new();
207
208 if cfg!(feature = "no-jail") {
209 out.push(JailRelaxation {
210 source: "no-jail (build feature)",
211 detail: "path jail compiled out — every tool path is allowed",
212 });
213 }
214
215 if crate::core::config::Config::load().path_jail == Some(false) {
216 out.push(JailRelaxation {
217 source: "path_jail = false (config.toml)",
218 detail: "path jail disabled — every tool path is allowed",
219 });
220 }
221
222 if crate::core::runtime_flags::allow_path_enabled() {
223 out.push(JailRelaxation {
224 source: "LEAN_CTX_ALLOW_PATH",
225 detail: "widens the read/write allow-list beyond the project root",
226 });
227 }
228
229 if env_is_set("LEAN_CTX_EXTRA_ROOTS") {
230 out.push(JailRelaxation {
231 source: "LEAN_CTX_EXTRA_ROOTS",
232 detail: "adds extra accessible roots beyond the project root",
233 });
234 }
235
236 let ide_env = std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
237 if ide_env
238 || crate::core::config::Config::load()
239 .allow_ide_config_dirs
240 .unwrap_or(false)
241 {
242 out.push(JailRelaxation {
243 source: if ide_env {
244 "LEAN_CTX_ALLOW_IDE_DIRS=1"
245 } else {
246 "allow_ide_config_dirs = true (config.toml)"
247 },
248 detail: "exposes ~/.cursor, ~/.claude, … (other agents' sessions/credentials) to tools",
249 });
250 }
251
252 out
253}
254
255pub fn warn_if_relaxed() {
259 for relaxation in active_relaxations() {
260 tracing::warn!(
261 "[SECURITY] path jail relaxed via {}: {} — intended for trusted local use only",
262 relaxation.source,
263 relaxation.detail
264 );
265 }
266}
267
268pub fn is_read_only_path(candidate: &Path) -> bool {
279 let roots = read_only_roots_from_env_and_config();
280 if roots.is_empty() {
281 return false;
282 }
283
284 let base = match canonicalize_existing_ancestor(candidate) {
288 Some((base, remainder)) => {
289 let mut p = base;
290 for part in remainder.iter().rev() {
291 p.push(part);
292 }
293 p
294 }
295 None => canonicalize_or_self(candidate),
296 };
297
298 roots.iter().any(|r| is_under_prefix(&base, r))
299}
300
301pub fn enforce_writable(candidate: &Path) -> Result<(), String> {
310 if is_read_only_path(candidate) {
311 return Err(format!(
312 "path is inside a read-only root — writes are denied (read_only_roots): {}",
313 candidate.display()
314 ));
315 }
316 Ok(())
317}
318
319fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
330 let mut out = Vec::new();
331 if ide_dirs_allowed {
332 let targets = crate::core::editor_registry::build_targets(home);
333 collect_ide_allow_dirs(home, &targets, &mut out);
334 }
335 out
336}
337
338fn collect_ide_allow_dirs(
347 home: &Path,
348 targets: &[crate::core::editor_registry::EditorTarget],
349 out: &mut Vec<PathBuf>,
350) {
351 let mut seen: std::collections::HashSet<PathBuf> = out.iter().cloned().collect();
352 for target in targets {
353 let candidates = [
354 target.config_path.parent().map(Path::to_path_buf),
355 Some(target.detect_path.clone()),
356 ];
357 for cand in candidates.into_iter().flatten() {
358 if cand.as_path() == home || !cand.starts_with(home) || !cand.is_dir() {
359 continue;
360 }
361 let resolved = canonicalize_secure(&cand);
362 if seen.insert(resolved.clone()) {
363 out.push(resolved);
364 }
365 }
366 }
367}
368
369fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
370 path.starts_with(prefix)
371}
372
373pub fn is_harness_auto_memory_path(path: &Path) -> bool {
382 let lower = path.to_string_lossy().replace('\\', "/").to_lowercase();
383 for marker in ["/.claude/projects/", "/.codebuddy/projects/"] {
384 if let Some(idx) = lower.find(marker) {
385 let after = &lower[idx + marker.len()..];
386 let mut parts = after.split('/');
387 let Some(_slug) = parts.next() else {
388 continue;
389 };
390 if parts.next() == Some("memory") {
391 return true;
392 }
393 }
394 }
395 false
396}
397
398fn path_allowed_by_jail(base: &Path, root: &Path, allow: &[PathBuf]) -> bool {
399 let allowed = is_under_prefix(base, root)
400 || allow.iter().any(|p| is_under_prefix(base, p))
401 || is_harness_auto_memory_path(base);
402 #[cfg(windows)]
403 let allowed = allowed || is_under_prefix_windows(base, root);
404 allowed
405}
406
407pub fn canonicalize_or_self(path: &Path) -> PathBuf {
411 super::pathutil::safe_canonicalize_bounded(path, 2000)
412}
413
414fn canonicalize_secure(path: &Path) -> PathBuf {
419 super::pathutil::canonicalize_secure_bounded(path, 2000)
420}
421
422fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
423 let mut cur = path.to_path_buf();
424 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
425 loop {
426 if cur.exists() {
427 return Some((canonicalize_secure(&cur), remainder));
428 }
429 let name = cur.file_name()?.to_os_string();
430 remainder.push(name);
431 if !cur.pop() {
432 return None;
433 }
434 }
435}
436
437pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, PathJailError> {
438 jail_path_with_roots(candidate, jail_root, &[])
439}
440
441const LANGUAGE_CACHE_PATTERNS: &[(&str, &str, &str)] = &[
446 ("/go/pkg/mod/", "Go module cache", "~/go/pkg/mod"),
447 (
448 "/.cargo/registry/",
449 "Rust crate registry",
450 "~/.cargo/registry",
451 ),
452 (
453 "/site-packages/",
454 "Python site-packages",
455 "<venv>/lib/pythonX.Y/site-packages",
456 ),
457 ("/node_modules/", "Node modules", "<project>/node_modules"),
458 (
459 "/.m2/repository/",
460 "Maven local repository",
461 "~/.m2/repository",
462 ),
463 ("/.gradle/caches/", "Gradle cache", "~/.gradle/caches"),
464 (
465 "/.nuget/packages/",
466 "NuGet package cache",
467 "~/.nuget/packages",
468 ),
469];
470
471fn detected_cache_hint(candidate: &std::path::Path) -> Option<String> {
475 let s = candidate.to_string_lossy();
476 for &(pattern, name, example) in LANGUAGE_CACHE_PATTERNS {
477 if s.contains(pattern) {
478 return Some(format!(
479 ". Detected {name} — add read_only_roots = [\"{example}\"] to \
480 ~/.config/lean-ctx/config.toml for cached, compressed reads without write access"
481 ));
482 }
483 }
484 None
485}
486
487pub fn detect_language_cache_root(candidate: &Path) -> Option<(&'static str, PathBuf)> {
492 let s = candidate.to_string_lossy().replace('\\', "/");
493 for &(marker, label, _) in LANGUAGE_CACHE_PATTERNS {
494 if let Some(idx) = s.find(marker) {
495 let end = idx + marker.len() - 1; return Some((label, PathBuf::from(&s[..end])));
497 }
498 }
499 None
500}
501
502pub fn jail_path_with_roots(
512 candidate: &Path,
513 jail_root: &Path,
514 extra_roots: &[String],
515) -> Result<PathBuf, PathJailError> {
516 if candidate.to_string_lossy().as_bytes().contains(&0) {
517 return Err(PathJailError::NullByte);
518 }
519
520 #[cfg(feature = "no-jail")]
521 {
522 let _ = (jail_root, extra_roots);
523 return Ok(canonicalize_or_self(candidate));
524 }
525
526 #[allow(unreachable_code)]
527 {
528 let cfg = crate::core::config::Config::load();
529 if cfg.path_jail == Some(false) {
530 return Ok(canonicalize_or_self(candidate));
531 }
532
533 let root = canonicalize_secure(jail_root);
534
535 let resolved: PathBuf;
540 let candidate: &Path = if candidate.is_absolute() {
541 candidate
542 } else {
543 resolved = root.join(candidate);
544 resolved.as_path()
545 };
546
547 let mut allow = allow_paths_from_env_and_config();
548 allow.extend(
550 extra_roots
551 .iter()
552 .filter(|r| !r.is_empty())
553 .map(|r| canonicalize_secure(Path::new(r))),
554 );
555
556 if let Ok(state) = crate::core::paths::state_dir() {
560 allow.push(canonicalize_secure(&state));
561 }
562 allow.extend(
565 read_only_roots_from_env_and_config()
566 .into_iter()
567 .map(|p| canonicalize_secure(&p)),
568 );
569
570 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
571 PathJailError::NoExistingAncestor {
572 path: candidate.to_path_buf(),
573 }
574 })?;
575
576 let allowed = path_allowed_by_jail(&base, &root, &allow);
577
578 if !allowed {
579 let mut hint = if crate::core::protocol::meta_visible() {
580 let dir = candidate.parent().unwrap_or(candidate).display();
581 format!(
582 ". Hint: set LEAN_CTX_ALLOW_PATH={dir} for read-write access \
583 (colon-separated for multiple: /path/a:/path/b), \
584 LEAN_CTX_READ_ONLY_ROOTS={dir} for read-only, \
585 or add entries to allow_paths = [\"{dir}\"] or extra_roots = [\"{dir}\"] \
586 in ~/.config/lean-ctx/config.toml"
587 )
588 } else {
589 ". Fix (additive): add the directory to extra_roots or allow_paths in \
594 ~/.config/lean-ctx/config.toml — `lean-ctx doctor` shows the config in effect"
595 .to_string()
596 };
597 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
601 hint.push_str(". ");
602 hint.push_str(¬ice);
603 }
604 if let Some(missing) = crate::core::config::Config::missing_config_path() {
608 hint.push_str(&format!(
609 ". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
610 allow_paths edit in a config.toml elsewhere is not read; \
611 `lean-ctx doctor` shows the path in effect",
612 missing.display()
613 ));
614 }
615 if let Some(cache_hint) = detected_cache_hint(candidate) {
616 hint.push_str(&cache_hint);
617 }
618 return Err(PathJailError::EscapesRoot {
619 path: candidate.to_path_buf(),
620 root,
621 hint,
622 });
623 }
624
625 #[cfg(windows)]
626 reject_symlink_on_windows(candidate)?;
627
628 let mut out = base;
629 for part in remainder.iter().rev() {
630 out.push(part);
631 }
632
633 if out.exists() {
636 let final_canon = canonicalize_secure(&out);
637 let final_ok = path_allowed_by_jail(&final_canon, &root, &allow);
638 if !final_ok {
639 return Err(PathJailError::PostCanonicalizeEscape {
640 path: candidate.to_path_buf(),
641 resolved: final_canon,
642 });
643 }
644 }
645
646 Ok(out)
647 }
648}
649
650#[cfg(windows)]
651fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
652 let path_str = normalize_windows_path(&path.to_string_lossy());
653 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
654 path_str.starts_with(&prefix_str)
655}
656
657#[cfg(windows)]
658fn normalize_windows_path(s: &str) -> String {
659 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
660 stripped.to_lowercase().replace('/', "\\")
661}
662
663#[cfg(windows)]
664fn reject_symlink_on_windows(path: &Path) -> Result<(), PathJailError> {
665 if let Ok(meta) = std::fs::symlink_metadata(path) {
666 if super::pathutil::is_symlink_or_reparse(&meta) {
669 return Err(PathJailError::Symlink {
670 path: path.to_path_buf(),
671 });
672 }
673 }
674 Ok(())
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680
681 #[cfg(not(feature = "no-jail"))]
682 #[test]
683 fn rejects_path_outside_root() {
684 let _iso = crate::core::data_dir::isolated_data_dir();
689 let tmp = tempfile::tempdir().unwrap();
690 let root = tmp.path().join("root");
691 let other = tmp.path().join("other");
692 std::fs::create_dir_all(&root).unwrap();
693 std::fs::create_dir_all(&other).unwrap();
694 std::fs::write(root.join("a.txt"), "ok").unwrap();
695 std::fs::write(other.join("b.txt"), "no").unwrap();
696
697 let ok = jail_path(&root.join("a.txt"), &root);
698 assert!(ok.is_ok());
699
700 let bad = jail_path(&other.join("b.txt"), &root);
701 assert!(bad.is_err());
702 }
703
704 #[cfg(not(feature = "no-jail"))]
711 #[test]
712 fn read_only_roots_deny_writes_but_allow_reads() {
713 let _iso = crate::core::data_dir::isolated_data_dir();
716
717 let tmp = tempfile::tempdir().unwrap();
718 let project = tmp.path().join("project");
719 let refrepo = tmp.path().join("refrepo");
720 std::fs::create_dir_all(&project).unwrap();
721 std::fs::create_dir_all(refrepo.join("sub")).unwrap();
722 std::fs::write(refrepo.join("lib.rs"), "pub fn x() {}\n").unwrap();
723
724 let ro_canon = canonicalize_secure(&refrepo);
727 crate::test_env::set_var(
728 "LEAN_CTX_READ_ONLY_ROOTS",
729 ro_canon.to_string_lossy().as_ref(),
730 );
731
732 let existing = refrepo.join("lib.rs");
733 let new_file = refrepo.join("sub").join("new.rs");
734 let proj_file = project.join("main.rs");
735
736 let read_existing = jail_path(&existing, &project);
738 let deny_existing = enforce_writable(&existing);
739 let deny_new = enforce_writable(&new_file);
740 let allow_project = enforce_writable(&proj_file);
741 let ro_existing = is_read_only_path(&existing);
742 let ro_project = is_read_only_path(&proj_file);
743
744 crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
745
746 assert!(
747 deny_existing.is_err(),
748 "write to an existing file in a read-only root must be denied"
749 );
750 assert!(
751 deny_new.is_err(),
752 "creating a new file in a read-only root must be denied"
753 );
754 assert!(
755 allow_project.is_ok(),
756 "writes into the project root must stay allowed: {allow_project:?}"
757 );
758 assert!(
759 read_existing.is_ok(),
760 "reads inside a read-only root must resolve (read allow-list): {read_existing:?}"
761 );
762 assert!(ro_existing, "the file is inside the read-only root");
763 assert!(!ro_project, "the project file is not read-only");
764 }
765
766 #[cfg(not(feature = "no-jail"))]
772 #[test]
773 fn honors_path_jail_false_after_mtime_preserving_edit() {
774 let _iso = crate::core::data_dir::isolated_data_dir();
775 let cfg_path = crate::core::config::Config::path().unwrap();
776 if let Some(parent) = cfg_path.parent() {
777 std::fs::create_dir_all(parent).unwrap();
778 }
779
780 let tmp = tempfile::tempdir().unwrap();
781 let root = tmp.path().join("project");
782 let outside = tmp.path().join("outside");
783 std::fs::create_dir_all(&root).unwrap();
784 std::fs::create_dir_all(&outside).unwrap();
785 let secret = outside.join("secret.txt");
786 std::fs::write(&secret, "x").unwrap();
787
788 std::fs::write(&cfg_path, "# jail on\n").unwrap();
790 let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
791 assert_eq!(crate::core::config::Config::load().path_jail, None);
792
793 std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
796 filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
797
798 assert!(
799 jail_path(&secret, &root).is_ok(),
800 "path_jail=false must take effect without a fresh process (#406)"
801 );
802 }
803
804 #[test]
805 fn allows_nonexistent_child_under_root() {
806 let tmp = tempfile::tempdir().unwrap();
807 let root = tmp.path().join("root");
808 std::fs::create_dir_all(&root).unwrap();
809 std::fs::write(root.join("a.txt"), "ok").unwrap();
810
811 let p = root.join("new").join("file.txt");
812 let ok = jail_path(&p, &root).unwrap();
813 assert!(ok.to_string_lossy().contains("file.txt"));
814 }
815
816 #[cfg(not(feature = "no-jail"))]
817 #[test]
818 fn relative_candidate_resolves_against_root_not_cwd() {
819 let _iso = crate::core::data_dir::isolated_data_dir();
822 let tmp = tempfile::tempdir().unwrap();
823 let root = tmp.path().join("project");
824 std::fs::create_dir_all(root.join("sub")).unwrap();
825 std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
826
827 let jailed = jail_path(Path::new("sub/file.rs"), &root)
828 .expect("relative candidate should resolve under the jail root");
829 assert!(jailed.ends_with("sub/file.rs"));
830 assert!(
831 is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
832 "resolved path must live under the jail root: {jailed:?}"
833 );
834 }
835
836 #[test]
837 fn ide_allow_dirs_are_registry_derived_and_skip_home() {
838 use crate::core::editor_registry::{ConfigType, EditorTarget};
839
840 let home = tempfile::tempdir().unwrap();
841 let h = home.path();
842 std::fs::create_dir_all(h.join("Library/Application Support/Code/User")).unwrap();
845 std::fs::create_dir_all(h.join(".cursor")).unwrap();
846
847 let targets = vec![
848 EditorTarget {
849 name: "VS Code",
850 agent_key: "vscode".into(),
851 config_path: h.join("Library/Application Support/Code/User/mcp.json"),
852 detect_path: h.join("Library/Application Support/Code"),
853 config_type: ConfigType::VsCodeMcp,
854 },
855 EditorTarget {
856 name: "Cursor",
857 agent_key: "cursor".into(),
858 config_path: h.join(".cursor/mcp.json"),
859 detect_path: h.join(".cursor"),
860 config_type: ConfigType::McpJson,
861 },
862 EditorTarget {
864 name: "Claude Code",
865 agent_key: "claude".into(),
866 config_path: h.join(".claude.json"),
867 detect_path: h.join(".no-such-dir"),
868 config_type: ConfigType::McpJson,
869 },
870 ];
871
872 let mut out = Vec::new();
873 collect_ide_allow_dirs(h, &targets, &mut out);
874
875 assert!(
876 out.iter().any(|p| p.ends_with("Code/User")),
877 "non-dotfile VS Code dir must be covered: {out:?}"
878 );
879 assert!(out.iter().any(|p| p.ends_with(".cursor")), "{out:?}");
880 let home_canon = canonicalize_secure(h);
881 assert!(
882 !out.contains(&home_canon),
883 "must never widen the jail to $HOME: {out:?}"
884 );
885 }
886
887 #[test]
892 fn ide_config_dirs_are_excluded_by_default() {
893 let home = tempfile::tempdir().unwrap();
894 for d in [".lean-ctx", ".cursor", ".codex"] {
895 std::fs::create_dir_all(home.path().join(d)).unwrap();
896 }
897
898 let denied = home_allow_dirs(home.path(), false);
899 assert!(
900 denied.is_empty(),
901 "foreign editor dirs must stay jailed by default: {denied:?}"
902 );
903
904 let allowed = home_allow_dirs(home.path(), true);
909 assert!(
910 allowed.iter().any(|p| p.ends_with(".cursor")),
911 "opt-in must expose editor dirs: {allowed:?}"
912 );
913 assert!(
914 !allowed.iter().any(|p| p.ends_with(".lean-ctx")),
915 "lean-ctx's own dir is covered by the data_dir root, not home_allow_dirs: {allowed:?}"
916 );
917 }
918
919 #[test]
920 fn canonicalize_or_self_strips_verbatim() {
921 let tmp = tempfile::tempdir().unwrap();
922 let dir = tmp.path().join("project");
923 std::fs::create_dir_all(&dir).unwrap();
924
925 let result = canonicalize_or_self(&dir);
926 let s = result.to_string_lossy();
927 assert!(
928 !s.starts_with(r"\\?\"),
929 "canonicalize_or_self should strip verbatim prefix, got: {s}"
930 );
931 }
932
933 #[test]
934 fn jail_path_accepts_same_dir_different_format() {
935 let tmp = tempfile::tempdir().unwrap();
936 let root = tmp.path().join("project");
937 std::fs::create_dir_all(&root).unwrap();
938 std::fs::write(root.join("file.rs"), "ok").unwrap();
939
940 let result = jail_path(&root.join("file.rs"), &root);
941 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
942 }
943
944 #[cfg(not(feature = "no-jail"))]
945 #[test]
946 fn error_message_contains_escape_info() {
947 let _iso = crate::core::data_dir::isolated_data_dir();
950 let tmp = tempfile::tempdir().unwrap();
951 let root = tmp.path().join("root");
952 let other = tmp.path().join("other");
953 std::fs::create_dir_all(&root).unwrap();
954 std::fs::create_dir_all(&other).unwrap();
955 std::fs::write(other.join("b.txt"), "no").unwrap();
956
957 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
958 assert!(
959 err.to_string().contains("path escapes project root"),
960 "error should mention escape: {err}"
961 );
962 }
963
964 #[cfg(not(feature = "no-jail"))]
968 #[test]
969 fn escape_error_names_config_keys_without_meta() {
970 let _iso = crate::core::data_dir::isolated_data_dir();
971 crate::test_env::remove_var("LEAN_CTX_META");
972 crate::test_env::remove_var("LEAN_CTX_DIAGNOSTICS");
973 let tmp = tempfile::tempdir().unwrap();
974 let root = tmp.path().join("root");
975 let other = tmp.path().join("other");
976 std::fs::create_dir_all(&root).unwrap();
977 std::fs::create_dir_all(&other).unwrap();
978 std::fs::write(other.join("b.txt"), "no").unwrap();
979
980 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
981 let msg = err.to_string();
982 assert!(
983 msg.contains("extra_roots") && msg.contains("allow_paths"),
984 "agent-visible escape error should name the config keys: {msg}"
985 );
986 }
987
988 #[test]
991 fn expand_user_path_expands_tilde_and_vars() {
992 let _env_lock = crate::core::data_dir::test_env_lock();
993 let home = dirs::home_dir().expect("home dir");
994 let home_s = home.to_string_lossy().to_string();
995
996 assert_eq!(expand_user_path("~"), home);
997 assert_eq!(expand_user_path("~/code"), home.join("code"));
998 assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
999 assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
1000 crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
1002 assert_eq!(
1003 expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
1004 PathBuf::from(format!("{home_s}/sub/x"))
1005 );
1006 crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
1007 assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
1009 }
1010
1011 #[test]
1012 fn expand_user_path_leaves_unset_vars_verbatim() {
1013 let _env_lock = crate::core::data_dir::test_env_lock();
1014 crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
1015 let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
1016 assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
1017 }
1018
1019 #[cfg(unix)]
1032 #[test]
1033 fn allow_path_root_slash_permits_everything() {
1034 let _guard = crate::core::data_dir::test_env_lock();
1035 let tmp = tempfile::tempdir().unwrap();
1036 let root = tmp.path().join("root");
1037 let other = tmp.path().join("other");
1038 std::fs::create_dir_all(&root).unwrap();
1039 std::fs::create_dir_all(&other).unwrap();
1040 std::fs::write(other.join("b.txt"), "allowed").unwrap();
1041
1042 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
1043 let result = jail_path(&other.join("b.txt"), &root);
1044 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
1045
1046 assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
1047 }
1048
1049 #[test]
1052 fn active_relaxations_detects_allow_path_env() {
1053 let _iso = crate::core::data_dir::isolated_data_dir();
1054 crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
1055 crate::test_env::remove_var("LEAN_CTX_ALLOW_IDE_DIRS");
1056 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/tmp");
1057
1058 let relaxed = active_relaxations();
1059
1060 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
1061
1062 assert!(
1063 relaxed.iter().any(|r| r.source == "LEAN_CTX_ALLOW_PATH"),
1064 "LEAN_CTX_ALLOW_PATH must be reported as a jail relaxation: {relaxed:?}"
1065 );
1066 }
1067
1068 #[cfg(not(feature = "no-jail"))]
1069 #[test]
1070 fn active_relaxations_empty_when_jail_intact() {
1071 let _iso = crate::core::data_dir::isolated_data_dir();
1072 for var in [
1073 "LEAN_CTX_ALLOW_PATH",
1074 "LCTX_ALLOW_PATH",
1075 "LEAN_CTX_EXTRA_ROOTS",
1076 "LEAN_CTX_ALLOW_IDE_DIRS",
1077 ] {
1078 crate::test_env::remove_var(var);
1079 }
1080
1081 assert!(
1082 active_relaxations().is_empty(),
1083 "an intact jail (clean config, no relaxation env) must report no relaxations: {:?}",
1084 active_relaxations()
1085 );
1086 }
1087
1088 #[test]
1089 fn allow_path_env_permits_outside_root() {
1090 let _guard = crate::core::data_dir::test_env_lock();
1091 let tmp = tempfile::tempdir().unwrap();
1092 let root = tmp.path().join("root");
1093 let other = tmp.path().join("other");
1094 std::fs::create_dir_all(&root).unwrap();
1095 std::fs::create_dir_all(&other).unwrap();
1096 std::fs::write(other.join("b.txt"), "allowed").unwrap();
1097
1098 let canon = canonicalize_or_self(&other);
1099 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
1100 let result = jail_path(&other.join("b.txt"), &root);
1101 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
1102
1103 assert!(
1104 result.is_ok(),
1105 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
1106 );
1107 }
1108
1109 #[cfg(all(unix, not(feature = "no-jail")))]
1110 #[test]
1111 fn rejects_symlink_escape_on_unix() {
1112 use std::os::unix::fs::symlink;
1113
1114 let _iso = crate::core::data_dir::isolated_data_dir();
1117 let tmp = tempfile::tempdir().unwrap();
1118 let root = tmp.path().join("root");
1119 let other = tmp.path().join("other");
1120 std::fs::create_dir_all(&root).unwrap();
1121 std::fs::create_dir_all(&other).unwrap();
1122 std::fs::write(other.join("secret.txt"), "no").unwrap();
1123
1124 let link = root.join("link.txt");
1125 symlink(other.join("secret.txt"), &link).unwrap();
1126
1127 let bad = jail_path(&link, &root);
1128 assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
1129 }
1130
1131 #[test]
1132 fn rejects_null_byte_in_path() {
1133 let tmp = tempfile::tempdir().unwrap();
1134 let root = tmp.path().join("root");
1135 std::fs::create_dir_all(&root).unwrap();
1136
1137 let bad_path = PathBuf::from("file\0.txt");
1138 let result = jail_path(&bad_path, &root);
1139 assert!(result.is_err(), "null byte in path must be rejected");
1140 assert!(
1141 result.unwrap_err().to_string().contains("null byte"),
1142 "error must mention null byte"
1143 );
1144 }
1145
1146 #[cfg(not(feature = "no-jail"))]
1152 #[test]
1153 fn extra_roots_permit_paths_outside_jail() {
1154 let _iso = crate::core::data_dir::isolated_data_dir();
1155
1156 let tmp = tempfile::tempdir().unwrap();
1157 let root = tmp.path().join("project");
1158 let worktree = tmp.path().join("worktree");
1159 let elsewhere = tmp.path().join("elsewhere");
1160 for d in [&root, &worktree, &elsewhere] {
1161 std::fs::create_dir_all(d).unwrap();
1162 }
1163 let in_worktree = worktree.join("a.txt");
1164 std::fs::write(&in_worktree, "x").unwrap();
1165 let outside = elsewhere.join("b.txt");
1166 std::fs::write(&outside, "y").unwrap();
1167
1168 assert!(jail_path(&in_worktree, &root).is_err());
1170 assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
1171
1172 let extra = vec![worktree.to_string_lossy().to_string()];
1175 assert!(
1176 jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
1177 "path under a session extra_root must resolve (#403)"
1178 );
1179
1180 assert!(
1182 jail_path_with_roots(&outside, &root, &extra).is_err(),
1183 "paths outside ALL roots must still be rejected"
1184 );
1185
1186 assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
1188 }
1189
1190 #[cfg(not(feature = "no-jail"))]
1193 #[test]
1194 fn harness_auto_memory_path_is_allowed_without_extra_roots() {
1195 let _iso = crate::core::data_dir::isolated_data_dir();
1196 let tmp = tempfile::tempdir().unwrap();
1197 let root = tmp.path().join("project");
1198 let memory = tmp
1199 .path()
1200 .join(".claude")
1201 .join("projects")
1202 .join("-tmp-project")
1203 .join("memory");
1204 std::fs::create_dir_all(&root).unwrap();
1205 std::fs::create_dir_all(&memory).unwrap();
1206 let mem_file = memory.join("MEMORY.md");
1207 std::fs::write(&mem_file, "# index\n").unwrap();
1208
1209 assert!(is_harness_auto_memory_path(&mem_file));
1210 assert!(is_harness_auto_memory_path(&memory));
1211 assert!(!is_harness_auto_memory_path(
1212 &tmp.path()
1213 .join(".claude")
1214 .join("projects")
1215 .join("-tmp-project")
1216 .join("session.jsonl")
1217 ));
1218
1219 assert!(
1220 jail_path_with_roots(&mem_file, &root, &[]).is_ok(),
1221 "auto-memory file must pass PathJail without extra_roots"
1222 );
1223 }
1224
1225 #[test]
1227 fn state_dir_tee_files_pass_jail() {
1228 let _lock = crate::core::data_dir::test_env_lock();
1229 let state = crate::core::paths::state_dir().expect("state_dir must be available");
1230 let tee_path = state.join("tee").join("some_command_deadbeef.log");
1231 let fake_root = std::env::temp_dir().join("pathjail_test_820_root");
1233 std::fs::create_dir_all(&fake_root).ok();
1234 let result = jail_path_with_roots(&tee_path, &fake_root, &[]);
1237 if state.exists() {
1241 assert!(
1242 result.is_ok(),
1243 "tee-file path under lean-ctx state dir must be auto-allowed: {result:?}"
1244 );
1245 }
1246 std::fs::remove_dir_all(&fake_root).ok();
1247 }
1248
1249 #[test]
1250 fn detected_cache_hint_recognizes_go_cargo_python() {
1251 use std::path::Path;
1252 let go = detected_cache_hint(Path::new("/Users/x/go/pkg/mod/github.com/foo/bar/main.go"));
1253 assert!(go.is_some(), "Go module cache should be detected");
1254 assert!(go.unwrap().contains("Go module cache"));
1255
1256 let cargo = detected_cache_hint(Path::new(
1257 "/home/x/.cargo/registry/src/crates.io/serde-1.0/lib.rs",
1258 ));
1259 assert!(cargo.is_some(), "Rust cargo registry should be detected");
1260 assert!(cargo.unwrap().contains("Rust crate registry"));
1261
1262 let py = detected_cache_hint(Path::new(
1263 "/usr/lib/python3.12/site-packages/requests/api.py",
1264 ));
1265 assert!(py.is_some(), "Python site-packages should be detected");
1266
1267 let normal = detected_cache_hint(Path::new("/home/x/projects/myapp/src/main.rs"));
1268 assert!(normal.is_none(), "Normal project path should not match");
1269 }
1270
1271 #[test]
1272 fn detect_cache_root_extracts_marker_dir() {
1273 let cases = [
1274 (
1275 "/Users/x/go/pkg/mod/github.com/foo/bar@v1.2.3/baz.go",
1276 "Go module cache",
1277 "/Users/x/go/pkg/mod",
1278 ),
1279 (
1280 "/home/u/.cargo/registry/src/index-abc/serde-1.0/src/lib.rs",
1281 "Rust crate registry",
1282 "/home/u/.cargo/registry",
1283 ),
1284 (
1285 "/opt/venv/lib/python3.12/site-packages/requests/api.py",
1286 "Python site-packages",
1287 "/opt/venv/lib/python3.12/site-packages",
1288 ),
1289 (
1290 "/w/app/node_modules/react/index.js",
1291 "Node modules",
1292 "/w/app/node_modules",
1293 ),
1294 ];
1295 for (path, want_label, want_root) in cases {
1296 let (label, root) = detect_language_cache_root(Path::new(path))
1297 .unwrap_or_else(|| panic!("expected cache match for {path}"));
1298 assert_eq!(label, want_label, "label for {path}");
1299 assert_eq!(root, PathBuf::from(want_root), "root for {path}");
1300 }
1301 assert!(
1302 detect_language_cache_root(Path::new("/home/u/proj/src/main.rs")).is_none(),
1303 "a normal project path is not a cache"
1304 );
1305 }
1306
1307 #[cfg(not(feature = "no-jail"))]
1311 #[test]
1312 fn registered_cache_root_reads_allow_writes_deny() {
1313 let _iso = crate::core::data_dir::isolated_data_dir();
1314
1315 let tmp = tempfile::tempdir().unwrap();
1316 let dep = tmp.path().join("go/pkg/mod/example.com/lib@v1");
1318 std::fs::create_dir_all(&dep).unwrap();
1319 let file = dep.join("lib.go");
1320 std::fs::write(&file, "package lib").unwrap();
1321
1322 let project = tmp.path().join("project");
1324 std::fs::create_dir_all(&project).unwrap();
1325
1326 assert!(jail_path_with_roots(&file, &project, &[]).is_err());
1328
1329 let (_, root) = detect_language_cache_root(&file).expect("cache match");
1331 assert!(
1332 register_session_read_only_root(&root),
1333 "first register is new"
1334 );
1335 assert!(
1336 !register_session_read_only_root(&root),
1337 "re-register is a no-op"
1338 );
1339
1340 assert!(
1342 jail_path_with_roots(&file, &project, &[]).is_ok(),
1343 "registered cache root must be readable"
1344 );
1345 assert!(is_read_only_path(&file), "cache file is read-only");
1346 assert!(
1347 enforce_writable(&file).is_err(),
1348 "writes into the cache root must be denied"
1349 );
1350 }
1351}