Skip to main content

lean_ctx/core/
pathjail.rs

1use std::path::{Path, PathBuf};
2
3const IDE_CONFIG_DIRS: &[&str] = &[
4    ".lean-ctx",
5    ".cursor",
6    ".claude",
7    ".codex",
8    ".codeium",
9    ".gemini",
10    ".qwen",
11    ".trae",
12    ".kiro",
13    ".verdent",
14    ".pi",
15    ".amp",
16    ".aider",
17    ".continue",
18];
19
20/// Expands `~`, `$VAR` and `${VAR}` in a config-supplied path entry.
21///
22/// `allow_paths` / `extra_roots` come from `config.toml`, where no shell ever
23/// runs — users writing `"$HOME/code"` or `"~/code"` got a literal,
24/// never-matching prefix and concluded the whole option was broken (GH #392).
25/// Unset variables are left verbatim (and warned about) so the entry fails
26/// loudly in `lean-ctx doctor` instead of silently matching something else.
27pub fn expand_user_path(raw: &str) -> PathBuf {
28    let mut s = raw.to_string();
29
30    if s == "~" || s.starts_with("~/") {
31        if let Some(home) = dirs::home_dir() {
32            s = format!("{}{}", home.to_string_lossy(), &s[1..]);
33        }
34    }
35
36    while let Some(start) = s.find('$') {
37        let rest = &s[start + 1..];
38        let (name, token_len) = if let Some(stripped) = rest.strip_prefix('{') {
39            match stripped.find('}') {
40                Some(end) => (stripped[..end].to_string(), end + 3),
41                None => break,
42            }
43        } else {
44            let end = rest
45                .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
46                .unwrap_or(rest.len());
47            (rest[..end].to_string(), end + 1)
48        };
49        if name.is_empty() {
50            break;
51        }
52        if let Ok(val) = std::env::var(&name) {
53            s.replace_range(start..start + token_len, &val);
54        } else {
55            tracing::warn!(
56                "allow_paths/extra_roots entry '{raw}' references unset variable ${name} — entry will never match"
57            );
58            break;
59        }
60    }
61
62    PathBuf::from(s)
63}
64
65pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
66    let mut out = Vec::new();
67    let cfg = crate::core::config::Config::load();
68
69    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
70        out.push(canonicalize_or_self(&data_dir));
71    }
72
73    if let Some(home) = dirs::home_dir() {
74        let ide_dirs_allowed = cfg.allow_ide_config_dirs
75            || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
76        out.extend(home_allow_dirs(&home, ide_dirs_allowed));
77    }
78
79    for p in &cfg.allow_paths {
80        out.push(canonicalize_or_self(&expand_user_path(p)));
81    }
82    for p in &cfg.extra_roots {
83        out.push(canonicalize_or_self(&expand_user_path(p)));
84    }
85
86    // Env entries are expanded too: MCP host configs pass env blocks verbatim
87    // (no shell), so "$HOME/code" arrives literally there as well.
88    let v = std::env::var("LCTX_ALLOW_PATH")
89        .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
90        .unwrap_or_default();
91    if !v.trim().is_empty() {
92        for p in std::env::split_paths(&v) {
93            out.push(canonicalize_or_self(&expand_user_path(
94                &p.to_string_lossy(),
95            )));
96        }
97    }
98
99    let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
100    if !extra.trim().is_empty() {
101        for p in std::env::split_paths(&extra) {
102            out.push(canonicalize_or_self(&expand_user_path(
103                &p.to_string_lossy(),
104            )));
105        }
106    }
107
108    out
109}
110
111/// Home-level allow-dirs for the jail. `~/.lean-ctx` (own state) is always
112/// allowed; the *other* IDE config dirs (~/.cursor, ~/.claude, …) expose
113/// foreign projects' sessions, MCP configs and credentials to any agent, so
114/// they are opt-in only (config `allow_ide_config_dirs = true` or
115/// `LEAN_CTX_ALLOW_IDE_DIRS=1`).
116fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
117    let mut out = Vec::new();
118    for dir in IDE_CONFIG_DIRS {
119        if *dir != ".lean-ctx" && !ide_dirs_allowed {
120            continue;
121        }
122        let p = home.join(dir);
123        if p.exists() {
124            out.push(canonicalize_or_self(&p));
125        }
126    }
127    out
128}
129
130fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
131    path.starts_with(prefix)
132}
133
134pub fn canonicalize_or_self(path: &Path) -> PathBuf {
135    super::pathutil::safe_canonicalize_bounded(path, 2000)
136}
137
138fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
139    let mut cur = path.to_path_buf();
140    let mut remainder: Vec<std::ffi::OsString> = Vec::new();
141    loop {
142        if cur.exists() {
143            return Some((canonicalize_or_self(&cur), remainder));
144        }
145        let name = cur.file_name()?.to_os_string();
146        remainder.push(name);
147        if !cur.pop() {
148            return None;
149        }
150    }
151}
152
153pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
154    if candidate.to_string_lossy().as_bytes().contains(&0) {
155        return Err("path contains null byte".to_string());
156    }
157
158    #[cfg(feature = "no-jail")]
159    {
160        let _ = jail_root;
161        return Ok(canonicalize_or_self(candidate));
162    }
163
164    #[allow(unreachable_code)]
165    {
166        let cfg = crate::core::config::Config::load();
167        if cfg.path_jail == Some(false) {
168            return Ok(canonicalize_or_self(candidate));
169        }
170
171        let root = canonicalize_or_self(jail_root);
172
173        // Resolve relative candidates against the (absolute) jail root — never the process
174        // CWD. The daemon's CWD is not the project, so CWD-relative resolution made
175        // graph-relative paths (e.g. auto-preload candidates like `rust/src/core/foo.rs`)
176        // spuriously fail with "no existing ancestor". Absolute candidates are unchanged.
177        let resolved: PathBuf;
178        let candidate: &Path = if candidate.is_absolute() {
179            candidate
180        } else {
181            resolved = root.join(candidate);
182            resolved.as_path()
183        };
184
185        let allow = allow_paths_from_env_and_config();
186
187        let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
188            format!(
189                "path does not exist and has no existing ancestor: {}",
190                candidate.display()
191            )
192        })?;
193
194        let allowed =
195            is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
196
197        #[cfg(windows)]
198        let allowed = allowed || is_under_prefix_windows(&base, &root);
199
200        if !allowed {
201            let base_msg = format!(
202                "path escapes project root: {} (root: {})",
203                candidate.display(),
204                root.display(),
205            );
206            let hint = if crate::core::protocol::meta_visible() {
207                format!(
208                ". Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
209                candidate.parent().unwrap_or(candidate).display()
210            )
211            } else {
212                String::new()
213            };
214            return Err(format!("{base_msg}{hint}"));
215        }
216
217        #[cfg(windows)]
218        reject_symlink_on_windows(candidate)?;
219
220        let mut out = base;
221        for part in remainder.iter().rev() {
222            out.push(part);
223        }
224
225        // Re-validate after reconstruction: if the final path exists, canonicalize
226        // and re-check to close TOCTOU window (symlink created between check and use).
227        if out.exists() {
228            let final_canon = canonicalize_or_self(&out);
229            let final_ok = is_under_prefix(&final_canon, &root)
230                || allow.iter().any(|p| is_under_prefix(&final_canon, p));
231            #[cfg(windows)]
232            let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
233            if !final_ok {
234                return Err(format!(
235                    "post-canonicalize jail escape detected: {} resolves to {}",
236                    candidate.display(),
237                    final_canon.display()
238                ));
239            }
240        }
241
242        Ok(out)
243    }
244}
245
246#[cfg(windows)]
247fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
248    let path_str = normalize_windows_path(&path.to_string_lossy());
249    let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
250    path_str.starts_with(&prefix_str)
251}
252
253#[cfg(windows)]
254fn normalize_windows_path(s: &str) -> String {
255    let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
256    stripped.to_lowercase().replace('/', "\\")
257}
258
259#[cfg(windows)]
260fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
261    if let Ok(meta) = std::fs::symlink_metadata(path) {
262        // Junctions and other reparse points redirect like symlinks but are
263        // invisible to `is_symlink()` — reject them too (GL#442).
264        if super::pathutil::is_symlink_or_reparse(&meta) {
265            return Err(format!(
266                "symlink not allowed in jailed path: {}",
267                path.display()
268            ));
269        }
270    }
271    Ok(())
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    #[cfg(not(feature = "no-jail"))]
279    #[test]
280    fn rejects_path_outside_root() {
281        let tmp = tempfile::tempdir().unwrap();
282        let root = tmp.path().join("root");
283        let other = tmp.path().join("other");
284        std::fs::create_dir_all(&root).unwrap();
285        std::fs::create_dir_all(&other).unwrap();
286        std::fs::write(root.join("a.txt"), "ok").unwrap();
287        std::fs::write(other.join("b.txt"), "no").unwrap();
288
289        let ok = jail_path(&root.join("a.txt"), &root);
290        assert!(ok.is_ok());
291
292        let bad = jail_path(&other.join("b.txt"), &root);
293        assert!(bad.is_err());
294    }
295
296    #[test]
297    fn allows_nonexistent_child_under_root() {
298        let tmp = tempfile::tempdir().unwrap();
299        let root = tmp.path().join("root");
300        std::fs::create_dir_all(&root).unwrap();
301        std::fs::write(root.join("a.txt"), "ok").unwrap();
302
303        let p = root.join("new").join("file.txt");
304        let ok = jail_path(&p, &root).unwrap();
305        assert!(ok.to_string_lossy().contains("file.txt"));
306    }
307
308    #[cfg(not(feature = "no-jail"))]
309    #[test]
310    fn relative_candidate_resolves_against_root_not_cwd() {
311        // Regression: in the daemon (CWD != project) a relative graph path like
312        // `sub/file.rs` must resolve under the jail root, not the process CWD.
313        let tmp = tempfile::tempdir().unwrap();
314        let root = tmp.path().join("project");
315        std::fs::create_dir_all(root.join("sub")).unwrap();
316        std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
317
318        let jailed = jail_path(Path::new("sub/file.rs"), &root)
319            .expect("relative candidate should resolve under the jail root");
320        assert!(jailed.ends_with("sub/file.rs"));
321        assert!(
322            is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
323            "resolved path must live under the jail root: {jailed:?}"
324        );
325    }
326
327    #[test]
328    fn ide_config_dirs_list_is_not_empty() {
329        assert!(IDE_CONFIG_DIRS.len() >= 10);
330        assert!(IDE_CONFIG_DIRS.contains(&".codex"));
331        assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
332        assert!(IDE_CONFIG_DIRS.contains(&".claude"));
333        assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
334    }
335
336    // P0-10 (#422): home-level IDE config dirs are opt-in; only ~/.lean-ctx
337    // is allowed unconditionally.
338    #[test]
339    fn ide_config_dirs_are_excluded_by_default() {
340        let home = tempfile::tempdir().unwrap();
341        for d in [".lean-ctx", ".cursor", ".claude", ".codex"] {
342            std::fs::create_dir_all(home.path().join(d)).unwrap();
343        }
344
345        let denied = home_allow_dirs(home.path(), false);
346        assert_eq!(
347            denied.len(),
348            1,
349            "only ~/.lean-ctx may be allowed: {denied:?}"
350        );
351        assert!(denied[0].ends_with(".lean-ctx"));
352
353        let allowed = home_allow_dirs(home.path(), true);
354        assert_eq!(allowed.len(), 4, "opt-in must allow all existing IDE dirs");
355    }
356
357    #[test]
358    fn canonicalize_or_self_strips_verbatim() {
359        let tmp = tempfile::tempdir().unwrap();
360        let dir = tmp.path().join("project");
361        std::fs::create_dir_all(&dir).unwrap();
362
363        let result = canonicalize_or_self(&dir);
364        let s = result.to_string_lossy();
365        assert!(
366            !s.starts_with(r"\\?\"),
367            "canonicalize_or_self should strip verbatim prefix, got: {s}"
368        );
369    }
370
371    #[test]
372    fn jail_path_accepts_same_dir_different_format() {
373        let tmp = tempfile::tempdir().unwrap();
374        let root = tmp.path().join("project");
375        std::fs::create_dir_all(&root).unwrap();
376        std::fs::write(root.join("file.rs"), "ok").unwrap();
377
378        let result = jail_path(&root.join("file.rs"), &root);
379        assert!(result.is_ok(), "same dir should be accepted: {result:?}");
380    }
381
382    #[cfg(not(feature = "no-jail"))]
383    #[test]
384    fn error_message_contains_escape_info() {
385        let tmp = tempfile::tempdir().unwrap();
386        let root = tmp.path().join("root");
387        let other = tmp.path().join("other");
388        std::fs::create_dir_all(&root).unwrap();
389        std::fs::create_dir_all(&other).unwrap();
390        std::fs::write(other.join("b.txt"), "no").unwrap();
391
392        let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
393        assert!(
394            err.contains("path escapes project root"),
395            "error should mention escape: {err}"
396        );
397    }
398
399    // GH #392: config entries like "$HOME/code" or "~/code" were taken
400    // literally and never matched.
401    #[test]
402    fn expand_user_path_expands_tilde_and_vars() {
403        let home = dirs::home_dir().expect("home dir");
404        let home_s = home.to_string_lossy().to_string();
405
406        assert_eq!(expand_user_path("~"), home);
407        assert_eq!(expand_user_path("~/code"), home.join("code"));
408        assert_eq!(expand_user_path("$HOME/code"), home.join("code"));
409        assert_eq!(expand_user_path("${HOME}/code"), home.join("code"));
410        // Multiple variables in one entry.
411        std::env::set_var("LEAN_CTX_TEST_SUB", "sub");
412        assert_eq!(
413            expand_user_path("$HOME/$LEAN_CTX_TEST_SUB/x"),
414            PathBuf::from(format!("{home_s}/sub/x"))
415        );
416        std::env::remove_var("LEAN_CTX_TEST_SUB");
417        // Absolute paths pass through untouched.
418        assert_eq!(expand_user_path("/etc"), PathBuf::from("/etc"));
419    }
420
421    #[test]
422    fn expand_user_path_leaves_unset_vars_verbatim() {
423        std::env::remove_var("LEAN_CTX_TEST_UNSET_VAR");
424        let p = expand_user_path("$LEAN_CTX_TEST_UNSET_VAR/code");
425        assert_eq!(p, PathBuf::from("$LEAN_CTX_TEST_UNSET_VAR/code"));
426    }
427
428    /// Serializes tests that mutate `LEAN_CTX_ALLOW_PATH` — cargo runs tests in
429    /// parallel threads and `set_var`/`remove_var` are process-global.
430    static ALLOW_PATH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
431
432    // GH #392: `allow_paths = ["/"]` (via the same env-var channel) must grant
433    // access to any absolute path — "/" is a prefix of everything.
434    #[cfg(unix)]
435    #[test]
436    fn allow_path_root_slash_permits_everything() {
437        let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
438        let tmp = tempfile::tempdir().unwrap();
439        let root = tmp.path().join("root");
440        let other = tmp.path().join("other");
441        std::fs::create_dir_all(&root).unwrap();
442        std::fs::create_dir_all(&other).unwrap();
443        std::fs::write(other.join("b.txt"), "allowed").unwrap();
444
445        std::env::set_var("LEAN_CTX_ALLOW_PATH", "/");
446        let result = jail_path(&other.join("b.txt"), &root);
447        std::env::remove_var("LEAN_CTX_ALLOW_PATH");
448
449        assert!(result.is_ok(), "allow path '/' must permit all: {result:?}");
450    }
451
452    #[test]
453    fn allow_path_env_permits_outside_root() {
454        let _guard = ALLOW_PATH_ENV_LOCK.lock().unwrap();
455        let tmp = tempfile::tempdir().unwrap();
456        let root = tmp.path().join("root");
457        let other = tmp.path().join("other");
458        std::fs::create_dir_all(&root).unwrap();
459        std::fs::create_dir_all(&other).unwrap();
460        std::fs::write(other.join("b.txt"), "allowed").unwrap();
461
462        let canon = canonicalize_or_self(&other);
463        std::env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
464        let result = jail_path(&other.join("b.txt"), &root);
465        std::env::remove_var("LEAN_CTX_ALLOW_PATH");
466
467        assert!(
468            result.is_ok(),
469            "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
470        );
471    }
472
473    #[cfg(all(unix, not(feature = "no-jail")))]
474    #[test]
475    fn rejects_symlink_escape_on_unix() {
476        use std::os::unix::fs::symlink;
477
478        let tmp = tempfile::tempdir().unwrap();
479        let root = tmp.path().join("root");
480        let other = tmp.path().join("other");
481        std::fs::create_dir_all(&root).unwrap();
482        std::fs::create_dir_all(&other).unwrap();
483        std::fs::write(other.join("secret.txt"), "no").unwrap();
484
485        let link = root.join("link.txt");
486        symlink(other.join("secret.txt"), &link).unwrap();
487
488        let bad = jail_path(&link, &root);
489        assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
490    }
491
492    #[test]
493    fn rejects_null_byte_in_path() {
494        let tmp = tempfile::tempdir().unwrap();
495        let root = tmp.path().join("root");
496        std::fs::create_dir_all(&root).unwrap();
497
498        let bad_path = PathBuf::from("file\0.txt");
499        let result = jail_path(&bad_path, &root);
500        assert!(result.is_err(), "null byte in path must be rejected");
501        assert!(
502            result.unwrap_err().contains("null byte"),
503            "error must mention null byte"
504        );
505    }
506}