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
20pub fn allow_paths_from_env_and_config() -> Vec<PathBuf> {
21    let mut out = Vec::new();
22    let cfg = crate::core::config::Config::load();
23
24    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
25        out.push(canonicalize_or_self(&data_dir));
26    }
27
28    if let Some(home) = dirs::home_dir() {
29        let ide_dirs_allowed = cfg.allow_ide_config_dirs
30            || std::env::var("LEAN_CTX_ALLOW_IDE_DIRS").is_ok_and(|v| v == "1");
31        out.extend(home_allow_dirs(&home, ide_dirs_allowed));
32    }
33
34    for p in &cfg.allow_paths {
35        let pb = PathBuf::from(p);
36        out.push(canonicalize_or_self(&pb));
37    }
38    for p in &cfg.extra_roots {
39        let pb = PathBuf::from(p);
40        out.push(canonicalize_or_self(&pb));
41    }
42
43    let v = std::env::var("LCTX_ALLOW_PATH")
44        .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
45        .unwrap_or_default();
46    if !v.trim().is_empty() {
47        for p in std::env::split_paths(&v) {
48            out.push(canonicalize_or_self(&p));
49        }
50    }
51
52    let extra = std::env::var("LEAN_CTX_EXTRA_ROOTS").unwrap_or_default();
53    if !extra.trim().is_empty() {
54        for p in std::env::split_paths(&extra) {
55            out.push(canonicalize_or_self(&p));
56        }
57    }
58
59    out
60}
61
62/// Home-level allow-dirs for the jail. `~/.lean-ctx` (own state) is always
63/// allowed; the *other* IDE config dirs (~/.cursor, ~/.claude, …) expose
64/// foreign projects' sessions, MCP configs and credentials to any agent, so
65/// they are opt-in only (config `allow_ide_config_dirs = true` or
66/// `LEAN_CTX_ALLOW_IDE_DIRS=1`).
67fn home_allow_dirs(home: &Path, ide_dirs_allowed: bool) -> Vec<PathBuf> {
68    let mut out = Vec::new();
69    for dir in IDE_CONFIG_DIRS {
70        if *dir != ".lean-ctx" && !ide_dirs_allowed {
71            continue;
72        }
73        let p = home.join(dir);
74        if p.exists() {
75            out.push(canonicalize_or_self(&p));
76        }
77    }
78    out
79}
80
81fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
82    path.starts_with(prefix)
83}
84
85pub fn canonicalize_or_self(path: &Path) -> PathBuf {
86    super::pathutil::safe_canonicalize_bounded(path, 2000)
87}
88
89fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
90    let mut cur = path.to_path_buf();
91    let mut remainder: Vec<std::ffi::OsString> = Vec::new();
92    loop {
93        if cur.exists() {
94            return Some((canonicalize_or_self(&cur), remainder));
95        }
96        let name = cur.file_name()?.to_os_string();
97        remainder.push(name);
98        if !cur.pop() {
99            return None;
100        }
101    }
102}
103
104pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
105    if candidate.to_string_lossy().as_bytes().contains(&0) {
106        return Err("path contains null byte".to_string());
107    }
108
109    #[cfg(feature = "no-jail")]
110    {
111        let _ = jail_root;
112        return Ok(canonicalize_or_self(candidate));
113    }
114
115    #[allow(unreachable_code)]
116    {
117        let cfg = crate::core::config::Config::load();
118        if cfg.path_jail == Some(false) {
119            return Ok(canonicalize_or_self(candidate));
120        }
121
122        let root = canonicalize_or_self(jail_root);
123
124        // Resolve relative candidates against the (absolute) jail root — never the process
125        // CWD. The daemon's CWD is not the project, so CWD-relative resolution made
126        // graph-relative paths (e.g. auto-preload candidates like `rust/src/core/foo.rs`)
127        // spuriously fail with "no existing ancestor". Absolute candidates are unchanged.
128        let resolved: PathBuf;
129        let candidate: &Path = if candidate.is_absolute() {
130            candidate
131        } else {
132            resolved = root.join(candidate);
133            resolved.as_path()
134        };
135
136        let allow = allow_paths_from_env_and_config();
137
138        let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
139            format!(
140                "path does not exist and has no existing ancestor: {}",
141                candidate.display()
142            )
143        })?;
144
145        let allowed =
146            is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
147
148        #[cfg(windows)]
149        let allowed = allowed || is_under_prefix_windows(&base, &root);
150
151        if !allowed {
152            let base_msg = format!(
153                "path escapes project root: {} (root: {})",
154                candidate.display(),
155                root.display(),
156            );
157            let hint = if crate::core::protocol::meta_visible() {
158                format!(
159                ". Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
160                candidate.parent().unwrap_or(candidate).display()
161            )
162            } else {
163                String::new()
164            };
165            return Err(format!("{base_msg}{hint}"));
166        }
167
168        #[cfg(windows)]
169        reject_symlink_on_windows(candidate)?;
170
171        let mut out = base;
172        for part in remainder.iter().rev() {
173            out.push(part);
174        }
175
176        // Re-validate after reconstruction: if the final path exists, canonicalize
177        // and re-check to close TOCTOU window (symlink created between check and use).
178        if out.exists() {
179            let final_canon = canonicalize_or_self(&out);
180            let final_ok = is_under_prefix(&final_canon, &root)
181                || allow.iter().any(|p| is_under_prefix(&final_canon, p));
182            #[cfg(windows)]
183            let final_ok = final_ok || is_under_prefix_windows(&final_canon, &root);
184            if !final_ok {
185                return Err(format!(
186                    "post-canonicalize jail escape detected: {} resolves to {}",
187                    candidate.display(),
188                    final_canon.display()
189                ));
190            }
191        }
192
193        Ok(out)
194    }
195}
196
197#[cfg(windows)]
198fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
199    let path_str = normalize_windows_path(&path.to_string_lossy());
200    let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
201    path_str.starts_with(&prefix_str)
202}
203
204#[cfg(windows)]
205fn normalize_windows_path(s: &str) -> String {
206    let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
207    stripped.to_lowercase().replace('/', "\\")
208}
209
210#[cfg(windows)]
211fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
212    if let Ok(meta) = std::fs::symlink_metadata(path) {
213        // Junctions and other reparse points redirect like symlinks but are
214        // invisible to `is_symlink()` — reject them too (GL#442).
215        if super::pathutil::is_symlink_or_reparse(&meta) {
216            return Err(format!(
217                "symlink not allowed in jailed path: {}",
218                path.display()
219            ));
220        }
221    }
222    Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[cfg(not(feature = "no-jail"))]
230    #[test]
231    fn rejects_path_outside_root() {
232        let tmp = tempfile::tempdir().unwrap();
233        let root = tmp.path().join("root");
234        let other = tmp.path().join("other");
235        std::fs::create_dir_all(&root).unwrap();
236        std::fs::create_dir_all(&other).unwrap();
237        std::fs::write(root.join("a.txt"), "ok").unwrap();
238        std::fs::write(other.join("b.txt"), "no").unwrap();
239
240        let ok = jail_path(&root.join("a.txt"), &root);
241        assert!(ok.is_ok());
242
243        let bad = jail_path(&other.join("b.txt"), &root);
244        assert!(bad.is_err());
245    }
246
247    #[test]
248    fn allows_nonexistent_child_under_root() {
249        let tmp = tempfile::tempdir().unwrap();
250        let root = tmp.path().join("root");
251        std::fs::create_dir_all(&root).unwrap();
252        std::fs::write(root.join("a.txt"), "ok").unwrap();
253
254        let p = root.join("new").join("file.txt");
255        let ok = jail_path(&p, &root).unwrap();
256        assert!(ok.to_string_lossy().contains("file.txt"));
257    }
258
259    #[cfg(not(feature = "no-jail"))]
260    #[test]
261    fn relative_candidate_resolves_against_root_not_cwd() {
262        // Regression: in the daemon (CWD != project) a relative graph path like
263        // `sub/file.rs` must resolve under the jail root, not the process CWD.
264        let tmp = tempfile::tempdir().unwrap();
265        let root = tmp.path().join("project");
266        std::fs::create_dir_all(root.join("sub")).unwrap();
267        std::fs::write(root.join("sub").join("file.rs"), "ok").unwrap();
268
269        let jailed = jail_path(Path::new("sub/file.rs"), &root)
270            .expect("relative candidate should resolve under the jail root");
271        assert!(jailed.ends_with("sub/file.rs"));
272        assert!(
273            is_under_prefix(&canonicalize_or_self(&jailed), &canonicalize_or_self(&root)),
274            "resolved path must live under the jail root: {jailed:?}"
275        );
276    }
277
278    #[test]
279    fn ide_config_dirs_list_is_not_empty() {
280        assert!(IDE_CONFIG_DIRS.len() >= 10);
281        assert!(IDE_CONFIG_DIRS.contains(&".codex"));
282        assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
283        assert!(IDE_CONFIG_DIRS.contains(&".claude"));
284        assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
285    }
286
287    // P0-10 (#422): home-level IDE config dirs are opt-in; only ~/.lean-ctx
288    // is allowed unconditionally.
289    #[test]
290    fn ide_config_dirs_are_excluded_by_default() {
291        let home = tempfile::tempdir().unwrap();
292        for d in [".lean-ctx", ".cursor", ".claude", ".codex"] {
293            std::fs::create_dir_all(home.path().join(d)).unwrap();
294        }
295
296        let denied = home_allow_dirs(home.path(), false);
297        assert_eq!(
298            denied.len(),
299            1,
300            "only ~/.lean-ctx may be allowed: {denied:?}"
301        );
302        assert!(denied[0].ends_with(".lean-ctx"));
303
304        let allowed = home_allow_dirs(home.path(), true);
305        assert_eq!(allowed.len(), 4, "opt-in must allow all existing IDE dirs");
306    }
307
308    #[test]
309    fn canonicalize_or_self_strips_verbatim() {
310        let tmp = tempfile::tempdir().unwrap();
311        let dir = tmp.path().join("project");
312        std::fs::create_dir_all(&dir).unwrap();
313
314        let result = canonicalize_or_self(&dir);
315        let s = result.to_string_lossy();
316        assert!(
317            !s.starts_with(r"\\?\"),
318            "canonicalize_or_self should strip verbatim prefix, got: {s}"
319        );
320    }
321
322    #[test]
323    fn jail_path_accepts_same_dir_different_format() {
324        let tmp = tempfile::tempdir().unwrap();
325        let root = tmp.path().join("project");
326        std::fs::create_dir_all(&root).unwrap();
327        std::fs::write(root.join("file.rs"), "ok").unwrap();
328
329        let result = jail_path(&root.join("file.rs"), &root);
330        assert!(result.is_ok(), "same dir should be accepted: {result:?}");
331    }
332
333    #[cfg(not(feature = "no-jail"))]
334    #[test]
335    fn error_message_contains_escape_info() {
336        let tmp = tempfile::tempdir().unwrap();
337        let root = tmp.path().join("root");
338        let other = tmp.path().join("other");
339        std::fs::create_dir_all(&root).unwrap();
340        std::fs::create_dir_all(&other).unwrap();
341        std::fs::write(other.join("b.txt"), "no").unwrap();
342
343        let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
344        assert!(
345            err.contains("path escapes project root"),
346            "error should mention escape: {err}"
347        );
348    }
349
350    #[test]
351    fn allow_path_env_permits_outside_root() {
352        let tmp = tempfile::tempdir().unwrap();
353        let root = tmp.path().join("root");
354        let other = tmp.path().join("other");
355        std::fs::create_dir_all(&root).unwrap();
356        std::fs::create_dir_all(&other).unwrap();
357        std::fs::write(other.join("b.txt"), "allowed").unwrap();
358
359        let canon = canonicalize_or_self(&other);
360        std::env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
361        let result = jail_path(&other.join("b.txt"), &root);
362        std::env::remove_var("LEAN_CTX_ALLOW_PATH");
363
364        assert!(
365            result.is_ok(),
366            "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
367        );
368    }
369
370    #[cfg(all(unix, not(feature = "no-jail")))]
371    #[test]
372    fn rejects_symlink_escape_on_unix() {
373        use std::os::unix::fs::symlink;
374
375        let tmp = tempfile::tempdir().unwrap();
376        let root = tmp.path().join("root");
377        let other = tmp.path().join("other");
378        std::fs::create_dir_all(&root).unwrap();
379        std::fs::create_dir_all(&other).unwrap();
380        std::fs::write(other.join("secret.txt"), "no").unwrap();
381
382        let link = root.join("link.txt");
383        symlink(other.join("secret.txt"), &link).unwrap();
384
385        let bad = jail_path(&link, &root);
386        assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
387    }
388
389    #[test]
390    fn rejects_null_byte_in_path() {
391        let tmp = tempfile::tempdir().unwrap();
392        let root = tmp.path().join("root");
393        std::fs::create_dir_all(&root).unwrap();
394
395        let bad_path = PathBuf::from("file\0.txt");
396        let result = jail_path(&bad_path, &root);
397        assert!(result.is_err(), "null byte in path must be rejected");
398        assert!(
399            result.unwrap_err().contains("null byte"),
400            "error must mention null byte"
401        );
402    }
403}