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