1use std::path::{Path, PathBuf};
2
3const IDE_CONFIG_DIRS: &[&str] = &[
4 ".lean-ctx",
5 ".cursor",
6 ".claude",
7 ".codex",
8 ".codeium",
9 ".gemini",
10 ".qwen",
11 ".trae",
12 ".kiro",
13 ".verdent",
14 ".pi",
15 ".amp",
16 ".aider",
17 ".continue",
18 ".codebuddy",
19];
20
21pub fn expand_user_path(raw: &str) -> PathBuf {
29 let mut s = raw.to_string();
30
31 if (s == "~" || s.starts_with("~/"))
32 && let Some(home) = dirs::home_dir()
33 {
34 s = format!("{}{}", home.to_string_lossy(), &s[1..]);
35 }
36
37 while let Some(start) = s.find('$') {
38 let rest = &s[start + 1..];
39 let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
40 match stripped.find('}') {
41 Some(end) => (stripped[..end].to_string(), end + 3),
42 None => break,
43 }
44 } else {
45 let end = rest
46 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
47 .unwrap_or(rest.len());
48 (rest[..end].to_string(), end + 1)
49 };
50 if name.is_empty() {
51 break;
52 }
53 if let Ok(val) = std::env::var(&name) {
54 s.replace_range(start..start + token_len, &val);
55 } else {
56 tracing::warn!(
57 "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
58 );
59 break;
60 }
61 }
62
63 PathBuf::from(s)
64}
65
66pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
67 let mut out = Vec::new();
68 let cfg = crate::core::config::Config::load();
69
70 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
76 out.push(canonicalize_secure(&data_dir));
77 }
78
79 if let Some(home) = dirs::home_dir() {
80 let ide_dirs_allowed = cfg.allow_ide_config_dirs
81 || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
82 out.extend(home_allow_dirs(&home, ide_dirs_allowed));
83 }
84
85 for p in &cfg.allow_paths {
86 out.push(canonicalize_secure(&expand_user_path(p)));
87 }
88 for p in &cfg.extra_roots {
89 out.push(canonicalize_secure(&expand_user_path(p)));
90 }
91
92 let v = std::env::var("LCTX_ALLOW_PATH")
95 .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
96 .unwrap_or_default();
97 if !v.trim().is_empty() {
98 for p in std::env::split_paths(&v) {
99 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
100 }
101 }
102
103 let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
104 if !extra.trim().is_empty() {
105 for p in std::env::split_paths(&extra) {
106 out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
107 }
108 }
109
110 out
111}
112
113fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
119 let mut out = Vec::new();
120 for dir in IDE_CONFIG_DIRS {
121 if *dir != ".lean-ctx" && !ide_dirs_allowed {
122 continue;
123 }
124 let p = home.join(dir);
125 if p.exists() {
126 out.push(canonicalize_secure(&p));
127 }
128 }
129 out
130}
131
132fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
133 path.starts_with(prefix)
134}
135
136pub fn canonicalize_or_self(path: &Path) -> PathBuf {
140 super::pathutil::safe_canonicalize_bounded(path, 2000)
141}
142
143fn canonicalize_secure(path: &Path) -> PathBuf {
148 super::pathutil::canonicalize_secure_bounded(path, 2000)
149}
150
151fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
152 let mut cur = path.to_path_buf();
153 let mut remainder: Vec<std::ffi::OsString> = Vec::new();
154 loop {
155 if cur.exists() {
156 return Some((canonicalize_secure(&cur), remainder));
157 }
158 let name = cur.file_name()?.to_os_string();
159 remainder.push(name);
160 if !cur.pop() {
161 return None;
162 }
163 }
164}
165
166pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
167 jail_path_with_roots(candidate, jail_root, &[])
168}
169
170pub fn jail_path_with_roots(
180 candidate: &Path,
181 jail_root: &Path,
182 extra_roots: &[String],
183) -> Result<PathBuf, String> {
184 if candidate.to_string_lossy().as_bytes().contains(&0) {
185 return Err("path contains null byte".to_string());
186 }
187
188 #[cfg(feature = "no-jail")]
189 {
190 let _ = (jail_root, extra_roots);
191 return Ok(canonicalize_or_self(candidate));
192 }
193
194 #[allow(unreachable_code)]
195 {
196 let cfg = crate::core::config::Config::load();
197 if cfg.path_jail == Some(false) {
198 return Ok(canonicalize_or_self(candidate));
199 }
200
201 let root = canonicalize_secure(jail_root);
202
203 let resolved: PathBuf;
208 let candidate: &Path = if candidate.is_absolute() {
209 candidate
210 } else {
211 resolved = root.join(candidate);
212 resolved.as_path()
213 };
214
215 let mut allow = allow_paths_from_env_and_config();
216 allow.extend(
218 extra_roots
219 .iter()
220 .filter(|r| !r.is_empty())
221 .map(|r| canonicalize_secure(Path::new(r))),
222 );
223
224 let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
225 format!(
226 "path does not exist and has no existing ancestor: {}",
227 candidate.display()
228 )
229 })?;
230
231 let allowed =
232 is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
233
234 #[cfg(windows)]
235 let allowed = allowed || is_under_prefix_windows(&base, &root);
236
237 if !allowed {
238 let base_msg = format!(
239 "path escapes project root: {} (root: {})",
240 candidate.display(),
241 root.display(),
242 );
243 let hint = if crate::core::protocol::meta_visible() {
244 format!(
245 ". Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
246 candidate.parent().unwrap_or(candidate).display()
247 )
248 } else {
249 String::new()
250 };
251 return Err(format!("{base_msg}{hint}"));
252 }
253
254 #[cfg(windows)]
255 reject_symlink_on_windows(candidate)?;
256
257 let mut out = base;
258 for part in remainder.iter().rev() {
259 out.push(part);
260 }
261
262 if out.exists() {
265 let final_canon = canonicalize_secure(&out);
266 let final_ok = is_under_prefix(&final_canon, &root)
267 || allow.iter().any(|p| is_under_prefix(&final_canon, p));
268 #[cfg(windows)]
269 let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
270 if !final_ok {
271 return Err(format!(
272 "post-canonicalize jail escape detected: {} resolves to {}",
273 candidate.display(),
274 final_canon.display()
275 ));
276 }
277 }
278
279 Ok(out)
280 }
281}
282
283#[cfg(windows)]
284fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
285 let path_str = normalize_windows_path(&path.to_string_lossy());
286 let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
287 path_str.starts_with(&prefix_str)
288}
289
290#[cfg(windows)]
291fn normalize_windows_path(s: &str) -> String {
292 let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
293 stripped.to_lowercase().replace('/', "\\")
294}
295
296#[cfg(windows)]
297fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
298 if let Ok(meta) = std::fs::symlink_metadata(path) {
299 if super::pathutil::is_symlink_or_reparse(&meta) {
302 return Err(format!(
303 "symlink not allowed in jailed path: {}",
304 path.display()
305 ));
306 }
307 }
308 Ok(())
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[cfg(not(feature = "no-jail"))]
316 #[test]
317 fn rejects_path_outside_root() {
318 let _iso = crate::core::data_dir::isolated_data_dir();
323 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
324 let tmp = tempfile::tempdir().unwrap();
325 let root = tmp.path().join("root");
326 let other = tmp.path().join("other");
327 std::fs::create_dir_all(&root).unwrap();
328 std::fs::create_dir_all(&other).unwrap();
329 std::fs::write(root.join("a.txt"), "ok").unwrap();
330 std::fs::write(other.join("b.txt"), "no").unwrap();
331
332 let ok = jail_path(&root.join("a.txt"), &root);
333 assert!(ok.is_ok());
334
335 let bad = jail_path(&other.join("b.txt"), &root);
336 assert!(bad.is_err());
337 }
338
339 #[cfg(not(feature = "no-jail"))]
345 #[test]
346 fn honors_path_jail_false_after_mtime_preserving_edit() {
347 let _iso = crate::core::data_dir::isolated_data_dir();
348 let cfg_path = crate::core::config::Config::path().unwrap();
349 if let Some(parent) = cfg_path.parent() {
350 std::fs::create_dir_all(parent).unwrap();
351 }
352
353 let tmp = tempfile::tempdir().unwrap();
354 let root = tmp.path().join("project");
355 let outside = tmp.path().join("outside");
356 std::fs::create_dir_all(&root).unwrap();
357 std::fs::create_dir_all(&outside).unwrap();
358 let secret = outside.join("secret.txt");
359 std::fs::write(&secret, "x").unwrap();
360
361 std::fs::write(&cfg_path, "# jail on\n").unwrap();
363 let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
364 assert_eq!(crate::core::config::Config::load().path_jail, None);
365
366 std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
369 filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
370
371 assert!(
372 jail_path(&secret, &root).is_ok(),
373 "path_jail=false must take effect without a fresh process (#406)"
374 );
375 }
376
377 #[test]
378 fn allows_nonexistent_child_under_root() {
379 let tmp = tempfile::tempdir().unwrap();
380 let root = tmp.path().join("root");
381 std::fs::create_dir_all(&root).unwrap();
382 std::fs::write(root.join("a.txt"), "ok").unwrap();
383
384 let p = root.join("new").join("file.txt");
385 let ok = jail_path(&p, &root).unwrap();
386 assert!(ok.to_string_lossy().contains("file.txt"));
387 }
388
389 #[cfg(not(feature = "no-jail"))]
390 #[test]
391 fn relative_candidate_resolves_against_root_not_cwd() {
392 let _iso = crate::core::data_dir::isolated_data_dir();
395 let tmp = tempfile::tempdir().unwrap();
396 let root = tmp.path().join("project");
397 std::fs::create_dir_all(root.join("sub")).unwrap();
398 std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
399
400 let jailed = jail_path(Path::new("sub/file.rs"), &root)
401 .expect("relative candidate should resolve under the jail root");
402 assert!(jailed.ends_with("sub/file.rs"));
403 assert!(
404 is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
405 "resolved path must live under the jail root: {jailed:?}"
406 );
407 }
408
409 #[test]
410 fn ide_config_dirs_list_is_not_empty() {
411 assert!(IDE_CONFIG_DIRS.len() >= 10);
412 assert!(IDE_CONFIG_DIRS.contains(&".codex"));
413 assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
414 assert!(IDE_CONFIG_DIRS.contains(&".claude"));
415 assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
416 }
417
418 #[test]
421 fn ide_config_dirs_are_excluded_by_default() {
422 let home = tempfile::tempdir().unwrap();
423 for d in [".lean-ctx", ".cursor", ".claude", ".codex"] {
424 std::fs::create_dir_all(home.path().join(d)).unwrap();
425 }
426
427 let denied = home_allow_dirs(home.path(), false);
428 assert_eq!(
429 denied.len(),
430 1,
431 "only ~/.lean-ctx may be allowed: {denied:?}"
432 );
433 assert!(denied[0].ends_with(".lean-ctx"));
434
435 let allowed = home_allow_dirs(home.path(), true);
436 assert_eq!(allowed.len(), 4, "opt-in must allow all existing IDE dirs");
437 }
438
439 #[test]
440 fn canonicalize_or_self_strips_verbatim() {
441 let tmp = tempfile::tempdir().unwrap();
442 let dir = tmp.path().join("project");
443 std::fs::create_dir_all(&dir).unwrap();
444
445 let result = canonicalize_or_self(&dir);
446 let s = result.to_string_lossy();
447 assert!(
448 !s.starts_with(r"\\?\"),
449 "canonicalize_or_self should strip verbatim prefix, got: {s}"
450 );
451 }
452
453 #[test]
454 fn jail_path_accepts_same_dir_different_format() {
455 let tmp = tempfile::tempdir().unwrap();
456 let root = tmp.path().join("project");
457 std::fs::create_dir_all(&root).unwrap();
458 std::fs::write(root.join("file.rs"), "ok").unwrap();
459
460 let result = jail_path(&root.join("file.rs"), &root);
461 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
462 }
463
464 #[cfg(not(feature = "no-jail"))]
465 #[test]
466 fn error_message_contains_escape_info() {
467 let _iso = crate::core::data_dir::isolated_data_dir();
470 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
471 let tmp = tempfile::tempdir().unwrap();
472 let root = tmp.path().join("root");
473 let other = tmp.path().join("other");
474 std::fs::create_dir_all(&root).unwrap();
475 std::fs::create_dir_all(&other).unwrap();
476 std::fs::write(other.join("b.txt"), "no").unwrap();
477
478 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
479 assert!(
480 err.contains("path escapes project root"),
481 "error should mention escape: {err}"
482 );
483 }
484
485 #[test]
488 fn expand_user_path_expands_tilde_and_vars() {
489 let home = dirs::home_dir().expect("home dir");
490 let home_s = home.to_string_lossy().to_string();
491
492 assert_eq!(expand_user_path("~"), home);
493 assert_eq!(expand_user_path("~/code"), home.join("code"));
494 assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
495 assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
496 crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
498 assert_eq!(
499 expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
500 PathBuf::from(format!("{home_s}/sub/x"))
501 );
502 crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
503 assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
505 }
506
507 #[test]
508 fn expand_user_path_leaves_unset_vars_verbatim() {
509 crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
510 let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
511 assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
512 }
513
514 static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
517
518 #[cfg(unix)]
521 #[test]
522 fn allow_path_root_slash_permits_everything() {
523 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
524 let tmp = tempfile::tempdir().unwrap();
525 let root = tmp.path().join("root");
526 let other = tmp.path().join("other");
527 std::fs::create_dir_all(&root).unwrap();
528 std::fs::create_dir_all(&other).unwrap();
529 std::fs::write(other.join("b.txt"), "allowed").unwrap();
530
531 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
532 let result = jail_path(&other.join("b.txt"), &root);
533 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
534
535 assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
536 }
537
538 #[test]
539 fn allow_path_env_permits_outside_root() {
540 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
541 let tmp = tempfile::tempdir().unwrap();
542 let root = tmp.path().join("root");
543 let other = tmp.path().join("other");
544 std::fs::create_dir_all(&root).unwrap();
545 std::fs::create_dir_all(&other).unwrap();
546 std::fs::write(other.join("b.txt"), "allowed").unwrap();
547
548 let canon = canonicalize_or_self(&other);
549 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
550 let result = jail_path(&other.join("b.txt"), &root);
551 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
552
553 assert!(
554 result.is_ok(),
555 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
556 );
557 }
558
559 #[cfg(all(unix, not(feature = "no-jail")))]
560 #[test]
561 fn rejects_symlink_escape_on_unix() {
562 use std::os::unix::fs::symlink;
563
564 let _iso = crate::core::data_dir::isolated_data_dir();
567 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
568 let tmp = tempfile::tempdir().unwrap();
569 let root = tmp.path().join("root");
570 let other = tmp.path().join("other");
571 std::fs::create_dir_all(&root).unwrap();
572 std::fs::create_dir_all(&other).unwrap();
573 std::fs::write(other.join("secret.txt"), "no").unwrap();
574
575 let link = root.join("link.txt");
576 symlink(other.join("secret.txt"), &link).unwrap();
577
578 let bad = jail_path(&link, &root);
579 assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
580 }
581
582 #[test]
583 fn rejects_null_byte_in_path() {
584 let tmp = tempfile::tempdir().unwrap();
585 let root = tmp.path().join("root");
586 std::fs::create_dir_all(&root).unwrap();
587
588 let bad_path = PathBuf::from("file\0.txt");
589 let result = jail_path(&bad_path, &root);
590 assert!(result.is_err(), "null byte in path must be rejected");
591 assert!(
592 result.unwrap_err().contains("null byte"),
593 "error must mention null byte"
594 );
595 }
596
597 #[cfg(not(feature = "no-jail"))]
603 #[test]
604 fn extra_roots_permit_paths_outside_jail() {
605 let _iso = crate::core::data_dir::isolated_data_dir();
606 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
607
608 let tmp = tempfile::tempdir().unwrap();
609 let root = tmp.path().join("project");
610 let worktree = tmp.path().join("worktree");
611 let elsewhere = tmp.path().join("elsewhere");
612 for d in [&root, &worktree, &elsewhere] {
613 std::fs::create_dir_all(d).unwrap();
614 }
615 let in_worktree = worktree.join("a.txt");
616 std::fs::write(&in_worktree, "x").unwrap();
617 let outside = elsewhere.join("b.txt");
618 std::fs::write(&outside, "y").unwrap();
619
620 assert!(jail_path(&in_worktree, &root).is_err());
622 assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
623
624 let extra = vec![worktree.to_string_lossy().to_string()];
627 assert!(
628 jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
629 "path under a session extra_root must resolve (#403)"
630 );
631
632 assert!(
634 jail_path_with_roots(&outside, &root, &extra).is_err(),
635 "paths outside ALL roots must still be rejected"
636 );
637
638 assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
640 }
641}