lean_ctx/core/
pathjail.rs1use 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 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}