Skip to main content

lean_ctx/core/
pathjail.rs

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