Skip to main content

lean_ctx_server/tools/
server_paths.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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    /// Resolves a (possibly relative) tool path against the session's project_root.
24    /// Absolute paths and "." are returned as-is. Relative paths like "src/main.rs"
25    /// are joined with project_root so tools work regardless of the server's cwd.
26    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    /// Records a classified boundary refusal once, with the calling `tool`:
33    /// its class only, never the path.
34    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    /// [`Self::resolve_path`], saying which boundary refused the path. The
52    /// dispatch layer records a classified refusal once, with the calling
53    /// tool (the same path may be resolved again for planning).
54    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            // #707: a shell_cwd tracking a mid-session worktree switch
79            // (different git checkout) outranks the stale project_root — same
80            // precedence as core::path_resolve. Checked BEFORE the `p.exists()`
81            // probe below: that probe runs against the *process* CWD, which
82            // IDEs routinely set to the original project root, so it would
83            // short-circuit every relative path back to the stale checkout and
84            // the divergence rule could never apply.
85            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            // Session-scoped trusted roots (MCP roots/list, config extra_roots,
118            // git worktrees) must widen the jail for an explicit path (#403).
119            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                // #899: a refused path in a language cache or an agent
140                // instruction folder is admitted read-only for the session
141                // and resolved again (see `jail_path_auto_admitting`). If it
142                // is still refused, report why — "retry" would only repeat
143                // the same refusal.
144                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        // A shared session is every client's: one client's path never moves
187        // it (AP04-G5/G6).
188        if !resolved.is_absolute() || self.session_mode == super::SessionMode::Shared {
189            return Ok(None);
190        }
191        // Under a Workspace the session root follows the snapshot only
192        // (#2120); a path never moves it.
193        if lean_ctx::core::workspace_authority::is_configured() {
194            return Ok(None);
195        }
196        // A root pinned with `LEAN_CTX_PROJECT_ROOT` is a boundary the operator
197        // set, not a guess to correct: a markerless pinned directory inside a
198        // larger checkout must not be widened to that checkout — and every
199        // index, policy and receipt moved with it — by the first absolute path.
200        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        // The outermost marker above a path can be `~` itself (a dotfiles
207        // repository) or an agent directory: never a project to move to
208        // (AP04-G6).
209        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        // #1437: never reroot to a child when jail is a real project. Skip when
218        // jail is suspicious (/, home, agent-config) — not a real boundary.
219        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        // Universal auto-reroot: when the jail root is not a real project
226        // (suspicious, None, or markerless), correct to derived project root.
227        // No IDE-specific logic — works for all IDEs and daemon.
228        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    /// Move the session — and the request that corrected it — to `new_root`
247    /// through the same switch as a host's root change: that project's own
248    /// session, the stale one saved where it was (AP04 review S4). Refused
249    /// under a pinned source view, whose request project cannot follow.
250    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(&current, switch).await else {
270            return false;
271        };
272        for save in saves {
273            let _ = save.await;
274        }
275        true
276    }
277
278    /// Like `resolve_path`, but returns the original path on failure instead of an error.
279    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}