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    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    // Env entries are expanded too: MCP host configs pass env blocks verbatim
88    // (no shell), so "$HOME/code" arrives literally there as well.
89    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
112/// Home-level allow-dirs for the jail. `~/.lean-ctx` (own state) is always
113/// allowed; the *other* IDE config dirs (~/.cursor, ~/.claude, …) expose
114/// foreign projects' sessions, MCP configs and credentials to any agent, so
115/// they are opt-in only (config `allow_ide_config_dirs = true` or
116/// `LEAN_CTX_ALLOW_IDE_DIRS=1`).
117fn 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
158/// Like [`jail_path`], but also accepts paths under any of `extra_roots`.
159///
160/// `extra_roots` are session-scoped trusted roots (MCP `roots/list` and config
161/// `extra_roots`, surfaced via `session.extra_roots`) — e.g. sibling git
162/// worktrees the agent legitimately spans. They widen the allow-list for *this
163/// call only*, so an explicit `path` under a worktree resolves instead of
164/// failing with "path escapes project root", without loosening the global jail
165/// (#403). `path_jail = false` still bypasses entirely and an empty slice is
166/// byte-for-byte identical to the old single-root behaviour.
167pub 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        // Resolve relative candidates against the (absolute) jail root — never the process
192        // CWD. The daemon's CWD is not the project, so CWD-relative resolution made
193        // graph-relative paths (e.g. auto-preload candidates like `rust/src/core/foo.rs`)
194        // spuriously fail with "no existing ancestor". Absolute candidates are unchanged.
195        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        // Session-scoped roots widen the allow-list for this call only.
205        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        // Re-validate after reconstruction: if the final path exists, canonicalize
251        // and re-check to close TOCTOU window (symlink created between check and use).
252        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        // Junctions and other reparse points redirect like symlinks but are
288        // invisible to `is_symlink()` — reject them too (GL#442).
289        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        // Hermetic config (empty data dir => jail on) so a parallel test that
307        // flips `path_jail` cannot leak into this enforcement check.
308        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    /// #406 regression: a long-lived process (the MCP server) must honor
325    /// `path_jail = false` written to config after startup. The config cache is
326    /// now keyed on content, so even an edit that preserves the file mtime takes
327    /// effect — a path outside the jail root is accepted once the flag flips.
328    /// (With the former mtime-only cache the stale `None` kept the jail on.)
329    #[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        // Warm the config cache with the jail on (no path_jail key).
347        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        // Flip path_jail=false but restore the original mtime, so any mtime-only
352        // cache would keep serving the stale jail-on value.
353        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        // Regression: in the daemon (CWD != project) a relative graph path like
378        // `sub/file.rs` must resolve under the jail root, not the process CWD.
379        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    // P0-10 (#422): home-level IDE config dirs are opt-in; only ~/.lean-ctx
404    // is allowed unconditionally.
405    #[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    // GH #392: config entries like "$HOME/code" or "~/code" were taken
468    // literally and never matched.
469    #[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        // Multiple variables in one entry.
479        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        // Absolute paths pass through untouched.
486        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    /// Serializes tests that mutate `LEAN_CTX_ALLOW_PATH` — cargo runs tests in
497    /// parallel threads and `set_var`/`remove_var` are process-global.
498    static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
499
500    // GH #392: `allow_paths = ["/"]` (via the same env-var channel) must grant
501    // access to any absolute path — "/" is a prefix of everything.
502    #[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    /// #403 Bug 1: an explicit path under a session-scoped `extra_root` (e.g. a
577    /// sibling git worktree from MCP `roots/list`) must resolve, while the same
578    /// path is rejected without it — and a path under *no* root is rejected even
579    /// when extra roots are present. Holds both env locks so neither a parallel
580    /// `path_jail` flip nor a `LEAN_CTX_ALLOW_PATH` mutation can leak in.
581    #[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        // Parity: with no extra roots, the worktree path escapes the jail.
600        assert!(jail_path(&in_worktree, &root).is_err());
601        assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
602
603        // The session-scoped extra root permits it — via the slice alone, with
604        // nothing in env/config.
605        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        // A path under neither the jail nor any extra root is still rejected.
612        assert!(
613            jail_path_with_roots(&outside, &root, &extra).is_err(),
614            "paths outside ALL roots must still be rejected"
615        );
616
617        // Empty entries are ignored (no accidental allow-all).
618        assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
619    }
620}