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