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/// `allow_paths` / `extra_roots` come from `config.toml`, where no shell ever
22/// runs — users writing `"$HOME/code"` or `"~/code"` got a literal,
23/// never-matching prefix and concluded the whole option was broken (GH #392).
24/// Unset variables are left verbatim (and warned about) so the entry fails
25/// loudly in `lean-ctx doctor` instead of silently matching something else.
26pub fn expand_user_path(raw: &str) -> PathBuf {
27    let mut s = raw.to_string();
28
29    if (s == "~" || s.starts_with("~/"))
30        && let Some(home) = dirs::home_dir()
31    {
32        s = format!("{}{}", home.to_string_lossy(), &s[1..]);
33    }
34
35    while let Some(start) = s.find('$') {
36        let rest = &s[start + 1..];
37        let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
38            match stripped.find('}') {
39                Some(end) => (stripped[..end].to_string(), end + 3),
40                None => break,
41            }
42        } else {
43            let end = rest
44                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
45                .unwrap_or(rest.len());
46            (rest[..end].to_string(), end + 1)
47        };
48        if name.is_empty() {
49            break;
50        }
51        if let Ok(val) = std::env::var(&name) {
52            s.replace_range(start..start + token_len, &val);
53        } else {
54            tracing::warn!(
55                "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
56            );
57            break;
58        }
59    }
60
61    PathBuf::from(s)
62}
63
64pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
65    let mut out = Vec::new();
66    let cfg = crate::core::config::Config::load();
67
68    // The allow-list defines the jail boundary, so it must be canonicalized the
69    // same (security, symlink-resolving) way as the candidate it is compared
70    // against — otherwise a guarded (lexical) root vs a resolved candidate would
71    // break `is_under_prefix`. These entries are data_dir / IDE-config dirs /
72    // user-configured paths, virtually never under ~/Documents.
73    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
74        out.push(canonicalize_secure(&data_dir));
75    }
76
77    if let Some(home) = dirs::home_dir() {
78        let ide_dirs_allowed = cfg.allow_ide_config_dirs
79            || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
80        out.extend(home_allow_dirs(&home, ide_dirs_allowed));
81    }
82
83    for p in &cfg.allow_paths {
84        out.push(canonicalize_secure(&expand_user_path(p)));
85    }
86    for p in &cfg.extra_roots {
87        out.push(canonicalize_secure(&expand_user_path(p)));
88    }
89
90    // Env entries are expanded too: MCP host configs pass env blocks verbatim
91    // (no shell), so "$HOME/code" arrives literally there as well.
92    let v = std::env::var("LCTX_ALLOW_PATH")
93        .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
94        .unwrap_or_default();
95    if !v.trim().is_empty() {
96        for p in std::env::split_paths(&v) {
97            out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
98        }
99    }
100
101    let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
102    if !extra.trim().is_empty() {
103        for p in std::env::split_paths(&extra) {
104            out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
105        }
106    }
107
108    // Read-only roots are *readable* (the whole point is read access to sibling
109    // repos); writes into them are denied separately by `enforce_writable`
110    // (#475). Add them to the read allow-list so reads resolve, exactly like
111    // `extra_roots`, without granting write access.
112    out.extend(canonicalized_roots(
113        &cfg.read_only_roots,
114        "LEAN_CTX_READ_ONLY_ROOTS",
115    ));
116
117    out
118}
119
120/// Canonicalize a set of config-supplied root entries plus an env override
121/// (path-list separated), expanding `~`/`$VAR` first. Shared by the read
122/// allow-list and the read-only-roots collector so both tiers parse roots
123/// identically.
124fn canonicalized_roots(config_entries: &[String], env_var: &str) -> Vec<PathBuf> {
125    let mut out = Vec::new();
126    for p in config_entries {
127        out.push(canonicalize_secure(&expand_user_path(p)));
128    }
129    let v = std::env::var(env_var).unwrap_or_default();
130    if !v.trim().is_empty() {
131        for p in std::env::split_paths(&v) {
132            out.push(canonicalize_secure(&expand_user_path(&p.to_string_lossy())));
133        }
134    }
135    out
136}
137
138/// A read-only root is a sibling subtree the agent may **read** but never
139/// **write** — e.g. a reference repo mounted next to the project. Empty by
140/// default, so [`is_read_only_path`]/[`enforce_writable`] are zero-cost no-ops
141/// for everyone who hasn't opted in (#475).
142pub fn read_only_roots_from_env_and_config() -> Vec<PathBuf> {
143    let cfg = crate::core::config::Config::load();
144    canonicalized_roots(&cfg.read_only_roots, "LEAN_CTX_READ_ONLY_ROOTS")
145}
146
147/// A single active relaxation of the path jail. Each one widens or disables what
148/// tools can reach beyond the project root, so it is surfaced loudly (GH security
149/// audit, finding 3): the MCP/HTTP server inherits its process env from the
150/// IDE/launchd, so a globally-set `LEAN_CTX_ALLOW_PATH` / `LEAN_CTX_EXTRA_ROOTS`
151/// / `LEAN_CTX_ALLOW_IDE_DIRS` (or `path_jail = false`) silently loosens the
152/// boundary with no in-band signal otherwise.
153#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct JailRelaxation {
155    /// The knob that activated it (env var name, config key, or build feature).
156    pub source: &'static str,
157    /// Human-readable effect of the relaxation.
158    pub detail: &'static str,
159}
160
161fn env_is_set(var: &str) -> bool {
162    std::env::var(var).is_ok_and(|v| !v.trim().is_empty())
163}
164
165/// Collect every currently-active path-jail relaxation. An empty result means
166/// the jail is fully in force. This is the single source of truth shared by the
167/// startup warning ([`warn_if_relaxed`]) and `lean-ctx doctor`.
168#[must_use]
169pub fn active_relaxations() -> Vec<JailRelaxation> {
170    let mut out = Vec::new();
171
172    if cfg!(feature = "no-jail") {
173        out.push(JailRelaxation {
174            source: "no-jail (build feature)",
175            detail: "path jail compiled out — every tool path is allowed",
176        });
177    }
178
179    if crate::core::config::Config::load().path_jail == Some(false) {
180        out.push(JailRelaxation {
181            source: "path_jail = false (config.toml)",
182            detail: "path jail disabled — every tool path is allowed",
183        });
184    }
185
186    if env_is_set("LEAN_CTX_ALLOW_PATH") || env_is_set("LCTX_ALLOW_PATH") {
187        out.push(JailRelaxation {
188            source: "LEAN_CTX_ALLOW_PATH",
189            detail: "widens the read/write allow-list beyond the project root",
190        });
191    }
192
193    if env_is_set("LEAN_CTX_EXTRA_ROOTS") {
194        out.push(JailRelaxation {
195            source: "LEAN_CTX_EXTRA_ROOTS",
196            detail: "adds extra accessible roots beyond the project root",
197        });
198    }
199
200    let ide_env = std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
201    if ide_env || crate::core::config::Config::load().allow_ide_config_dirs {
202        out.push(JailRelaxation {
203            source: if ide_env {
204                "LEAN_CTX_ALLOW_IDE_DIRS=1"
205            } else {
206                "allow_ide_config_dirs = true (config.toml)"
207            },
208            detail: "exposes ~/.cursor, ~/.claude, … (other agents' sessions/credentials) to tools",
209        });
210    }
211
212    out
213}
214
215/// Emit a loud `tracing::warn!` for every active path-jail relaxation. Called
216/// once at MCP/HTTP server startup so a trusted-but-loosening env/config leaves
217/// an in-band audit signal instead of silently defeating the jail (finding 3).
218pub fn warn_if_relaxed() {
219    for relaxation in active_relaxations() {
220        tracing::warn!(
221            "[SECURITY] path jail relaxed via {}: {} — intended for trusted local use only",
222            relaxation.source,
223            relaxation.detail
224        );
225    }
226}
227
228/// True when `candidate` resolves to a location inside a configured read-only
229/// root. The candidate's nearest existing ancestor is canonicalized (so a
230/// not-yet-existing file inherits the read-only status of the directory it
231/// would be created in — closing the "create a new file in a read-only repo"
232/// hole) and matched against the (symlink-resolved) read-only roots.
233///
234/// A `false` return is only authoritative when the roots list is empty or the
235/// path provably sits outside every root; an unresolvable candidate (no
236/// existing ancestor) is treated as *not* read-only here and is rejected later
237/// by the ordinary write/jail error, never silently written.
238pub fn is_read_only_path(candidate: &Path) -> bool {
239    let roots = read_only_roots_from_env_and_config();
240    if roots.is_empty() {
241        return false;
242    }
243
244    // Compare the canonicalized nearest-existing-ancestor (resolves symlinks so
245    // a symlink *into* a read-only root can't launder a write past the prefix
246    // check), reconstructing the full path for the comparison.
247    let base = match canonicalize_existing_ancestor(candidate) {
248        Some((base, remainder)) => {
249            let mut p = base;
250            for part in remainder.iter().rev() {
251                p.push(part);
252            }
253            p
254        }
255        None => canonicalize_or_self(candidate),
256    };
257
258    roots.iter().any(|r| is_under_prefix(&base, r))
259}
260
261/// Default-deny write guard for the read-only tier (#475): returns an error if
262/// `candidate` is inside a configured read-only root, `Ok(())` otherwise.
263///
264/// This is the single read-only-aware choke point. Every filesystem write that
265/// can target a caller-supplied path routes through it (the atomic writers in
266/// `ctx_edit`/`edit_apply`, the handoff/session export bundle writers, the
267/// in-place memory-compaction writer, and the refactor IDE pre-write gate), so
268/// a "read-only" root cannot be written through any tool. Reads are unaffected.
269pub fn enforce_writable(candidate: &Path) -> Result<(), String> {
270    if is_read_only_path(candidate) {
271        return Err(format!(
272            "path is inside a read-only root — writes are denied (read_only_roots): {}",
273            candidate.display()
274        ));
275    }
276    Ok(())
277}
278
279/// Home-level allow-dirs for the jail. `~/.lean-ctx` (own state) is always
280/// allowed; the *other* IDE config dirs (~/.cursor, ~/.claude, …) expose
281/// foreign projects' sessions, MCP configs and credentials to any agent, so
282/// they are opt-in only (config `allow_ide_config_dirs = true` or
283/// `LEAN_CTX_ALLOW_IDE_DIRS=1`).
284fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
285    let mut out = Vec::new();
286    for dir in IDE_CONFIG_DIRS {
287        if *dir != ".lean-ctx" && !ide_dirs_allowed {
288            continue;
289        }
290        let p = home.join(dir);
291        if p.exists() {
292            out.push(canonicalize_secure(&p));
293        }
294    }
295    out
296}
297
298fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
299    path.starts_with(prefix)
300}
301
302/// Heuristic canonicalize — honours the #356 TCC guard. Used by the
303/// jail-disabled bypass and by external callers (session/startup/server roots)
304/// that must not pop a privacy prompt on their own initiative.
305pub fn canonicalize_or_self(path: &Path) -> PathBuf {
306    super::pathutil::safe_canonicalize_bounded(path, 2000)
307}
308
309/// SECURITY canonicalize for the jail boundary itself (roots + candidate +
310/// escape re-check). Deliberately bypasses the #356 TCC guard: the jail must
311/// keep resolving symlinks to detect escapes, and it only ever runs on a path
312/// the client explicitly asked to access, where a one-time prompt is legitimate.
313fn canonicalize_secure(path: &Path) -> PathBuf {
314    super::pathutil::canonicalize_secure_bounded(path, 2000)
315}
316
317fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
318    let mut cur = path.to_path_buf();
319    let mut remainder: Vec<std::ffi::OsString> = Vec::new();
320    loop {
321        if cur.exists() {
322            return Some((canonicalize_secure(&cur), remainder));
323        }
324        let name = cur.file_name()?.to_os_string();
325        remainder.push(name);
326        if !cur.pop() {
327            return None;
328        }
329    }
330}
331
332pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
333    jail_path_with_roots(candidate, jail_root, &[])
334}
335
336/// Like [`jail_path`], but also accepts paths under any of `extra_roots`.
337///
338/// `extra_roots` are session-scoped trusted roots (MCP `roots/list` and config
339/// `extra_roots`, surfaced via `session.extra_roots`) — e.g. sibling git
340/// worktrees the agent legitimately spans. They widen the allow-list for *this
341/// call only*, so an explicit `path` under a worktree resolves instead of
342/// failing with "path escapes project root", without loosening the global jail
343/// (#403). `path_jail = false` still bypasses entirely and an empty slice is
344/// byte-for-byte identical to the old single-root behaviour.
345pub fn jail_path_with_roots(
346    candidate: &Path,
347    jail_root: &Path,
348    extra_roots: &[String],
349) -> Result<PathBuf, String> {
350    if candidate.to_string_lossy().as_bytes().contains(&0) {
351        return Err("path contains null byte".to_string());
352    }
353
354    #[cfg(feature = "no-jail")]
355    {
356        let _ = (jail_root, extra_roots);
357        return Ok(canonicalize_or_self(candidate));
358    }
359
360    #[allow(unreachable_code)]
361    {
362        let cfg = crate::core::config::Config::load();
363        if cfg.path_jail == Some(false) {
364            return Ok(canonicalize_or_self(candidate));
365        }
366
367        let root = canonicalize_secure(jail_root);
368
369        // Resolve relative candidates against the (absolute) jail root — never the process
370        // CWD. The daemon's CWD is not the project, so CWD-relative resolution made
371        // graph-relative paths (e.g. auto-preload candidates like `rust/src/core/foo.rs`)
372        // spuriously fail with "no existing ancestor". Absolute candidates are unchanged.
373        let resolved: PathBuf;
374        let candidate: &Path = if candidate.is_absolute() {
375            candidate
376        } else {
377            resolved = root.join(candidate);
378            resolved.as_path()
379        };
380
381        let mut allow = allow_paths_from_env_and_config();
382        // Session-scoped roots widen the allow-list for this call only.
383        allow.extend(
384            extra_roots
385                .iter()
386                .filter(|r| !r.is_empty())
387                .map(|r| canonicalize_secure(Path::new(r))),
388        );
389
390        let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
391            format!(
392                "path does not exist and has no existing ancestor: {}",
393                candidate.display()
394            )
395        })?;
396
397        let allowed =
398            is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
399
400        #[cfg(windows)]
401        let allowed = allowed || is_under_prefix_windows(&base, &root);
402
403        if !allowed {
404            let base_msg = format!(
405                "path escapes project root: {} (root: {})",
406                candidate.display(),
407                root.display(),
408            );
409            let mut hint = if crate::core::protocol::meta_visible() {
410                format!(
411                    ". Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
412                    candidate.parent().unwrap_or(candidate).display()
413                )
414            } else {
415                String::new()
416            };
417            // An untrusted workspace's project-local `allow_paths` is silently
418            // withheld; always surface that reason (the stderr warning is
419            // invisible over MCP, and the hint above is meta-gated off) (#540).
420            if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
421                hint.push_str(". ");
422                hint.push_str(&notice);
423            }
424            // The global config the runtime reads doesn't exist → on defaults, so
425            // an `allow_paths` edit made to a config.toml elsewhere (XDG vs legacy
426            // dir, or a sandboxed/container HOME) is never seen (#540).
427            if let Some(missing) = crate::core::config::Config::missing_config_path() {
428                hint.push_str(&format!(
429                    ". ⚠ lean-ctx reads no config file at {} (running on defaults) — an \
430                     allow_paths edit in a config.toml elsewhere is not read; \
431                     `lean-ctx doctor` shows the path in effect",
432                    missing.display()
433                ));
434            }
435            return Err(format!("{base_msg}{hint}"));
436        }
437
438        #[cfg(windows)]
439        reject_symlink_on_windows(candidate)?;
440
441        let mut out = base;
442        for part in remainder.iter().rev() {
443            out.push(part);
444        }
445
446        // Re-validate after reconstruction: if the final path exists, canonicalize
447        // and re-check to close TOCTOU window (symlink created between check and use).
448        if out.exists() {
449            let final_canon = canonicalize_secure(&out);
450            let final_ok = is_under_prefix(&final_canon, &root)
451                || allow.iter().any(|p| is_under_prefix(&final_canon, p));
452            #[cfg(windows)]
453            let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
454            if !final_ok {
455                return Err(format!(
456                    "post-canonicalize jail escape detected: {} resolves to {}",
457                    candidate.display(),
458                    final_canon.display()
459                ));
460            }
461        }
462
463        Ok(out)
464    }
465}
466
467#[cfg(windows)]
468fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
469    let path_str = normalize_windows_path(&path.to_string_lossy());
470    let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
471    path_str.starts_with(&prefix_str)
472}
473
474#[cfg(windows)]
475fn normalize_windows_path(s: &str) -> String {
476    let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
477    stripped.to_lowercase().replace('/', "\\")
478}
479
480#[cfg(windows)]
481fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
482    if let Ok(meta) = std::fs::symlink_metadata(path) {
483        // Junctions and other reparse points redirect like symlinks but are
484        // invisible to `is_symlink()` — reject them too (GL#442).
485        if super::pathutil::is_symlink_or_reparse(&meta) {
486            return Err(format!(
487                "symlink not allowed in jailed path: {}",
488                path.display()
489            ));
490        }
491    }
492    Ok(())
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[cfg(not(feature = "no-jail"))]
500    #[test]
501    fn rejects_path_outside_root() {
502        // Hermetic config (empty data dir => jail on) so a parallel test that
503        // flips `path_jail` cannot leak into this enforcement check. Also hold the
504        // allow-path env lock: a parallel test setting `LEAN_CTX_ALLOW_PATH` (e.g.
505        // "/") would otherwise turn this escape into an accepted path.
506        let _iso = crate::core::data_dir::isolated_data_dir();
507        let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
508        let tmp = tempfile::tempdir().unwrap();
509        let root = tmp.path().join("root");
510        let other = tmp.path().join("other");
511        std::fs::create_dir_all(&root).unwrap();
512        std::fs::create_dir_all(&other).unwrap();
513        std::fs::write(root.join("a.txt"), "ok").unwrap();
514        std::fs::write(other.join("b.txt"), "no").unwrap();
515
516        let ok = jail_path(&root.join("a.txt"), &root);
517        assert!(ok.is_ok());
518
519        let bad = jail_path(&other.join("b.txt"), &root);
520        assert!(bad.is_err());
521    }
522
523    /// #475: a configured read-only root is readable but never writable. Reads
524    /// resolve (the root joins the allow-list like an extra_root), while the
525    /// single write choke point `enforce_writable` default-denies every write
526    /// inside it — including a not-yet-existing file, which inherits the
527    /// directory's read-only status. `isolated_data_dir` holds `test_env_lock`,
528    /// serialising the `LEAN_CTX_READ_ONLY_ROOTS` mutation against other tests.
529    #[cfg(not(feature = "no-jail"))]
530    #[test]
531    fn read_only_roots_deny_writes_but_allow_reads() {
532        let _iso = crate::core::data_dir::isolated_data_dir();
533
534        let tmp = tempfile::tempdir().unwrap();
535        let project = tmp.path().join("project");
536        let refrepo = tmp.path().join("refrepo");
537        std::fs::create_dir_all(&project).unwrap();
538        std::fs::create_dir_all(refrepo.join("sub")).unwrap();
539        std::fs::write(refrepo.join("lib.rs"), "pub fn x() {}\n").unwrap();
540
541        // Canonicalize the configured root the same (symlink-resolving) way the
542        // guard does, so macOS /var → /private/var can't defeat the prefix match.
543        let ro_canon = canonicalize_secure(&refrepo);
544        crate::test_env::set_var(
545            "LEAN_CTX_READ_ONLY_ROOTS",
546            ro_canon.to_string_lossy().as_ref(),
547        );
548
549        let existing = refrepo.join("lib.rs");
550        let new_file = refrepo.join("sub").join("new.rs");
551        let proj_file = project.join("main.rs");
552
553        // Capture every decision while the env is live (it is cleared below).
554        let read_existing = jail_path(&existing, &project);
555        let deny_existing = enforce_writable(&existing);
556        let deny_new = enforce_writable(&new_file);
557        let allow_project = enforce_writable(&proj_file);
558        let ro_existing = is_read_only_path(&existing);
559        let ro_project = is_read_only_path(&proj_file);
560
561        crate::test_env::remove_var("LEAN_CTX_READ_ONLY_ROOTS");
562
563        assert!(
564            deny_existing.is_err(),
565            "write to an existing file in a read-only root must be denied"
566        );
567        assert!(
568            deny_new.is_err(),
569            "creating a new file in a read-only root must be denied"
570        );
571        assert!(
572            allow_project.is_ok(),
573            "writes into the project root must stay allowed: {allow_project:?}"
574        );
575        assert!(
576            read_existing.is_ok(),
577            "reads inside a read-only root must resolve (read allow-list): {read_existing:?}"
578        );
579        assert!(ro_existing, "the file is inside the read-only root");
580        assert!(!ro_project, "the project file is not read-only");
581    }
582
583    /// #406 regression: a long-lived process (the MCP server) must honor
584    /// `path_jail = false` written to config after startup. The config cache is
585    /// now keyed on content, so even an edit that preserves the file mtime takes
586    /// effect — a path outside the jail root is accepted once the flag flips.
587    /// (With the former mtime-only cache the stale `None` kept the jail on.)
588    #[cfg(not(feature = "no-jail"))]
589    #[test]
590    fn honors_path_jail_false_after_mtime_preserving_edit() {
591        let _iso = crate::core::data_dir::isolated_data_dir();
592        let cfg_path = crate::core::config::Config::path().unwrap();
593        if let Some(parent) = cfg_path.parent() {
594            std::fs::create_dir_all(parent).unwrap();
595        }
596
597        let tmp = tempfile::tempdir().unwrap();
598        let root = tmp.path().join("project");
599        let outside = tmp.path().join("outside");
600        std::fs::create_dir_all(&root).unwrap();
601        std::fs::create_dir_all(&outside).unwrap();
602        let secret = outside.join("secret.txt");
603        std::fs::write(&secret, "x").unwrap();
604
605        // Warm the config cache with the jail on (no path_jail key).
606        std::fs::write(&cfg_path, "# jail on\n").unwrap();
607        let mtime0 = std::fs::metadata(&cfg_path).unwrap().modified().unwrap();
608        assert_eq!(crate::core::config::Config::load().path_jail, None);
609
610        // Flip path_jail=false but restore the original mtime, so any mtime-only
611        // cache would keep serving the stale jail-on value.
612        std::fs::write(&cfg_path, "path_jail = false\n").unwrap();
613        filetime::set_file_mtime(&cfg_path, filetime::FileTime::from_system_time(mtime0)).unwrap();
614
615        assert!(
616            jail_path(&secret, &root).is_ok(),
617            "path_jail=false must take effect without a fresh process (#406)"
618        );
619    }
620
621    #[test]
622    fn allows_nonexistent_child_under_root() {
623        let tmp = tempfile::tempdir().unwrap();
624        let root = tmp.path().join("root");
625        std::fs::create_dir_all(&root).unwrap();
626        std::fs::write(root.join("a.txt"), "ok").unwrap();
627
628        let p = root.join("new").join("file.txt");
629        let ok = jail_path(&p, &root).unwrap();
630        assert!(ok.to_string_lossy().contains("file.txt"));
631    }
632
633    #[cfg(not(feature = "no-jail"))]
634    #[test]
635    fn relative_candidate_resolves_against_root_not_cwd() {
636        // Regression: in the daemon (CWD != project) a relative graph path like
637        // `sub/file.rs` must resolve under the jail root, not the process CWD.
638        let _iso = crate::core::data_dir::isolated_data_dir();
639        let tmp = tempfile::tempdir().unwrap();
640        let root = tmp.path().join("project");
641        std::fs::create_dir_all(root.join("sub")).unwrap();
642        std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
643
644        let jailed = jail_path(Path::new("sub/file.rs"), &root)
645            .expect("relative candidate should resolve under the jail root");
646        assert!(jailed.ends_with("sub/file.rs"));
647        assert!(
648            is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
649            "resolved path must live under the jail root: {jailed:?}"
650        );
651    }
652
653    #[test]
654    fn ide_config_dirs_list_is_not_empty() {
655        assert!(IDE_CONFIG_DIRS.len() >= 10);
656        assert!(IDE_CONFIG_DIRS.contains(&".codex"));
657        assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
658        assert!(IDE_CONFIG_DIRS.contains(&".claude"));
659        assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
660    }
661
662    // P0-10 (#422): home-level IDE config dirs are opt-in; only ~/.lean-ctx
663    // is allowed unconditionally.
664    #[test]
665    fn ide_config_dirs_are_excluded_by_default() {
666        let home = tempfile::tempdir().unwrap();
667        for d in [".lean-ctx", ".cursor", ".claude", ".codex"] {
668            std::fs::create_dir_all(home.path().join(d)).unwrap();
669        }
670
671        let denied = home_allow_dirs(home.path(), false);
672        assert_eq!(
673            denied.len(),
674            1,
675            "only ~/.lean-ctx may be allowed: {denied:?}"
676        );
677        assert!(denied[0].ends_with(".lean-ctx"));
678
679        let allowed = home_allow_dirs(home.path(), true);
680        assert_eq!(allowed.len(), 4, "opt-in must allow all existing IDE dirs");
681    }
682
683    #[test]
684    fn canonicalize_or_self_strips_verbatim() {
685        let tmp = tempfile::tempdir().unwrap();
686        let dir = tmp.path().join("project");
687        std::fs::create_dir_all(&dir).unwrap();
688
689        let result = canonicalize_or_self(&dir);
690        let s = result.to_string_lossy();
691        assert!(
692            !s.starts_with(r"\\?\"),
693            "canonicalize_or_self should strip verbatim prefix, got: {s}"
694        );
695    }
696
697    #[test]
698    fn jail_path_accepts_same_dir_different_format() {
699        let tmp = tempfile::tempdir().unwrap();
700        let root = tmp.path().join("project");
701        std::fs::create_dir_all(&root).unwrap();
702        std::fs::write(root.join("file.rs"), "ok").unwrap();
703
704        let result = jail_path(&root.join("file.rs"), &root);
705        assert!(result.is_ok(), "same dir should be accepted: {result:?}");
706    }
707
708    #[cfg(not(feature = "no-jail"))]
709    #[test]
710    fn error_message_contains_escape_info() {
711        // Hold the allow-path env lock: a parallel test setting
712        // `LEAN_CTX_ALLOW_PATH="/"` would otherwise make this escape resolve to Ok.
713        let _iso = crate::core::data_dir::isolated_data_dir();
714        let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
715        let tmp = tempfile::tempdir().unwrap();
716        let root = tmp.path().join("root");
717        let other = tmp.path().join("other");
718        std::fs::create_dir_all(&root).unwrap();
719        std::fs::create_dir_all(&other).unwrap();
720        std::fs::write(other.join("b.txt"), "no").unwrap();
721
722        let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
723        assert!(
724            err.contains("path escapes project root"),
725            "error should mention escape: {err}"
726        );
727    }
728
729    // GH #392: config entries like "$HOME/code" or "~/code" were taken
730    // literally and never matched.
731    #[test]
732    fn expand_user_path_expands_tilde_and_vars() {
733        let home = dirs::home_dir().expect("home dir");
734        let home_s = home.to_string_lossy().to_string();
735
736        assert_eq!(expand_user_path("~"), home);
737        assert_eq!(expand_user_path("~/code"), home.join("code"));
738        assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
739        assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
740        // Multiple variables in one entry.
741        crate::test_env::set_var("LEAN_CTX_TEST_SUB", "sub");
742        assert_eq!(
743            expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
744            PathBuf::from(format!("{home_s}/sub/x"))
745        );
746        crate::test_env::remove_var("LEAN_CTX_TEST_SUB");
747        // Absolute paths pass through untouched.
748        assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
749    }
750
751    #[test]
752    fn expand_user_path_leaves_unset_vars_verbatim() {
753        crate::test_env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
754        let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
755        assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
756    }
757
758    /// Serializes tests that mutate `LEAN_CTX_ALLOW_PATH` — cargo runs tests in
759    /// parallel threads and `set_var`/`remove_var` are process-global.
760    static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
761
762    // GH #392: `allow_paths = ["/"]` (via the same env-var channel) must grant
763    // access to any absolute path — "/" is a prefix of everything.
764    #[cfg(unix)]
765    #[test]
766    fn allow_path_root_slash_permits_everything() {
767        let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
768        let tmp = tempfile::tempdir().unwrap();
769        let root = tmp.path().join("root");
770        let other = tmp.path().join("other");
771        std::fs::create_dir_all(&root).unwrap();
772        std::fs::create_dir_all(&other).unwrap();
773        std::fs::write(other.join("b.txt"), "allowed").unwrap();
774
775        crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/");
776        let result = jail_path(&other.join("b.txt"), &root);
777        crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
778
779        assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
780    }
781
782    // Finding 3 (GH security audit): env-channel jail relaxations must be
783    // detectable so startup + doctor can surface them loudly.
784    #[test]
785    fn active_relaxations_detects_allow_path_env() {
786        let _iso = crate::core::data_dir::isolated_data_dir();
787        let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
788        crate::test_env::remove_var("LEAN_CTX_EXTRA_ROOTS");
789        crate::test_env::remove_var("LEAN_CTX_ALLOW_IDE_DIRS");
790        crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", "/tmp");
791
792        let relaxed = active_relaxations();
793
794        crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
795
796        assert!(
797            relaxed.iter().any(|r| r.source == "LEAN_CTX_ALLOW_PATH"),
798            "LEAN_CTX_ALLOW_PATH must be reported as a jail relaxation: {relaxed:?}"
799        );
800    }
801
802    #[cfg(not(feature = "no-jail"))]
803    #[test]
804    fn active_relaxations_empty_when_jail_intact() {
805        let _iso = crate::core::data_dir::isolated_data_dir();
806        let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
807        for var in [
808            "LEAN_CTX_ALLOW_PATH",
809            "LCTX_ALLOW_PATH",
810            "LEAN_CTX_EXTRA_ROOTS",
811            "LEAN_CTX_ALLOW_IDE_DIRS",
812        ] {
813            crate::test_env::remove_var(var);
814        }
815
816        assert!(
817            active_relaxations().is_empty(),
818            "an intact jail (clean config, no relaxation env) must report no relaxations: {:?}",
819            active_relaxations()
820        );
821    }
822
823    #[test]
824    fn allow_path_env_permits_outside_root() {
825        let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
826        let tmp = tempfile::tempdir().unwrap();
827        let root = tmp.path().join("root");
828        let other = tmp.path().join("other");
829        std::fs::create_dir_all(&root).unwrap();
830        std::fs::create_dir_all(&other).unwrap();
831        std::fs::write(other.join("b.txt"), "allowed").unwrap();
832
833        let canon = canonicalize_or_self(&other);
834        crate::test_env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
835        let result = jail_path(&other.join("b.txt"), &root);
836        crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
837
838        assert!(
839            result.is_ok(),
840            "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
841        );
842    }
843
844    #[cfg(all(unix, not(feature = "no-jail")))]
845    #[test]
846    fn rejects_symlink_escape_on_unix() {
847        use std::os::unix::fs::symlink;
848
849        // Hold the allow-path env lock: a parallel test setting
850        // `LEAN_CTX_ALLOW_PATH="/"` would otherwise let the symlink escape resolve.
851        let _iso = crate::core::data_dir::isolated_data_dir();
852        let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
853        let tmp = tempfile::tempdir().unwrap();
854        let root = tmp.path().join("root");
855        let other = tmp.path().join("other");
856        std::fs::create_dir_all(&root).unwrap();
857        std::fs::create_dir_all(&other).unwrap();
858        std::fs::write(other.join("secret.txt"), "no").unwrap();
859
860        let link = root.join("link.txt");
861        symlink(other.join("secret.txt"), &link).unwrap();
862
863        let bad = jail_path(&link, &root);
864        assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
865    }
866
867    #[test]
868    fn rejects_null_byte_in_path() {
869        let tmp = tempfile::tempdir().unwrap();
870        let root = tmp.path().join("root");
871        std::fs::create_dir_all(&root).unwrap();
872
873        let bad_path = PathBuf::from("file\0.txt");
874        let result = jail_path(&bad_path, &root);
875        assert!(result.is_err(), "null byte in path must be rejected");
876        assert!(
877            result.unwrap_err().contains("null byte"),
878            "error must mention null byte"
879        );
880    }
881
882    /// #403 Bug 1: an explicit path under a session-scoped `extra_root` (e.g. a
883    /// sibling git worktree from MCP `roots/list`) must resolve, while the same
884    /// path is rejected without it — and a path under *no* root is rejected even
885    /// when extra roots are present. Holds both env locks so neither a parallel
886    /// `path_jail` flip nor a `LEAN_CTX_ALLOW_PATH` mutation can leak in.
887    #[cfg(not(feature = "no-jail"))]
888    #[test]
889    fn extra_roots_permit_paths_outside_jail() {
890        let _iso = crate::core::data_dir::isolated_data_dir();
891        let _alp = ALLOW_PATH_ENV_LOCK.lock().unwrap();
892
893        let tmp = tempfile::tempdir().unwrap();
894        let root = tmp.path().join("project");
895        let worktree = tmp.path().join("worktree");
896        let elsewhere = tmp.path().join("elsewhere");
897        for d in [&root, &worktree, &elsewhere] {
898            std::fs::create_dir_all(d).unwrap();
899        }
900        let in_worktree = worktree.join("a.txt");
901        std::fs::write(&in_worktree, "x").unwrap();
902        let outside = elsewhere.join("b.txt");
903        std::fs::write(&outside, "y").unwrap();
904
905        // Parity: with no extra roots, the worktree path escapes the jail.
906        assert!(jail_path(&in_worktree, &root).is_err());
907        assert!(jail_path_with_roots(&in_worktree, &root, &[]).is_err());
908
909        // The session-scoped extra root permits it — via the slice alone, with
910        // nothing in env/config.
911        let extra = vec![worktree.to_string_lossy().to_string()];
912        assert!(
913            jail_path_with_roots(&in_worktree, &root, &extra).is_ok(),
914            "path under a session extra_root must resolve (#403)"
915        );
916
917        // A path under neither the jail nor any extra root is still rejected.
918        assert!(
919            jail_path_with_roots(&outside, &root, &extra).is_err(),
920            "paths outside ALL roots must still be rejected"
921        );
922
923        // Empty entries are ignored (no accidental allow-all).
924        assert!(jail_path_with_roots(&outside, &root, &[String::new()]).is_err());
925    }
926}