Skip to main content

lean_ctx/core/
pathjail.rs

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