Skip to main content

lean_ctx/tools/
server_paths.rs

1use super::server::LeanCtxServer;
2use super::startup::{
3    has_project_marker, is_suspicious_root, maybe_derive_project_root_from_absolute,
4};
5
6impl LeanCtxServer {
7    pub fn checkpoint_interval_effective() -> usize {
8        if let Ok(v) = std::env::var("LEAN_CTX_CHECKPOINT_INTERVAL")
9            && let Ok(parsed) = v.trim().parse::<usize>()
10        {
11            return parsed;
12        }
13        let profile_interval = crate::core::profiles::active_profile()
14            .autonomy
15            .checkpoint_interval_effective();
16        if profile_interval > 0 {
17            return profile_interval as usize;
18        }
19        crate::core::config::Config::load().checkpoint_interval as usize
20    }
21
22    /// Resolves a (possibly relative) tool path against the session's project_root.
23    /// Absolute paths and "." are returned as-is. Relative paths like "src/main.rs"
24    /// are joined with project_root so tools work regardless of the server's cwd.
25    pub async fn resolve_path(&self, path: &str) -> Result<String, String> {
26        let normalized = crate::core::pathutil::normalize_tool_path(path);
27        if normalized.is_empty() || normalized == "." {
28            return Ok(normalized);
29        }
30        let p = std::path::Path::new(&normalized);
31
32        let (resolved, jail_root, extra_roots) = {
33            let session = self.session.read().await;
34            let jail_root = session
35                .project_root
36                .as_deref()
37                .or(session.shell_cwd.as_deref())
38                .unwrap_or(".")
39                .to_string();
40
41            // #707: a shell_cwd tracking a mid-session worktree switch
42            // (different git checkout) outranks the stale project_root — same
43            // precedence as core::path_resolve. Checked BEFORE the `p.exists()`
44            // probe below: that probe runs against the *process* CWD, which
45            // IDEs routinely set to the original project root, so it would
46            // short-circuit every relative path back to the stale checkout and
47            // the divergence rule could never apply.
48            let worktree_cwd = if p.is_absolute() {
49                None
50            } else {
51                session
52                    .project_root
53                    .as_deref()
54                    .zip(session.shell_cwd.as_deref())
55                    .filter(|(root, cwd)| {
56                        crate::core::path_resolve::shell_cwd_is_divergent_checkout(root, cwd)
57                    })
58                    .map(|(_, cwd)| std::path::Path::new(cwd).join(&normalized))
59            };
60
61            let resolved = if let Some(overridden) = worktree_cwd {
62                overridden
63            } else if p.is_absolute() || p.exists() {
64                std::path::PathBuf::from(&normalized)
65            } else if let Some(ref root) = session.project_root {
66                let joined = std::path::Path::new(root).join(&normalized);
67                if joined.exists() {
68                    joined
69                } else if let Some(ref cwd) = session.shell_cwd {
70                    std::path::Path::new(cwd).join(&normalized)
71                } else {
72                    std::path::Path::new(&jail_root).join(&normalized)
73                }
74            } else if let Some(ref cwd) = session.shell_cwd {
75                std::path::Path::new(cwd).join(&normalized)
76            } else {
77                std::path::Path::new(&jail_root).join(&normalized)
78            };
79
80            // Session-scoped trusted roots (MCP roots/list, config extra_roots,
81            // git worktrees) must widen the jail for an explicit path (#403).
82            (resolved, jail_root, session.extra_roots.clone())
83        };
84
85        let jail_root_path = std::path::Path::new(&jail_root);
86        let jailed = match crate::core::pathjail::jail_path_with_roots(
87            &resolved,
88            jail_root_path,
89            &extra_roots,
90        ) {
91            Ok(p) => p,
92            Err(e) => {
93                // #899: the rejected path is dependency source in a language
94                // cache (Go module cache, cargo registry, site-packages,
95                // node_modules, …). Register its root as a session-scoped
96                // read-only root and ask the agent to retry — the next resolve
97                // sees it in the read allow-list. Stays fail-closed: this first
98                // call still errors, and writes into the cache remain denied.
99                if let Some((label, cache_root)) =
100                    crate::core::pathjail::detect_language_cache_root(&resolved)
101                    && crate::core::pathjail::register_session_read_only_root(&cache_root)
102                {
103                    return Err(format!(
104                        "Auto-detected {label} at {} — added as a read-only root for this \
105                         session. Retry the read.",
106                        cache_root.display()
107                    ));
108                }
109                if p.is_absolute() {
110                    if let Some(new_root) = maybe_derive_project_root_from_absolute(&resolved) {
111                        let cfg_allow = std::env::var("LEAN_CTX_ALLOW_REROOT").map_or_else(
112                            |_| crate::core::config::Config::load().allow_auto_reroot,
113                            |v| v == "1" || v == "true",
114                        );
115                        let candidate_under_jail = resolved.starts_with(jail_root_path);
116                        // #580/#649: when the MCP server was launched from an
117                        // agent/IDE config dir (e.g. ~/.copilot) or a markerless
118                        // client cwd (e.g. WSL VS Code starting in /mnt/c/Users),
119                        // that jail is not a real project boundary. The derived
120                        // root already carries a project marker, so correcting to
121                        // it is a root fix, not a jail weakening. Real project
122                        // roots and trusted startup roots still keep the
123                        // conservative gate.
124                        let allow_reroot = if candidate_under_jail {
125                            false
126                        } else if is_suspicious_root(jail_root_path)
127                            || (self.startup_project_root.is_none()
128                                && !has_project_marker(jail_root_path))
129                        {
130                            true
131                        } else if !cfg_allow {
132                            false
133                        } else if let Some(ref trusted_root) = self.startup_project_root {
134                            std::path::Path::new(trusted_root) == new_root.as_path()
135                        } else {
136                            !has_project_marker(jail_root_path)
137                        };
138
139                        if allow_reroot {
140                            let mut session = self.session.write().await;
141                            let new_root_str = new_root.to_string_lossy().to_string();
142                            session.project_root = Some(new_root_str.clone());
143                            session.shell_cwd = self
144                                .startup_shell_cwd
145                                .as_ref()
146                                .filter(|cwd| std::path::Path::new(cwd).starts_with(&new_root))
147                                .cloned()
148                                .or_else(|| Some(new_root_str.clone()));
149                            let _ = session.save();
150
151                            crate::core::pathjail::jail_path_with_roots(
152                                &resolved,
153                                &new_root,
154                                &extra_roots,
155                            )
156                            .map_err(|e| e.to_string())?
157                        } else {
158                            return Err(e.to_string());
159                        }
160                    } else {
161                        return Err(e.to_string());
162                    }
163                } else {
164                    return Err(e.to_string());
165                }
166            }
167        };
168
169        crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
170
171        Ok(crate::core::pathutil::normalize_tool_path(
172            &jailed.to_string_lossy().replace('\\', "/"),
173        ))
174    }
175
176    /// Like `resolve_path`, but returns the original path on failure instead of an error.
177    pub async fn resolve_path_or_passthrough(&self, path: &str) -> String {
178        self.resolve_path(path)
179            .await
180            .unwrap_or_else(|_| path.to_string())
181    }
182}