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();
321 let tmp = tempfile::tempdir().unwrap();
322 let root = tmp.path().join("root");
323 let other = tmp.path().join("other");
324 std::fs::create_dir_all(&root).unwrap();
325 std::fs::create_dir_all(&other).unwrap();
326 std::fs::write(root.join("a.txt"), "ok").unwrap();
327 std::fs::write(other.join("b.txt"), "no").unwrap();
328
329 let ok = jail_path(&root.join("a.txt"), &root);
330 assert!(ok.is_ok());
331
332 let bad = jail_path(&other.join("b.txt"), &root);
333 assert!(bad.is_err());
334 }
335
336 #[cfg(not(feature = "no-jail"))]
342 #[test]
343 fn honors_path_jail_false_after_mtime_preserving_edit() {
344 let _iso = crate::core::data_dir::isolated_data_dir();
345 let cfg_path = crate::core::config::Config::path().unwrap();
346 if let Some(parent) = cfg_path.parent() {
347 std::fs::create_dir_all(parent).unwrap();
348 }
349
350 let tmp = tempfile::tempdir().unwrap();
351 let root = tmp.path().join("project");
352 let outside = tmp.path().join("outside");
353 std::fs::create_dir_all(&root).unwrap();
354 std::fs::create_dir_all(&outside).unwrap();
355 let secret = outside.join("secret.txt");
356 std::fs::write(&secret, "x").unwrap();
357
358 std::fs::write(&cfg_path, "# jail on\n").unwrap();
360 let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
361 assert_eq!(crate::core::config::Config::load().path_jail, None);
362
363 std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
366 filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
367
368 assert!(
369 jail_path(&secret, &root).is_ok(),
370 "path_jail=false must take effect without a fresh process (#406)"
371 );
372 }
373
374 #[test]
375 fn allows_nonexistent_child_under_root() {
376 let tmp = tempfile::tempdir().unwrap();
377 let root = tmp.path().join("root");
378 std::fs::create_dir_all(&root).unwrap();
379 std::fs::write(root.join("a.txt"), "ok").unwrap();
380
381 let p = root.join("new").join("file.txt");
382 let ok = jail_path(&p, &root).unwrap();
383 assert!(ok.to_string_lossy().contains("file.txt"));
384 }
385
386 #[cfg(not(feature = "no-jail"))]
387 #[test]
388 fn relative_candidate_resolves_against_root_not_cwd() {
389 let _iso = crate::core::data_dir::isolated_data_dir();
392 let tmp = tempfile::tempdir().unwrap();
393 let root = tmp.path().join("project");
394 std::fs::create_dir_all(root.join("sub")).unwrap();
395 std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
396
397 let jailed = jail_path(Path::new("sub/file.rs"), &root)
398 .expect("relative candidate should resolve under the jail root");
399 assert!(jailed.ends_with("sub/file.rs"));
400 assert!(
401 is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
402 "resolved path must live under the jail root: {jailed:?}"
403 );
404 }
405
406 #[test]
407 fn ide_config_dirs_list_is_not_empty() {
408 assert!(IDE_CONFIG_DIRS.len() >= 10);
409 assert!(IDE_CONFIG_DIRS.contains(&".codex"));
410 assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
411 assert!(IDE_CONFIG_DIRS.contains(&".claude"));
412 assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
413 }
414
415 #[test]
418 fn ide_config_dirs_are_excluded_by_default() {
419 let home = tempfile::tempdir().unwrap();
420 for d in [".lean-ctx", ".cursor", ".claude", ".codex"] {
421 std::fs::create_dir_all(home.path().join(d)).unwrap();
422 }
423
424 let denied = home_allow_dirs(home.path(), false);
425 assert_eq!(
426 denied.len(),
427 1,
428 "only ~/.lean-ctx may be allowed: {denied:?}"
429 );
430 assert!(denied[0].ends_with(".lean-ctx"));
431
432 let allowed = home_allow_dirs(home.path(), true);
433 assert_eq!(allowed.len(), 4, "opt-in must allow all existing IDE dirs");
434 }
435
436 #[test]
437 fn canonicalize_or_self_strips_verbatim() {
438 let tmp = tempfile::tempdir().unwrap();
439 let dir = tmp.path().join("project");
440 std::fs::create_dir_all(&dir).unwrap();
441
442 let result = canonicalize_or_self(&dir);
443 let s = result.to_string_lossy();
444 assert!(
445 !s.starts_with(r"\\?\"),
446 "canonicalize_or_self should strip verbatim prefix, got: {s}"
447 );
448 }
449
450 #[test]
451 fn jail_path_accepts_same_dir_different_format() {
452 let tmp = tempfile::tempdir().unwrap();
453 let root = tmp.path().join("project");
454 std::fs::create_dir_all(&root).unwrap();
455 std::fs::write(root.join("file.rs"), "ok").unwrap();
456
457 let result = jail_path(&root.join("file.rs"), &root);
458 assert!(result.is_ok(), "same dir should be accepted: {result:?}");
459 }
460
461 #[cfg(not(feature = "no-jail"))]
462 #[test]
463 fn error_message_contains_escape_info() {
464 let _iso = crate::core::data_dir::isolated_data_dir();
465 let tmp = tempfile::tempdir().unwrap();
466 let root = tmp.path().join("root");
467 let other = tmp.path().join("other");
468 std::fs::create_dir_all(&root).unwrap();
469 std::fs::create_dir_all(&other).unwrap();
470 std::fs::write(other.join("b.txt"), "no").unwrap();
471
472 let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
473 assert!(
474 err.contains("path escapes project root"),
475 "error should mention escape: {err}"
476 );
477 }
478
479 #[test]
482 fn expand_user_path_expands_tilde_and_vars() {
483 let home = dirs::home_dir().expect("home dir");
484 let home_s = home.to_string_lossy().to_string();
485
486 assert_eq!(expand_user_path("~"), home);
487 assert_eq!(expand_user_path("~/code"), home.join("code"));
488 assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
489 assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
490 crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
492 assert_eq!(
493 expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
494 PathBuf::from(format!("{home_s}/sub/x"))
495 );
496 crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
497 assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
499 }
500
501 #[test]
502 fn expand_user_path_leaves_unset_vars_verbatim() {
503 crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
504 let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
505 assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
506 }
507
508 static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
511
512 #[cfg(unix)]
515 #[test]
516 fn allow_path_root_slash_permits_everything() {
517 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
518 let tmp = tempfile::tempdir().unwrap();
519 let root = tmp.path().join("root");
520 let other = tmp.path().join("other");
521 std::fs::create_dir_all(&root).unwrap();
522 std::fs::create_dir_all(&other).unwrap();
523 std::fs::write(other.join("b.txt"), "allowed").unwrap();
524
525 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
526 let result = jail_path(&other.join("b.txt"), &root);
527 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
528
529 assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
530 }
531
532 #[test]
533 fn allow_path_env_permits_outside_root() {
534 let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
535 let tmp = tempfile::tempdir().unwrap();
536 let root = tmp.path().join("root");
537 let other = tmp.path().join("other");
538 std::fs::create_dir_all(&root).unwrap();
539 std::fs::create_dir_all(&other).unwrap();
540 std::fs::write(other.join("b.txt"), "allowed").unwrap();
541
542 let canon = canonicalize_or_self(&other);
543 crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
544 let result = jail_path(&other.join("b.txt"), &root);
545 crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
546
547 assert!(
548 result.is_ok(),
549 "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
550 );
551 }
552
553 #[cfg(all(unix, not(feature = "no-jail")))]
554 #[test]
555 fn rejects_symlink_escape_on_unix() {
556 use std::os::unix::fs::symlink;
557
558 let _iso = crate::core::data_dir::isolated_data_dir();
559 let tmp = tempfile::tempdir().unwrap();
560 let root = tmp.path().join("root");
561 let other = tmp.path().join("other");
562 std::fs::create_dir_all(&root).unwrap();
563 std::fs::create_dir_all(&other).unwrap();
564 std::fs::write(other.join("secret.txt"), "no").unwrap();
565
566 let link = root.join("link.txt");
567 symlink(other.join("secret.txt"), &link).unwrap();
568
569 let bad = jail_path(&link, &root);
570 assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
571 }
572
573 #[test]
574 fn rejects_null_byte_in_path() {
575 let tmp = tempfile::tempdir().unwrap();
576 let root = tmp.path().join("root");
577 std::fs::create_dir_all(&root).unwrap();
578
579 let bad_path = PathBuf::from("file\0.txt");
580 let result = jail_path(&bad_path, &root);
581 assert!(result.is_err(), "null byte in path must be rejected");
582 assert!(
583 result.unwrap_err().contains("null byte"),
584 "error must mention null byte"
585 );
586 }
587
588 #[cfg(not(feature = "no-jail"))]
594 #[test]
595 fn extra_roots_permit_paths_outside_jail() {
596 let _iso = crate::core::data_dir::isolated_data_dir();
597 let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
598
599 let tmp = tempfile::tempdir().unwrap();
600 let root = tmp.path().join("project");
601 let worktree = tmp.path().join("worktree");
602 let elsewhere = tmp.path().join("elsewhere");
603 for d in [&root, &worktree, &elsewhere] {
604 std::fs::create_dir_all(d).unwrap();
605 }
606 let in_worktree = worktree.join("a.txt");
607 std::fs::write(&in_worktree, "x").unwrap();
608 let outside = elsewhere.join("b.txt");
609 std::fs::write(&outside, "y").unwrap();
610
611 assert!(jail_path(&in_worktree, &root).is_err());
613 assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
614
615 let extra = vec![worktree.to_string_lossy().to_string()];
618 assert!(
619 jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
620 "path under a session extra_root must resolve (#403)"
621 );
622
623 assert!(
625 jail_path_with_roots(&outside, &root, &extra).is_err(),
626 "paths outside ALL roots must still be rejected"
627 );
628
629 assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
631 }
632}