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];
19
20/// Expands `~`, `$VAR` and `${VAR}` in a config-supplied path entry.
21///
22/// `allow_paths` / `extra_roots` come from `config.toml`, where no shell ever
23/// runs — users writing `"$HOME/code"` or `"~/code"` got a literal,
24/// never-matching prefix and concluded the whole option was broken (GH #392).
25/// Unset variables are left verbatim (and warned about) so the entry fails
26/// loudly in `lean-ctx doctor` instead of silently matching something else.
27pub fn expand_user_path(raw: &str) -> PathBuf {
28    let mut s = raw.to_string();
29
30    if s == "~" || s.starts_with("~/") {
31        if let Some(home) = dirs::home_dir() {
32            s = format!("{}{}", home.to_string_lossy(), &s[1..]);
33        }
34    }
35
36    while let Some(start) = s.find('$') {
37        let rest = &s[start + 1..];
38        let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
39            match stripped.find('}') {
40                Some(end) => (stripped[..end].to_string(), end + 3),
41                None => break,
42            }
43        } else {
44            let end = rest
45                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
46                .unwrap_or(rest.len());
47            (rest[..end].to_string(), end + 1)
48        };
49        if name.is_empty() {
50            break;
51        }
52        if let Ok(val) = std::env::var(&name) {
53            s.replace_range(start..start + token_len, &val);
54        } else {
55            tracing::warn!(
56                "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
57            );
58            break;
59        }
60    }
61
62    PathBuf::from(s)
63}
64
65pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
66    let mut out = Vec::new();
67    let cfg = crate::core::config::Config::load();
68
69    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
70        out.push(canonicalize_or_self(&data_dir));
71    }
72
73    if let Some(home) = dirs::home_dir() {
74        let ide_dirs_allowed = cfg.allow_ide_config_dirs
75            || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
76        out.extend(home_allow_dirs(&home, ide_dirs_allowed));
77    }
78
79    for p in &cfg.allow_paths {
80        out.push(canonicalize_or_self(&expand_user_path(p)));
81    }
82    for p in &cfg.extra_roots {
83        out.push(canonicalize_or_self(&expand_user_path(p)));
84    }
85
86    // Env entries are expanded too: MCP host configs pass env blocks verbatim
87    // (no shell), so "$HOME/code" arrives literally there as well.
88    let v = std::env::var("LCTX_ALLOW_PATH")
89        .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
90        .unwrap_or_default();
91    if !v.trim().is_empty() {
92        for p in std::env::split_paths(&v) {
93            out.push(canonicalize_or_self(&expand_user_path(
94                &p.to_string_lossy(),
95            )));
96        }
97    }
98
99    let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
100    if !extra.trim().is_empty() {
101        for p in std::env::split_paths(&extra) {
102            out.push(canonicalize_or_self(&expand_user_path(
103                &p.to_string_lossy(),
104            )));
105        }
106    }
107
108    out
109}
110
111/// Home-level allow-dirs for the jail. `~/.lean-ctx` (own state) is always
112/// allowed; the *other* IDE config dirs (~/.cursor, ~/.claude, …) expose
113/// foreign projects' sessions, MCP configs and credentials to any agent, so
114/// they are opt-in only (config `allow_ide_config_dirs = true` or
115/// `LEAN_CTX_ALLOW_IDE_DIRS=1`).
116fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
117    let mut out = Vec::new();
118    for dir in IDE_CONFIG_DIRS {
119        if *dir != ".lean-ctx" && !ide_dirs_allowed {
120            continue;
121        }
122        let p = home.join(dir);
123        if p.exists() {
124            out.push(canonicalize_or_self(&p));
125        }
126    }
127    out
128}
129
130fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
131    path.starts_with(prefix)
132}
133
134pub fn canonicalize_or_self(path: &Path) -> PathBuf {
135    super::pathutil::safe_canonicalize_bounded(path, 2000)
136}
137
138fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
139    let mut cur = path.to_path_buf();
140    let mut remainder: Vec<std::ffi::OsString> = Vec::new();
141    loop {
142        if cur.exists() {
143            return Some((canonicalize_or_self(&cur), remainder));
144        }
145        let name = cur.file_name()?.to_os_string();
146        remainder.push(name);
147        if !cur.pop() {
148            return None;
149        }
150    }
151}
152
153pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
154    jail_path_with_roots(candidate, jail_root, &[])
155}
156
157/// Like [`jail_path`], but also accepts paths under any of `extra_roots`.
158///
159/// `extra_roots` are session-scoped trusted roots (MCP `roots/list` and config
160/// `extra_roots`, surfaced via `session.extra_roots`) — e.g. sibling git
161/// worktrees the agent legitimately spans. They widen the allow-list for *this
162/// call only*, so an explicit `path` under a worktree resolves instead of
163/// failing with "path escapes project root", without loosening the global jail
164/// (#403). `path_jail = false` still bypasses entirely and an empty slice is
165/// byte-for-byte identical to the old single-root behaviour.
166pub fn jail_path_with_roots(
167    candidate: &Path,
168    jail_root: &Path,
169    extra_roots: &[String],
170) -> Result<PathBuf, String> {
171    if candidate.to_string_lossy().as_bytes().contains(&0) {
172        return Err("path contains null byte".to_string());
173    }
174
175    #[cfg(feature = "no-jail")]
176    {
177        let _ = (jail_root, extra_roots);
178        return Ok(canonicalize_or_self(candidate));
179    }
180
181    #[allow(unreachable_code)]
182    {
183        let cfg = crate::core::config::Config::load();
184        if cfg.path_jail == Some(false) {
185            return Ok(canonicalize_or_self(candidate));
186        }
187
188        let root = canonicalize_or_self(jail_root);
189
190        // Resolve relative candidates against the (absolute) jail root — never the process
191        // CWD. The daemon's CWD is not the project, so CWD-relative resolution made
192        // graph-relative paths (e.g. auto-preload candidates like `rust/src/core/foo.rs`)
193        // spuriously fail with "no existing ancestor". Absolute candidates are unchanged.
194        let resolved: PathBuf;
195        let candidate: &Path = if candidate.is_absolute() {
196            candidate
197        } else {
198            resolved = root.join(candidate);
199            resolved.as_path()
200        };
201
202        let mut allow = allow_paths_from_env_and_config();
203        // Session-scoped roots widen the allow-list for this call only.
204        allow.extend(
205            extra_roots
206                .iter()
207                .filter(|r| !r.is_empty())
208                .map(|r| canonicalize_or_self(Path::new(r))),
209        );
210
211        let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
212            format!(
213                "path does not exist and has no existing ancestor: {}",
214                candidate.display()
215            )
216        })?;
217
218        let allowed =
219            is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
220
221        #[cfg(windows)]
222        let allowed = allowed || is_under_prefix_windows(&base, &root);
223
224        if !allowed {
225            let base_msg = format!(
226                "path escapes project root: {} (root: {})",
227                candidate.display(),
228                root.display(),
229            );
230            let hint = if crate::core::protocol::meta_visible() {
231                format!(
232                ". Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
233                candidate.parent().unwrap_or(candidate).display()
234            )
235            } else {
236                String::new()
237            };
238            return Err(format!("{base_msg}{hint}"));
239        }
240
241        #[cfg(windows)]
242        reject_symlink_on_windows(candidate)?;
243
244        let mut out = base;
245        for part in remainder.iter().rev() {
246            out.push(part);
247        }
248
249        // Re-validate after reconstruction: if the final path exists, canonicalize
250        // and re-check to close TOCTOU window (symlink created between check and use).
251        if out.exists() {
252            let final_canon = canonicalize_or_self(&out);
253            let final_ok = is_under_prefix(&final_canon, &root)
254                || allow.iter().any(|p| is_under_prefix(&final_canon, p));
255            #[cfg(windows)]
256            let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
257            if !final_ok {
258                return Err(format!(
259                    "post-canonicalize jail escape detected: {} resolves to {}",
260                    candidate.display(),
261                    final_canon.display()
262                ));
263            }
264        }
265
266        Ok(out)
267    }
268}
269
270#[cfg(windows)]
271fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
272    let path_str = normalize_windows_path(&path.to_string_lossy());
273    let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
274    path_str.starts_with(&prefix_str)
275}
276
277#[cfg(windows)]
278fn normalize_windows_path(s: &str) -> String {
279    let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
280    stripped.to_lowercase().replace('/', "\\")
281}
282
283#[cfg(windows)]
284fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
285    if let Ok(meta) = std::fs::symlink_metadata(path) {
286        // Junctions and other reparse points redirect like symlinks but are
287        // invisible to `is_symlink()` — reject them too (GL#442).
288        if super::pathutil::is_symlink_or_reparse(&meta) {
289            return Err(format!(
290                "symlink not allowed in jailed path: {}",
291                path.display()
292            ));
293        }
294    }
295    Ok(())
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[cfg(not(feature = "no-jail"))]
303    #[test]
304    fn rejects_path_outside_root() {
305        // Hermetic config (empty data dir => jail on) so a parallel test that
306        // flips `path_jail` cannot leak into this enforcement check.
307        let _iso = crate::core::data_dir::isolated_data_dir();
308        let tmp = tempfile::tempdir().unwrap();
309        let root = tmp.path().join("root");
310        let other = tmp.path().join("other");
311        std::fs::create_dir_all(&root).unwrap();
312        std::fs::create_dir_all(&other).unwrap();
313        std::fs::write(root.join("a.txt"), "ok").unwrap();
314        std::fs::write(other.join("b.txt"), "no").unwrap();
315
316        let ok = jail_path(&root.join("a.txt"), &root);
317        assert!(ok.is_ok());
318
319        let bad = jail_path(&other.join("b.txt"), &root);
320        assert!(bad.is_err());
321    }
322
323    /// #406 regression: a long-lived process (the MCP server) must honor
324    /// `path_jail = false` written to config after startup. The config cache is
325    /// now keyed on content, so even an edit that preserves the file mtime takes
326    /// effect — a path outside the jail root is accepted once the flag flips.
327    /// (With the former mtime-only cache the stale `None` kept the jail on.)
328    #[cfg(not(feature = "no-jail"))]
329    #[test]
330    fn honors_path_jail_false_after_mtime_preserving_edit() {
331        let _iso = crate::core::data_dir::isolated_data_dir();
332        let cfg_path = crate::core::config::Config::path().unwrap();
333        if let Some(parent) = cfg_path.parent() {
334            std::fs::create_dir_all(parent).unwrap();
335        }
336
337        let tmp = tempfile::tempdir().unwrap();
338        let root = tmp.path().join("project");
339        let outside = tmp.path().join("outside");
340        std::fs::create_dir_all(&root).unwrap();
341        std::fs::create_dir_all(&outside).unwrap();
342        let secret = outside.join("secret.txt");
343        std::fs::write(&secret, "x").unwrap();
344
345        // Warm the config cache with the jail on (no path_jail key).
346        std::fs::write(&cfg_path, "# jail on\n").unwrap();
347        let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
348        assert_eq!(crate::core::config::Config::load().path_jail, None);
349
350        // Flip path_jail=false but restore the original mtime, so any mtime-only
351        // cache would keep serving the stale jail-on value.
352        std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
353        filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
354
355        assert!(
356            jail_path(&secret, &root).is_ok(),
357            "path_jail=false must take effect without a fresh process (#406)"
358        );
359    }
360
361    #[test]
362    fn allows_nonexistent_child_under_root() {
363        let tmp = tempfile::tempdir().unwrap();
364        let root = tmp.path().join("root");
365        std::fs::create_dir_all(&root).unwrap();
366        std::fs::write(root.join("a.txt"), "ok").unwrap();
367
368        let p = root.join("new").join("file.txt");
369        let ok = jail_path(&p, &root).unwrap();
370        assert!(ok.to_string_lossy().contains("file.txt"));
371    }
372
373    #[cfg(not(feature = "no-jail"))]
374    #[test]
375    fn relative_candidate_resolves_against_root_not_cwd() {
376        // Regression: in the daemon (CWD != project) a relative graph path like
377        // `sub/file.rs` must resolve under the jail root, not the process CWD.
378        let _iso = crate::core::data_dir::isolated_data_dir();
379        let tmp = tempfile::tempdir().unwrap();
380        let root = tmp.path().join("project");
381        std::fs::create_dir_all(root.join("sub")).unwrap();
382        std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
383
384        let jailed = jail_path(Path::new("sub/file.rs"), &root)
385            .expect("relative candidate should resolve under the jail root");
386        assert!(jailed.ends_with("sub/file.rs"));
387        assert!(
388            is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
389            "resolved path must live under the jail root: {jailed:?}"
390        );
391    }
392
393    #[test]
394    fn ide_config_dirs_list_is_not_empty() {
395        assert!(IDE_CONFIG_DIRS.len() >= 10);
396        assert!(IDE_CONFIG_DIRS.contains(&".codex"));
397        assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
398        assert!(IDE_CONFIG_DIRS.contains(&".claude"));
399        assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
400    }
401
402    // P0-10 (#422): home-level IDE config dirs are opt-in; only ~/.lean-ctx
403    // is allowed unconditionally.
404    #[test]
405    fn ide_config_dirs_are_excluded_by_default() {
406        let home = tempfile::tempdir().unwrap();
407        for d in [".lean-ctx", ".cursor", ".claude", ".codex"] {
408            std::fs::create_dir_all(home.path().join(d)).unwrap();
409        }
410
411        let denied = home_allow_dirs(home.path(), false);
412        assert_eq!(
413            denied.len(),
414            1,
415            "only ~/.lean-ctx may be allowed: {denied:?}"
416        );
417        assert!(denied[0].ends_with(".lean-ctx"));
418
419        let allowed = home_allow_dirs(home.path(), true);
420        assert_eq!(allowed.len(), 4, "opt-in must allow all existing IDE dirs");
421    }
422
423    #[test]
424    fn canonicalize_or_self_strips_verbatim() {
425        let tmp = tempfile::tempdir().unwrap();
426        let dir = tmp.path().join("project");
427        std::fs::create_dir_all(&dir).unwrap();
428
429        let result = canonicalize_or_self(&dir);
430        let s = result.to_string_lossy();
431        assert!(
432            !s.starts_with(r"\\?\"),
433            "canonicalize_or_self should strip verbatim prefix, got: {s}"
434        );
435    }
436
437    #[test]
438    fn jail_path_accepts_same_dir_different_format() {
439        let tmp = tempfile::tempdir().unwrap();
440        let root = tmp.path().join("project");
441        std::fs::create_dir_all(&root).unwrap();
442        std::fs::write(root.join("file.rs"), "ok").unwrap();
443
444        let result = jail_path(&root.join("file.rs"), &root);
445        assert!(result.is_ok(), "same dir should be accepted: {result:?}");
446    }
447
448    #[cfg(not(feature = "no-jail"))]
449    #[test]
450    fn error_message_contains_escape_info() {
451        let _iso = crate::core::data_dir::isolated_data_dir();
452        let tmp = tempfile::tempdir().unwrap();
453        let root = tmp.path().join("root");
454        let other = tmp.path().join("other");
455        std::fs::create_dir_all(&root).unwrap();
456        std::fs::create_dir_all(&other).unwrap();
457        std::fs::write(other.join("b.txt"), "no").unwrap();
458
459        let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
460        assert!(
461            err.contains("path escapes project root"),
462            "error should mention escape: {err}"
463        );
464    }
465
466    // GH #392: config entries like "$HOME/code" or "~/code" were taken
467    // literally and never matched.
468    #[test]
469    fn expand_user_path_expands_tilde_and_vars() {
470        let home = dirs::home_dir().expect("home dir");
471        let home_s = home.to_string_lossy().to_string();
472
473        assert_eq!(expand_user_path("~"), home);
474        assert_eq!(expand_user_path("~/code"), home.join("code"));
475        assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
476        assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
477        // Multiple variables in one entry.
478        std::env::set_var("LEAN_CTX_TEST_SUB", "sub");
479        assert_eq!(
480            expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
481            PathBuf::from(format!("{home_s}/sub/x"))
482        );
483        std::env::remove_var("LEAN_CTX_TEST_SUB");
484        // Absolute paths pass through untouched.
485        assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
486    }
487
488    #[test]
489    fn expand_user_path_leaves_unset_vars_verbatim() {
490        std::env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
491        let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
492        assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
493    }
494
495    /// Serializes tests that mutate `LEAN_CTX_ALLOW_PATH` — cargo runs tests in
496    /// parallel threads and `set_var`/`remove_var` are process-global.
497    static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
498
499    // GH #392: `allow_paths = ["/"]` (via the same env-var channel) must grant
500    // access to any absolute path — "/" is a prefix of everything.
501    #[cfg(unix)]
502    #[test]
503    fn allow_path_root_slash_permits_everything() {
504        let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
505        let tmp = tempfile::tempdir().unwrap();
506        let root = tmp.path().join("root");
507        let other = tmp.path().join("other");
508        std::fs::create_dir_all(&root).unwrap();
509        std::fs::create_dir_all(&other).unwrap();
510        std::fs::write(other.join("b.txt"), "allowed").unwrap();
511
512        std::env::set_var("LEAN_CTX_ALLOW_PATH", "/");
513        let result = jail_path(&other.join("b.txt"), &root);
514        std::env::remove_var("LEAN_CTX_ALLOW_PATH");
515
516        assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
517    }
518
519    #[test]
520    fn allow_path_env_permits_outside_root() {
521        let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
522        let tmp = tempfile::tempdir().unwrap();
523        let root = tmp.path().join("root");
524        let other = tmp.path().join("other");
525        std::fs::create_dir_all(&root).unwrap();
526        std::fs::create_dir_all(&other).unwrap();
527        std::fs::write(other.join("b.txt"), "allowed").unwrap();
528
529        let canon = canonicalize_or_self(&other);
530        std::env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
531        let result = jail_path(&other.join("b.txt"), &root);
532        std::env::remove_var("LEAN_CTX_ALLOW_PATH");
533
534        assert!(
535            result.is_ok(),
536            "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
537        );
538    }
539
540    #[cfg(all(unix, not(feature = "no-jail")))]
541    #[test]
542    fn rejects_symlink_escape_on_unix() {
543        use std::os::unix::fs::symlink;
544
545        let _iso = crate::core::data_dir::isolated_data_dir();
546        let tmp = tempfile::tempdir().unwrap();
547        let root = tmp.path().join("root");
548        let other = tmp.path().join("other");
549        std::fs::create_dir_all(&root).unwrap();
550        std::fs::create_dir_all(&other).unwrap();
551        std::fs::write(other.join("secret.txt"), "no").unwrap();
552
553        let link = root.join("link.txt");
554        symlink(other.join("secret.txt"), &link).unwrap();
555
556        let bad = jail_path(&link, &root);
557        assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
558    }
559
560    #[test]
561    fn rejects_null_byte_in_path() {
562        let tmp = tempfile::tempdir().unwrap();
563        let root = tmp.path().join("root");
564        std::fs::create_dir_all(&root).unwrap();
565
566        let bad_path = PathBuf::from("file\0.txt");
567        let result = jail_path(&bad_path, &root);
568        assert!(result.is_err(), "null byte in path must be rejected");
569        assert!(
570            result.unwrap_err().contains("null byte"),
571            "error must mention null byte"
572        );
573    }
574
575    /// #403 Bug 1: an explicit path under a session-scoped `extra_root` (e.g. a
576    /// sibling git worktree from MCP `roots/list`) must resolve, while the same
577    /// path is rejected without it — and a path under *no* root is rejected even
578    /// when extra roots are present. Holds both env locks so neither a parallel
579    /// `path_jail` flip nor a `LEAN_CTX_ALLOW_PATH` mutation can leak in.
580    #[cfg(not(feature = "no-jail"))]
581    #[test]
582    fn extra_roots_permit_paths_outside_jail() {
583        let _iso = crate::core::data_dir::isolated_data_dir();
584        let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
585
586        let tmp = tempfile::tempdir().unwrap();
587        let root = tmp.path().join("project");
588        let worktree = tmp.path().join("worktree");
589        let elsewhere = tmp.path().join("elsewhere");
590        for d in [&root, &worktree, &elsewhere] {
591            std::fs::create_dir_all(d).unwrap();
592        }
593        let in_worktree = worktree.join("a.txt");
594        std::fs::write(&in_worktree, "x").unwrap();
595        let outside = elsewhere.join("b.txt");
596        std::fs::write(&outside, "y").unwrap();
597
598        // Parity: with no extra roots, the worktree path escapes the jail.
599        assert!(jail_path(&in_worktree, &root).is_err());
600        assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
601
602        // The session-scoped extra root permits it — via the slice alone, with
603        // nothing in env/config.
604        let extra = vec![worktree.to_string_lossy().to_string()];
605        assert!(
606            jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
607            "path under a session extra_root must resolve (#403)"
608        );
609
610        // A path under neither the jail nor any extra root is still rejected.
611        assert!(
612            jail_path_with_roots(&outside, &root, &extra).is_err(),
613            "paths outside ALL roots must still be rejected"
614        );
615
616        // Empty entries are ignored (no accidental allow-all).
617        assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
618    }
619}