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        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    // 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.
320        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    /// #406 regression: a long-lived process (the MCP server) must honor
337    /// `path_jail = false` written to config after startup. The config cache is
338    /// now keyed on content, so even an edit that preserves the file mtime takes
339    /// effect — a path outside the jail root is accepted once the flag flips.
340    /// (With the former mtime-only cache the stale `None` kept the jail on.)
341    #[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        // Warm the config cache with the jail on (no path_jail key).
359        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        // Flip path_jail=false but restore the original mtime, so any mtime-only
364        // cache would keep serving the stale jail-on value.
365        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        // Regression: in the daemon (CWD != project) a relative graph path like
390        // `sub/file.rs` must resolve under the jail root, not the process CWD.
391        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    // P0-10 (#422): home-level IDE config dirs are opt-in; only ~/.lean-ctx
416    // is allowed unconditionally.
417    #[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    // GH #392: config entries like "$HOME/code" or "~/code" were taken
480    // literally and never matched.
481    #[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        // Multiple variables in one entry.
491        std::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        std::env::remove_var("LEAN_CTX_TEST_SUB");
497        // Absolute paths pass through untouched.
498        assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
499    }
500
501    #[test]
502    fn expand_user_path_leaves_unset_vars_verbatim() {
503        std::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    /// Serializes tests that mutate `LEAN_CTX_ALLOW_PATH` — cargo runs tests in
509    /// parallel threads and `set_var`/`remove_var` are process-global.
510    static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
511
512    // GH #392: `allow_paths = ["/"]` (via the same env-var channel) must grant
513    // access to any absolute path — "/" is a prefix of everything.
514    #[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        std::env::set_var("LEAN_CTX_ALLOW_PATH", "/");
526        let result = jail_path(&other.join("b.txt"), &root);
527        std::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        std::env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
544        let result = jail_path(&other.join("b.txt"), &root);
545        std::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    /// #403 Bug 1: an explicit path under a session-scoped `extra_root` (e.g. a
589    /// sibling git worktree from MCP `roots/list`) must resolve, while the same
590    /// path is rejected without it — and a path under *no* root is rejected even
591    /// when extra roots are present. Holds both env locks so neither a parallel
592    /// `path_jail` flip nor a `LEAN_CTX_ALLOW_PATH` mutation can leak in.
593    #[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        // Parity: with no extra roots, the worktree path escapes the jail.
612        assert!(jail_path(&in_worktree, &root).is_err());
613        assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
614
615        // The session-scoped extra root permits it — via the slice alone, with
616        // nothing in env/config.
617        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        // A path under neither the jail nor any extra root is still rejected.
624        assert!(
625            jail_path_with_roots(&outside, &root, &extra).is_err(),
626            "paths outside ALL roots must still be rejected"
627        );
628
629        // Empty entries are ignored (no accidental allow-all).
630        assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
631    }
632}