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