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