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    // Bypass PathJail entirely when:
90    // 1. LEAN_CTX_NO_JAIL=1 env var is set (explicit opt-out)
91    // 2. path_jail = false in config.toml
92    // 3. Running inside a container (Docker/Podman) where paths are already isolated
93    if std::env::var("LEAN_CTX_NO_JAIL").is_ok_and(|v| v == "1" || v == "true") {
94        return Ok(canonicalize_or_self(candidate));
95    }
96    let cfg = crate::core::config::Config::load();
97    if cfg.path_jail == Some(false) {
98        return Ok(canonicalize_or_self(candidate));
99    }
100    if crate::shell::platform::is_container() {
101        return Ok(canonicalize_or_self(candidate));
102    }
103
104    let root = canonicalize_or_self(jail_root);
105    let allow = allow_paths_from_env_and_config();
106
107    let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
108        format!(
109            "path does not exist and has no existing ancestor: {}",
110            candidate.display()
111        )
112    })?;
113
114    let allowed = is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
115
116    #[cfg(windows)]
117    let allowed = allowed || is_under_prefix_windows(&base, &root);
118
119    if !allowed {
120        return Err(format!(
121            "path escapes project root: {} (root: {}). \
122             Hint: set LEAN_CTX_ALLOW_PATH={} or add it to allow_paths in ~/.lean-ctx/config.toml",
123            candidate.display(),
124            root.display(),
125            candidate.parent().unwrap_or(candidate).display()
126        ));
127    }
128
129    #[cfg(windows)]
130    reject_symlink_on_windows(candidate)?;
131
132    let mut out = base;
133    for part in remainder.iter().rev() {
134        out.push(part);
135    }
136    Ok(out)
137}
138
139#[cfg(windows)]
140fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
141    let path_str = normalize_windows_path(&path.to_string_lossy());
142    let prefix_str = normalize_windows_path(&prefix.to_string_lossy());
143    path_str.starts_with(&prefix_str)
144}
145
146#[cfg(windows)]
147fn normalize_windows_path(s: &str) -> String {
148    let stripped = super::pathutil::strip_verbatim_str(s).unwrap_or_else(|| s.to_string());
149    stripped.to_lowercase().replace('/', "\\")
150}
151
152#[cfg(windows)]
153fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
154    if let Ok(meta) = std::fs::symlink_metadata(path) {
155        if meta.is_symlink() {
156            return Err(format!(
157                "symlink not allowed in jailed path: {}",
158                path.display()
159            ));
160        }
161    }
162    Ok(())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn rejects_path_outside_root() {
171        let tmp = tempfile::tempdir().unwrap();
172        let root = tmp.path().join("root");
173        let other = tmp.path().join("other");
174        std::fs::create_dir_all(&root).unwrap();
175        std::fs::create_dir_all(&other).unwrap();
176        std::fs::write(root.join("a.txt"), "ok").unwrap();
177        std::fs::write(other.join("b.txt"), "no").unwrap();
178
179        let ok = jail_path(&root.join("a.txt"), &root);
180        assert!(ok.is_ok());
181
182        let bad = jail_path(&other.join("b.txt"), &root);
183        assert!(bad.is_err());
184    }
185
186    #[test]
187    fn allows_nonexistent_child_under_root() {
188        let tmp = tempfile::tempdir().unwrap();
189        let root = tmp.path().join("root");
190        std::fs::create_dir_all(&root).unwrap();
191        std::fs::write(root.join("a.txt"), "ok").unwrap();
192
193        let p = root.join("new").join("file.txt");
194        let ok = jail_path(&p, &root).unwrap();
195        assert!(ok.to_string_lossy().contains("file.txt"));
196    }
197
198    #[test]
199    fn ide_config_dirs_list_is_not_empty() {
200        assert!(IDE_CONFIG_DIRS.len() >= 10);
201        assert!(IDE_CONFIG_DIRS.contains(&".codex"));
202        assert!(IDE_CONFIG_DIRS.contains(&".cursor"));
203        assert!(IDE_CONFIG_DIRS.contains(&".claude"));
204        assert!(IDE_CONFIG_DIRS.contains(&".gemini"));
205    }
206
207    #[test]
208    fn canonicalize_or_self_strips_verbatim() {
209        let tmp = tempfile::tempdir().unwrap();
210        let dir = tmp.path().join("project");
211        std::fs::create_dir_all(&dir).unwrap();
212
213        let result = canonicalize_or_self(&dir);
214        let s = result.to_string_lossy();
215        assert!(
216            !s.starts_with(r"\\?\"),
217            "canonicalize_or_self should strip verbatim prefix, got: {s}"
218        );
219    }
220
221    #[test]
222    fn jail_path_accepts_same_dir_different_format() {
223        let tmp = tempfile::tempdir().unwrap();
224        let root = tmp.path().join("project");
225        std::fs::create_dir_all(&root).unwrap();
226        std::fs::write(root.join("file.rs"), "ok").unwrap();
227
228        let result = jail_path(&root.join("file.rs"), &root);
229        assert!(result.is_ok(), "same dir should be accepted: {result:?}");
230    }
231
232    #[test]
233    fn error_message_contains_allow_path_hint() {
234        let tmp = tempfile::tempdir().unwrap();
235        let root = tmp.path().join("root");
236        let other = tmp.path().join("other");
237        std::fs::create_dir_all(&root).unwrap();
238        std::fs::create_dir_all(&other).unwrap();
239        std::fs::write(other.join("b.txt"), "no").unwrap();
240
241        let err = jail_path(&other.join("b.txt"), &root).unwrap_err();
242        assert!(
243            err.contains("LEAN_CTX_ALLOW_PATH"),
244            "error should hint at LEAN_CTX_ALLOW_PATH: {err}"
245        );
246        assert!(
247            err.contains("allow_paths"),
248            "error should hint at config allow_paths: {err}"
249        );
250    }
251
252    #[test]
253    fn allow_path_env_permits_outside_root() {
254        let tmp = tempfile::tempdir().unwrap();
255        let root = tmp.path().join("root");
256        let other = tmp.path().join("other");
257        std::fs::create_dir_all(&root).unwrap();
258        std::fs::create_dir_all(&other).unwrap();
259        std::fs::write(other.join("b.txt"), "allowed").unwrap();
260
261        let canon = canonicalize_or_self(&other);
262        std::env::set_var("LEAN_CTX_ALLOW_PATH", canon.to_string_lossy().as_ref());
263        let result = jail_path(&other.join("b.txt"), &root);
264        std::env::remove_var("LEAN_CTX_ALLOW_PATH");
265
266        assert!(
267            result.is_ok(),
268            "LEAN_CTX_ALLOW_PATH should permit access: {result:?}"
269        );
270    }
271
272    #[cfg(unix)]
273    #[test]
274    fn rejects_symlink_escape_on_unix() {
275        use std::os::unix::fs::symlink;
276
277        let tmp = tempfile::tempdir().unwrap();
278        let root = tmp.path().join("root");
279        let other = tmp.path().join("other");
280        std::fs::create_dir_all(&root).unwrap();
281        std::fs::create_dir_all(&other).unwrap();
282        std::fs::write(other.join("secret.txt"), "no").unwrap();
283
284        let link = root.join("link.txt");
285        symlink(other.join("secret.txt"), &link).unwrap();
286
287        let bad = jail_path(&link, &root);
288        assert!(bad.is_err(), "symlink escape must be rejected: {bad:?}");
289    }
290}