Skip to main content

lean_ctx/core/
pathjail.rs

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
21/// Expands `~`, `$VAR` and `${VAR}` in a config-supplied path entry.
22///
23/// `allow_paths` / `extra_roots` come from `config.toml`, where no shell ever
24/// runs — users writing `"$HOME/code"` or `"~/code"` got a literal,
25/// never-matching prefix and concluded the whole option was broken (GH #392).
26/// Unset variables are left verbatim (and warned about) so the entry fails
27/// loudly in `lean-ctx doctor` instead of silently matching something else.
28pub 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    // The allow-list defines the jail boundary, so it must be canonicalized the
71    // same (security, symlink-resolving) way as the candidate it is compared
72    // against — otherwise a guarded (lexical) root vs a resolved candidate would
73    // break `is_under_prefix`. These entries are data_dir / IDE-config dirs /
74    // user-configured paths, virtually never under ~/Documents.
75    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    // Env entries are expanded too: MCP host configs pass env blocks verbatim
93    // (no shell), so "$HOME/code" arrives literally there as well.
94    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
113/// Home-level allow-dirs for the jail. `~/.lean-ctx` (own state) is always
114/// allowed; the *other* IDE config dirs (~/.cursor, ~/.claude, …) expose
115/// foreign projects' sessions, MCP configs and credentials to any agent, so
116/// they are opt-in only (config `allow_ide_config_dirs = true` or
117/// `LEAN_CTX_ALLOW_IDE_DIRS=1`).
118fn 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
136/// Heuristic canonicalize — honours the #356 TCC guard. Used by the
137/// jail-disabled bypass and by external callers (session/startup/server roots)
138/// that must not pop a privacy prompt on their own initiative.
139pub fn canonicalize_or_self(path: &Path) -> PathBuf {
140    super::pathutil::safe_canonicalize_bounded(path, 2000)
141}
142
143/// SECURITY canonicalize for the jail boundary itself (roots + candidate +
144/// escape re-check). Deliberately bypasses the #356 TCC guard: the jail must
145/// keep resolving symlinks to detect escapes, and it only ever runs on a path
146/// the client explicitly asked to access, where a one-time prompt is legitimate.
147fn 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
170/// Like [`jail_path`], but also accepts paths under any of `extra_roots`.
171///
172/// `extra_roots` are session-scoped trusted roots (MCP `roots/list` and config
173/// `extra_roots`, surfaced via `session.extra_roots`) — e.g. sibling git
174/// worktrees the agent legitimately spans. They widen the allow-list for *this
175/// call only*, so an explicit `path` under a worktree resolves instead of
176/// failing with "path escapes project root", without loosening the global jail
177/// (#403). `path_jail = false` still bypasses entirely and an empty slice is
178/// byte-for-byte identical to the old single-root behaviour.
179pub 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        // Resolve relative candidates against the (absolute) jail root — never the process
204        // CWD. The daemon's CWD is not the project, so CWD-relative resolution made
205        // graph-relative paths (e.g. auto-preload candidates like `rust/src/core/foo.rs`)
206        // spuriously fail with "no existing ancestor". Absolute candidates are unchanged.
207        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        // Session-scoped roots widen the allow-list for this call only.
217        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        // Re-validate after reconstruction: if the final path exists, canonicalize
263        // and re-check to close TOCTOU window (symlink created between check and use).
264        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        // Junctions and other reparse points redirect like symlinks but are
300        // invisible to `is_symlink()` — reject them too (GL#442).
301        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        // Hermetic config (empty data dir => jail on) so a parallel test that
319        // flips `path_jail` cannot leak into this enforcement check. Also hold the
320        // allow-path env lock: a parallel test setting `LEAN_CTX_ALLOW_PATH` (e.g.
321        // "/") would otherwise turn this escape into an accepted path.
322        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    /// #406 regression: a long-lived process (the MCP server) must honor
340    /// `path_jail = false` written to config after startup. The config cache is
341    /// now keyed on content, so even an edit that preserves the file mtime takes
342    /// effect — a path outside the jail root is accepted once the flag flips.
343    /// (With the former mtime-only cache the stale `None` kept the jail on.)
344    #[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        // Warm the config cache with the jail on (no path_jail key).
362        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        // Flip path_jail=false but restore the original mtime, so any mtime-only
367        // cache would keep serving the stale jail-on value.
368        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        // Regression: in the daemon (CWD != project) a relative graph path like
393        // `sub/file.rs` must resolve under the jail root, not the process CWD.
394        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    // P0-10 (#422): home-level IDE config dirs are opt-in; only ~/.lean-ctx
419    // is allowed unconditionally.
420    #[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        // Hold the allow-path env lock: a parallel test setting
468        // `LEAN_CTX_ALLOW_PATH="/"` would otherwise make this escape resolve to Ok.
469        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    // GH #392: config entries like "$HOME/code" or "~/code" were taken
486    // literally and never matched.
487    #[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        // Multiple variables in one entry.
497        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        // Absolute paths pass through untouched.
504        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    /// Serializes tests that mutate `LEAN_CTX_ALLOW_PATH` — cargo runs tests in
515    /// parallel threads and `set_var`/`remove_var` are process-global.
516    static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
517
518    // GH #392: `allow_paths = ["/"]` (via the same env-var channel) must grant
519    // access to any absolute path — "/" is a prefix of everything.
520    #[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        // Hold the allow-path env lock: a parallel test setting
565        // `LEAN_CTX_ALLOW_PATH="/"` would otherwise let the symlink escape resolve.
566        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    /// #403 Bug 1: an explicit path under a session-scoped `extra_root` (e.g. a
598    /// sibling git worktree from MCP `roots/list`) must resolve, while the same
599    /// path is rejected without it — and a path under *no* root is rejected even
600    /// when extra roots are present. Holds both env locks so neither a parallel
601    /// `path_jail` flip nor a `LEAN_CTX_ALLOW_PATH` mutation can leak in.
602    #[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        // Parity: with no extra roots, the worktree path escapes the jail.
621        assert!(jail_path(&in_worktree, &root).is_err());
622        assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
623
624        // The session-scoped extra root permits it — via the slice alone, with
625        // nothing in env/config.
626        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        // A path under neither the jail nor any extra root is still rejected.
633        assert!(
634            jail_path_with_roots(&outside, &root, &extra).is_err(),
635            "paths outside ALL roots must still be rejected"
636        );
637
638        // Empty entries are ignored (no accidental allow-all).
639        assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
640    }
641}