lean_ctx_server/tools/
server_paths.rs1use super::server::LeanCtxServer;
3use super::startup::{
4 has_project_marker, is_suspicious_root, maybe_derive_project_root_from_absolute,
5};
6
7impl LeanCtxServer {
8 pub fn checkpoint_interval_effective() -> usize {
9 if let Ok(v) = std::env::var("LEAN_CTX_CHECKPOINT_INTERVAL")
10 && let Ok(parsed) = v.trim().parse::<usize>()
11 {
12 return parsed;
13 }
14 let profile_interval = lean_ctx::core::profiles::active_profile()
15 .autonomy
16 .checkpoint_interval_effective();
17 if profile_interval > 0 {
18 return profile_interval as usize;
19 }
20 lean_ctx::core::config::Config::load().checkpoint_interval as usize
21 }
22
23 pub async fn resolve_path(&self, path: &str) -> Result<String, String> {
27 self.resolve_path_refusing(path)
28 .await
29 .map_err(|refusal| refusal.message)
30 }
31
32 pub(crate) async fn record_path_refusal(
35 &self,
36 tool: &str,
37 refusal: &lean_ctx::core::path_resolve::PathRefusal,
38 ) {
39 let Some(class) = refusal.class else {
40 return;
41 };
42 let agent_id = self
43 .agent_id
44 .read()
45 .await
46 .clone()
47 .unwrap_or_else(|| "unknown".to_string());
48 lean_ctx::core::security_events::record_refusals(tool, &agent_id, class, 1);
49 }
50
51 pub(crate) async fn resolve_path_refusing(
55 &self,
56 path: &str,
57 ) -> Result<String, lean_ctx::core::path_resolve::PathRefusal> {
58 use lean_ctx::core::path_resolve::PathRefusal;
59 let normalized = lean_ctx::core::pathutil::normalize_tool_path(path);
60 if normalized.is_empty() || normalized == "." {
61 let root = self.session.read().await.project_root.clone();
62 return Ok(lean_ctx::core::workspace_authority::project_dot(
63 &normalized,
64 root.as_deref(),
65 )?);
66 }
67 let p = std::path::Path::new(&normalized);
68
69 let (resolved, jail_root, extra_roots) = {
70 let session = self.session.read().await;
71 let jail_root = session
72 .project_root
73 .as_deref()
74 .or(session.shell_cwd.as_deref())
75 .unwrap_or(".")
76 .to_string();
77
78 let worktree_cwd = if p.is_absolute() {
86 None
87 } else {
88 session
89 .project_root
90 .as_deref()
91 .zip(session.shell_cwd.as_deref())
92 .filter(|(root, cwd)| {
93 lean_ctx::core::path_resolve::shell_cwd_is_divergent_checkout(root, cwd)
94 })
95 .map(|(_, cwd)| std::path::Path::new(cwd).join(&normalized))
96 };
97
98 let resolved = if let Some(overridden) = worktree_cwd {
99 overridden
100 } else if p.is_absolute() || p.exists() {
101 std::path::PathBuf::from(&normalized)
102 } else if let Some(ref root) = session.project_root {
103 let joined = std::path::Path::new(root).join(&normalized);
104 if joined.exists() {
105 joined
106 } else if let Some(ref cwd) = session.shell_cwd {
107 std::path::Path::new(cwd).join(&normalized)
108 } else {
109 std::path::Path::new(&jail_root).join(&normalized)
110 }
111 } else if let Some(ref cwd) = session.shell_cwd {
112 std::path::Path::new(cwd).join(&normalized)
113 } else {
114 std::path::Path::new(&jail_root).join(&normalized)
115 };
116
117 if session.project_root.is_none()
120 && let Some(error) =
121 lean_ctx::core::path_resolve::unbound_relative_path(path, &resolved, &jail_root)
122 {
123 return Err(error.into());
124 }
125 (resolved, jail_root, session.extra_roots.clone())
126 };
127
128 let jail_root_path = std::path::Path::new(&jail_root);
129 let jailed = match lean_ctx::core::pathjail::jail_path_with_roots(
130 &resolved,
131 jail_root_path,
132 &extra_roots,
133 ) {
134 Ok(p) => self
135 .maybe_reroot_for_absolute_path(&resolved, jail_root_path, &extra_roots)
136 .await?
137 .unwrap_or(p),
138 Err(e) => {
139 if lean_ctx::core::pathjail::detect_language_cache_root(&resolved).is_some() {
145 match lean_ctx::core::pathjail::jail_path_auto_admitting(
146 &resolved,
147 jail_root_path,
148 &extra_roots,
149 ) {
150 Ok(jailed) => jailed,
151 Err(retry) => {
152 lean_ctx_hive::metrics_v2::signals::blocked(
153 &lean_ctx_hive::metrics_v2::signals::path_jail_rule(),
154 );
155 return Err(PathRefusal::jail(&retry));
156 }
157 }
158 } else if let Some(jailed) = self
159 .maybe_reroot_for_absolute_path(&resolved, jail_root_path, &extra_roots)
160 .await?
161 {
162 jailed
163 } else {
164 lean_ctx_hive::metrics_v2::signals::blocked(
165 &lean_ctx_hive::metrics_v2::signals::path_jail_rule(),
166 );
167 return Err(PathRefusal::jail(&e));
168 }
169 }
170 };
171
172 lean_ctx::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)
173 .map_err(PathRefusal::secret_path)?;
174
175 Ok(lean_ctx::core::pathutil::normalize_tool_path(
176 &jailed.to_string_lossy().replace('\\', "/"),
177 ))
178 }
179
180 async fn maybe_reroot_for_absolute_path(
181 &self,
182 resolved: &std::path::Path,
183 jail_root_path: &std::path::Path,
184 extra_roots: &[String],
185 ) -> Result<Option<std::path::PathBuf>, String> {
186 if !resolved.is_absolute() || self.session_mode == super::SessionMode::Shared {
189 return Ok(None);
190 }
191 if lean_ctx::core::workspace_authority::is_configured() {
194 return Ok(None);
195 }
196 if crate::server::roots::pinned_root().is_some_and(|pinned| {
201 lean_ctx::core::pathutil::safe_canonicalize_or_self(std::path::Path::new(&pinned))
202 == lean_ctx::core::pathutil::safe_canonicalize_or_self(jail_root_path)
203 }) {
204 return Ok(None);
205 }
206 let Some(new_root) = maybe_derive_project_root_from_absolute(resolved)
210 .filter(|root| !lean_ctx::core::pathutil::is_broad_or_unsafe_root(root))
211 else {
212 return Ok(None);
213 };
214 let jail_is_real_project =
215 has_project_marker(jail_root_path) && !is_suspicious_root(jail_root_path);
216 let candidate_under_jail = resolved.starts_with(jail_root_path);
217 if jail_is_real_project
220 && new_root.starts_with(jail_root_path)
221 && new_root != jail_root_path
222 {
223 return Ok(None);
224 }
225 let allow_reroot = if candidate_under_jail && jail_is_real_project {
229 false
230 } else {
231 !jail_is_real_project || self.session.read().await.project_root.is_none()
232 };
233
234 if !allow_reroot {
235 return Ok(None);
236 }
237
238 if !self.reroot_to_project(&new_root).await {
239 return Ok(None);
240 }
241 lean_ctx::core::pathjail::jail_path_with_roots(resolved, &new_root, extra_roots)
242 .map(Some)
243 .map_err(|e| e.to_string())
244 }
245
246 async fn reroot_to_project(&self, new_root: &std::path::Path) -> bool {
251 if lean_ctx::core::policy::runtime::inherited_source_view().is_some() {
252 return false;
253 }
254 let new_root_str = new_root.to_string_lossy().to_string();
255 let shell_cwd = self
256 .startup_shell_cwd
257 .as_ref()
258 .filter(|cwd| std::path::Path::new(cwd).starts_with(new_root))
259 .cloned()
260 .or_else(|| Some(new_root_str.clone()));
261 let current = self.session.instance();
262 let extra_roots = current.read().await.extra_roots.clone();
263 let switch = crate::server::SessionSwitch {
264 new_root: &new_root_str,
265 extra_roots,
266 shell_cwd,
267 rebind_request: true,
268 };
269 let Some(saves) = self.switch_session_root(¤t, switch).await else {
270 return false;
271 };
272 for save in saves {
273 let _ = save.await;
274 }
275 true
276 }
277
278 pub async fn resolve_path_or_passthrough(&self, path: &str) -> String {
280 self.resolve_path(path)
281 .await
282 .unwrap_or_else(|_| path.to_string())
283 }
284}