lean_ctx/tools/
server_paths.rs1use 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 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 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 (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) => self
92 .maybe_reroot_for_absolute_path(&resolved, jail_root_path, &extra_roots, false)
93 .await?
94 .unwrap_or(p),
95 Err(e) => {
96 if let Some((label, cache_root)) =
103 crate::core::pathjail::detect_language_cache_root(&resolved)
104 && crate::core::pathjail::register_session_read_only_root(&cache_root)
105 {
106 return Err(format!(
107 "Auto-detected {label} at {} — added as a read-only root for this \
108 session. Retry the read.",
109 cache_root.display()
110 ));
111 }
112 if let Some(jailed) = self
113 .maybe_reroot_for_absolute_path(&resolved, jail_root_path, &extra_roots, true)
114 .await?
115 {
116 jailed
117 } else {
118 return Err(e.to_string());
119 }
120 }
121 };
122
123 crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)?;
124
125 Ok(crate::core::pathutil::normalize_tool_path(
126 &jailed.to_string_lossy().replace('\\', "/"),
127 ))
128 }
129
130 async fn maybe_reroot_for_absolute_path(
131 &self,
132 resolved: &std::path::Path,
133 jail_root_path: &std::path::Path,
134 extra_roots: &[String],
135 require_opt_in_for_real_jail: bool,
136 ) -> Result<Option<std::path::PathBuf>, String> {
137 if !resolved.is_absolute() {
138 return Ok(None);
139 }
140 let Some(new_root) = maybe_derive_project_root_from_absolute(resolved) else {
141 return Ok(None);
142 };
143 let candidate_under_jail = resolved.starts_with(jail_root_path);
144 if new_root.starts_with(jail_root_path) && new_root != jail_root_path {
148 return Ok(None);
149 }
150 let allow_reroot = if candidate_under_jail {
156 false
157 } else if is_suspicious_root(jail_root_path)
158 || (self.startup_project_root.is_none() && !has_project_marker(jail_root_path))
159 {
160 true
161 } else if require_opt_in_for_real_jail {
162 let cfg_allow = std::env::var("LEAN_CTX_ALLOW_REROOT").map_or_else(
163 |_| crate::core::config::Config::load().allow_auto_reroot,
164 |v| v == "1" || v == "true",
165 );
166 cfg_allow
167 && self
168 .startup_project_root
169 .as_ref()
170 .is_some_and(|trusted_root| std::path::Path::new(trusted_root) == new_root)
171 } else {
172 false
173 };
174
175 if !allow_reroot {
176 return Ok(None);
177 }
178
179 self.reroot_to_project(&new_root).await;
180 crate::core::pathjail::jail_path_with_roots(resolved, &new_root, extra_roots)
181 .map(Some)
182 .map_err(|e| e.to_string())
183 }
184
185 async fn reroot_to_project(&self, new_root: &std::path::Path) {
186 let mut session = self.session.write().await;
187 let new_root_str = new_root.to_string_lossy().to_string();
188 session.project_root = Some(new_root_str.clone());
189 session.shell_cwd = self
190 .startup_shell_cwd
191 .as_ref()
192 .filter(|cwd| std::path::Path::new(cwd).starts_with(new_root))
193 .cloned()
194 .or_else(|| Some(new_root_str.clone()));
195 let _ = session.save();
196 }
197
198 pub async fn resolve_path_or_passthrough(&self, path: &str) -> String {
200 self.resolve_path(path)
201 .await
202 .unwrap_or_else(|_| path.to_string())
203 }
204}