Skip to main content

lean_ctx/core/
pathjail.rs

1use std::path::{Path, PathBuf};
2
3fn agent_allowlist_dirs() -> Vec<PathBuf> {
4    let home = match dirs::home_dir() {
5        Some(h) => h,
6        None => return Vec::new(),
7    };
8
9    let mut dirs = Vec::new();
10
11    if std::env::var("CODEX_CLI_SESSION").is_ok() || std::env::var("CODEX_SANDBOX_DIR").is_ok() {
12        let codex_dir = home.join(".codex");
13        if codex_dir.exists() {
14            dirs.push(canonicalize_or_self(&codex_dir));
15        }
16    }
17
18    if std::env::var("CLAUDE_CODE_SESSION").is_ok() || std::env::var("CLAUDE_CODE").is_ok() {
19        let claude_dir = home.join(".claude");
20        if claude_dir.exists() {
21            dirs.push(canonicalize_or_self(&claude_dir));
22        }
23    }
24
25    dirs
26}
27
28fn allow_paths_from_env() -> Vec<PathBuf> {
29    let mut out = Vec::new();
30
31    if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
32        out.push(canonicalize_or_self(&data_dir));
33    }
34
35    out.extend(agent_allowlist_dirs());
36
37    let v = std::env::var("LCTX_ALLOW_PATH")
38        .or_else(|_| std::env::var("LEAN_CTX_ALLOW_PATH"))
39        .unwrap_or_default();
40    if v.trim().is_empty() {
41        return out;
42    }
43    for p in std::env::split_paths(&v) {
44        out.push(crate::core::pathutil::safe_canonicalize_or_self(&p));
45    }
46    out
47}
48
49fn is_under_prefix(path: &Path, prefix: &Path) -> bool {
50    path.starts_with(prefix)
51}
52
53fn canonicalize_or_self(path: &Path) -> PathBuf {
54    crate::core::pathutil::safe_canonicalize_or_self(path)
55}
56
57fn canonicalize_existing_ancestor(path: &Path) -> Option<(PathBuf, Vec<std::ffi::OsString>)> {
58    let mut cur = path.to_path_buf();
59    let mut remainder: Vec<std::ffi::OsString> = Vec::new();
60    loop {
61        if cur.exists() {
62            return Some((canonicalize_or_self(&cur), remainder));
63        }
64        let name = cur.file_name()?.to_os_string();
65        remainder.push(name);
66        if !cur.pop() {
67            return None;
68        }
69    }
70}
71
72pub fn jail_path(candidate: &Path, jail_root: &Path) -> Result<PathBuf, String> {
73    let root = canonicalize_or_self(jail_root);
74    let allow = allow_paths_from_env();
75
76    let (base, remainder) = canonicalize_existing_ancestor(candidate).ok_or_else(|| {
77        format!(
78            "path does not exist and has no existing ancestor: {}",
79            candidate.display()
80        )
81    })?;
82
83    let allowed = is_under_prefix(&base, &root) || allow.iter().any(|p| is_under_prefix(&base, p));
84
85    #[cfg(windows)]
86    let allowed = allowed || is_under_prefix_windows(&base, &root);
87
88    if !allowed {
89        return Err(format!(
90            "path escapes project root: {} (root: {})",
91            candidate.display(),
92            root.display()
93        ));
94    }
95
96    #[cfg(windows)]
97    reject_symlink_on_windows(candidate)?;
98
99    let mut out = base;
100    for part in remainder.iter().rev() {
101        out.push(part);
102    }
103    Ok(out)
104}
105
106#[cfg(windows)]
107fn is_under_prefix_windows(path: &Path, prefix: &Path) -> bool {
108    let path_str = path.to_string_lossy().to_lowercase().replace('/', "\\");
109    let prefix_str = prefix.to_string_lossy().to_lowercase().replace('/', "\\");
110    path_str.starts_with(&prefix_str)
111}
112
113#[cfg(windows)]
114fn reject_symlink_on_windows(path: &Path) -> Result<(), String> {
115    if let Ok(meta) = std::fs::symlink_metadata(path) {
116        if meta.is_symlink() {
117            return Err(format!(
118                "symlink not allowed in jailed path: {}",
119                path.display()
120            ));
121        }
122    }
123    Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn rejects_path_outside_root() {
132        let tmp = tempfile::tempdir().unwrap();
133        let root = tmp.path().join("root");
134        let other = tmp.path().join("other");
135        std::fs::create_dir_all(&root).unwrap();
136        std::fs::create_dir_all(&other).unwrap();
137        std::fs::write(root.join("a.txt"), "ok").unwrap();
138        std::fs::write(other.join("b.txt"), "no").unwrap();
139
140        let ok = jail_path(&root.join("a.txt"), &root);
141        assert!(ok.is_ok());
142
143        let bad = jail_path(&other.join("b.txt"), &root);
144        assert!(bad.is_err());
145    }
146
147    #[test]
148    fn allows_nonexistent_child_under_root() {
149        let tmp = tempfile::tempdir().unwrap();
150        let root = tmp.path().join("root");
151        std::fs::create_dir_all(&root).unwrap();
152        std::fs::write(root.join("a.txt"), "ok").unwrap();
153
154        let p = root.join("new").join("file.txt");
155        let ok = jail_path(&p, &root).unwrap();
156        assert!(ok.to_string_lossy().contains("file.txt"));
157    }
158
159    #[test]
160    fn agent_allowlist_empty_without_env() {
161        std::env::remove_var("CODEX_CLI_SESSION");
162        std::env::remove_var("CODEX_SANDBOX_DIR");
163        std::env::remove_var("CLAUDE_CODE_SESSION");
164        std::env::remove_var("CLAUDE_CODE");
165        let dirs = agent_allowlist_dirs();
166        assert!(dirs.is_empty());
167    }
168}