Skip to main content

lean_ctx/core/
path_resolve.rs

1//! Shared path-resolution for tool handlers.
2//!
3//! Previously two near-identical `resolve_path_sync` implementations lived in
4//! `tools/registered/mod.rs` (SessionState-based) and `server/tool_trait.rs`
5//! (ToolContext-based), plus several copies of the project-marker test. This
6//! module is the single source of truth: [`resolve_tool_path`] for jailed path
7//! resolution and a re-export of [`has_project_marker`] for marker detection.
8
9use std::path::{Path, PathBuf};
10
11/// Single canonical project-marker test (`.git`, `Cargo.toml`, …).
12///
13/// Re-exported from [`crate::core::pathutil`] so callers that think in terms of
14/// path resolution have a local, discoverable handle.
15pub use crate::core::pathutil::has_project_marker;
16
17/// Nearest ancestor (including `start` itself) containing a `.git` entry —
18/// directory (normal checkout) **or file** (linked worktree: `gitdir: …`),
19/// canonicalized for comparison. `None` when no git boundary exists upward.
20///
21/// Deliberately `.git`-only, NOT [`has_project_marker`]: markers like
22/// `Cargo.toml` exist in nested monorepo subdirectories (`rust/Cargo.toml`
23/// in this very repo), so using them here would make a plain `cd rust/` look
24/// like a checkout switch (#707).
25fn nearest_git_boundary(start: &Path) -> Option<PathBuf> {
26    let start = crate::core::pathutil::safe_canonicalize_or_self(start);
27    let mut cur: Option<&Path> = Some(start.as_path());
28    while let Some(dir) = cur {
29        if dir.join(".git").exists() {
30            return Some(dir.to_path_buf());
31        }
32        cur = dir.parent();
33    }
34    None
35}
36
37/// True when `shell_cwd` lives in a DIFFERENT git checkout than
38/// `project_root` (#707): both sides resolve to a `.git` boundary and the
39/// boundaries differ. This is the worktree signal — Claude Code's
40/// `EnterWorktree` nests a full checkout (own `.git` *file*) under
41/// `<repo>/.claude/worktrees/<name>/`, so a same-named relative path exists
42/// in both trees and a bare `exists()` probe silently picks the stale one.
43/// A monorepo subdirectory (`cd rust/`) shares the boundary → not diverged;
44/// either side without any `.git` upward → no signal → not diverged.
45pub fn shell_cwd_is_divergent_checkout(project_root: &str, shell_cwd: &str) -> bool {
46    if shell_cwd == project_root {
47        return false;
48    }
49    match (
50        nearest_git_boundary(Path::new(shell_cwd)),
51        nearest_git_boundary(Path::new(project_root)),
52    ) {
53        (Some(cwd_git), Some(root_git)) => cwd_git != root_git,
54        _ => false,
55    }
56}
57
58/// Resolve a (possibly relative) tool path to a normalized, jail-checked,
59/// secret-screened absolute path.
60///
61/// Resolution order for relative inputs:
62/// 1. absolute path → used as-is
63/// 2. `<project_root>/<path>` if it exists
64/// 3. `<shell_cwd>/<path>` if a shell cwd is known
65/// 4. `<jail_root>/<path>` as a last resort
66///
67/// Relative inputs are NEVER resolved against the process CWD: the daemon's
68/// CWD is not the project, so a CWD `exists()` probe made resolution
69/// nondeterministic across MCP/daemon/CLI contexts (and could pick a
70/// same-named file outside the project).
71///
72/// `jail_root` is `project_root`, else `shell_cwd`, else `"."`. The result is
73/// confined to the jail root via [`crate::core::pathjail::jail_path`] and
74/// screened by the secret-path I/O boundary.
75///
76/// Performs blocking filesystem `exists()` checks; callers on async runtimes
77/// must wrap this in `tokio::task::block_in_place`.
78pub fn resolve_tool_path(
79    project_root: Option<&str>,
80    shell_cwd: Option<&str>,
81    raw: &str,
82) -> Result<String, String> {
83    resolve_tool_path_with_roots(project_root, shell_cwd, raw, &[])
84}
85
86/// Like [`resolve_tool_path`], but also permits paths under any of
87/// `extra_roots` (session-scoped trusted roots from `session.extra_roots`).
88///
89/// An empty `extra_roots` is identical to [`resolve_tool_path`]; this is how
90/// sync tool handlers honor MCP `roots/list` / config `extra_roots` for an
91/// explicit path without widening the global jail (#403).
92pub fn resolve_tool_path_with_roots(
93    project_root: Option<&str>,
94    shell_cwd: Option<&str>,
95    raw: &str,
96    extra_roots: &[String],
97) -> Result<String, String> {
98    resolve_tool_path_refusing(project_root, shell_cwd, raw, extra_roots)
99        .map_err(|refusal| refusal.message)
100}
101
102/// A path that did not resolve: the message the caller returns, and the
103/// boundary class when the I/O boundary refused it (`None` for a path that is
104/// merely missing or malformed). The class is what the audit trail records.
105#[derive(Debug)]
106pub struct PathRefusal {
107    pub message: String,
108    pub class: Option<crate::core::security_events::RefusalClass>,
109}
110
111impl From<String> for PathRefusal {
112    fn from(message: String) -> Self {
113        Self {
114            message,
115            class: None,
116        }
117    }
118}
119
120impl PathRefusal {
121    /// The refusal of the secret-path I/O boundary (enforce mode).
122    pub fn secret_path(message: String) -> Self {
123        Self {
124            message,
125            class: Some(crate::core::security_events::RefusalClass::SecretPath),
126        }
127    }
128
129    /// The refusal of the path jail, classified by
130    /// [`crate::core::security_events::RefusalClass::of_jail_error`].
131    pub fn jail(error: &crate::core::error::PathJailError) -> Self {
132        Self {
133            message: error.to_string(),
134            class: crate::core::security_events::RefusalClass::of_jail_error(error),
135        }
136    }
137}
138
139/// [`resolve_tool_path_with_roots`], saying which boundary refused the path.
140pub fn resolve_tool_path_refusing(
141    project_root: Option<&str>,
142    shell_cwd: Option<&str>,
143    raw: &str,
144    extra_roots: &[String],
145) -> Result<String, PathRefusal> {
146    let normalized = crate::core::pathutil::normalize_tool_path(raw);
147    if normalized.is_empty() || normalized == "." {
148        return Ok(crate::core::workspace_authority::project_dot(
149            &normalized,
150            project_root,
151        )?);
152    }
153
154    let p = Path::new(&normalized);
155    let jail_root = project_root.or(shell_cwd).unwrap_or(".").to_string();
156
157    let resolved: PathBuf = if p.is_absolute() {
158        PathBuf::from(&normalized)
159    } else if let Some(root) = project_root {
160        // #707: a live shell_cwd inside a DIFFERENT git checkout (a worktree
161        // switched into mid-session) outranks the stale project_root — even
162        // when `<project_root>/<path>` exists, because in a worktree it
163        // almost always does (full checkout, same layout, stale content).
164        if let Some(cwd) = shell_cwd
165            && shell_cwd_is_divergent_checkout(root, cwd)
166        {
167            Path::new(cwd).join(&normalized)
168        } else {
169            let joined = Path::new(root).join(&normalized);
170            if joined.exists() {
171                joined
172            } else if let Some(cwd) = shell_cwd {
173                Path::new(cwd).join(&normalized)
174            } else {
175                joined
176            }
177        }
178    } else if let Some(cwd) = shell_cwd {
179        Path::new(cwd).join(&normalized)
180    } else {
181        Path::new(&jail_root).join(&normalized)
182    };
183
184    if project_root.is_none()
185        && let Some(error) = unbound_relative_path(raw, &resolved, shell_cwd.unwrap_or(&jail_root))
186    {
187        return Err(error.into());
188    }
189
190    let jail_root_path = Path::new(&jail_root);
191    let jailed =
192        crate::core::pathjail::jail_path_auto_admitting(&resolved, jail_root_path, extra_roots)
193            .map_err(|e| PathRefusal::jail(&e))?;
194    crate::core::io_boundary::check_secret_path_for_tool("resolve_path", &jailed)
195        .map_err(PathRefusal::secret_path)?;
196
197    Ok(crate::core::pathutil::normalize_tool_path(
198        &jailed.to_string_lossy().replace('\\', "/"),
199    ))
200}
201
202/// Refusal for a relative path that cannot mean anything yet: the session has
203/// no project, the host started lean-ctx in a broad directory (OpenCode
204/// launched from `~`), and nothing exists at `resolved`. Without it agents got
205/// "file not found: ~/src/main.rs" and searched the wrong tree. `None` when the
206/// path is absolute, exists, or `base` is an ordinary working directory.
207pub fn unbound_relative_path(raw: &str, resolved: &Path, base: &str) -> Option<String> {
208    let normalized = crate::core::pathutil::normalize_tool_path(raw);
209    if Path::new(&normalized).is_absolute() || resolved.exists() {
210        return None;
211    }
212    let base = std::path::absolute(base).unwrap_or_else(|_| PathBuf::from(base));
213    if !crate::core::pathutil::is_broad_or_unsafe_root(&base) {
214        return None;
215    }
216    Some(format!(
217        "relative path '{raw}' has no project to resolve against: lean-ctx has no project for \
218         this session yet (the host started it in {}, outside a project). Pass the absolute \
219         path instead, e.g. /home/me/code/my-app/{normalized}; that project then stays the \
220         session project.",
221        base.display()
222    ))
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use std::fs;
229
230    #[test]
231    fn empty_and_dot_pass_through() {
232        assert_eq!(resolve_tool_path(None, None, "").unwrap(), "");
233        assert_eq!(resolve_tool_path(None, None, ".").unwrap(), ".");
234    }
235
236    #[test]
237    fn missing_relative_path_without_a_project_says_what_is_missing() {
238        let _home = crate::core::data_dir::test_env_lock();
239        // A host started outside a project: the home directory, as OpenCode
240        // does when launched from `~` (broad on every platform; `/` is not
241        // broad on Windows, where it means the current drive's root).
242        let home = dirs::home_dir()
243            .expect("home dir")
244            .to_string_lossy()
245            .to_string();
246        let error =
247            resolve_tool_path(None, Some(&home), "src/no_such_file_lc_7f3a.rs").unwrap_err();
248        assert!(
249            error.contains("has no project to resolve against"),
250            "{error}"
251        );
252        assert!(error.contains("absolute path"), "{error}");
253
254        // A relative path in an ordinary working directory keeps resolving there.
255        let dir = tempfile::tempdir().unwrap();
256        let cwd = dir.path().to_string_lossy().to_string();
257        let outcome = resolve_tool_path(None, Some(&cwd), "src/no_such_file_lc.rs");
258        assert!(
259            outcome
260                .as_ref()
261                .err()
262                .is_none_or(|e| !e.contains("no project to resolve against")),
263            "{outcome:?}"
264        );
265    }
266
267    #[test]
268    fn relative_resolves_against_project_root() {
269        let _environment = crate::core::data_dir::test_env_lock();
270        let tmp = std::env::temp_dir().join(format!("lc_pr_{}", std::process::id()));
271        let _ = fs::create_dir_all(&tmp);
272        let file = tmp.join("a.txt");
273        fs::write(&file, "x").unwrap();
274        let root = tmp.to_string_lossy().to_string();
275
276        let out = resolve_tool_path(Some(&root), None, "a.txt").unwrap();
277        assert!(out.ends_with("a.txt"), "got {out}");
278        assert!(out.contains(&root) || Path::new(&out).is_absolute());
279
280        let _ = fs::remove_dir_all(&tmp);
281    }
282
283    #[test]
284    fn falls_back_to_shell_cwd_when_not_in_project_root() {
285        let _environment = crate::core::data_dir::test_env_lock();
286        let base = std::env::temp_dir().join(format!("lc_pr_cwd_{}", std::process::id()));
287        let root = base.join("root");
288        let cwd = base.join("cwd");
289        fs::create_dir_all(&root).unwrap();
290        fs::create_dir_all(&cwd).unwrap();
291        fs::write(cwd.join("only_in_cwd.txt"), "x").unwrap();
292
293        let out = resolve_tool_path(
294            Some(&root.to_string_lossy()),
295            Some(&cwd.to_string_lossy()),
296            "only_in_cwd.txt",
297        );
298        // jail_root is project_root; a file only under shell_cwd resolves to a
299        // cwd-joined path which may be rejected by the jail — either way it must
300        // not panic and must yield a deterministic Result.
301        assert!(out.is_ok() || out.is_err());
302
303        let _ = fs::remove_dir_all(&base);
304    }
305
306    // P0-3 (#415): a relative path that happens to exist in the *process CWD*
307    // must NOT short-circuit resolution. `Cargo.toml` exists in the package
308    // root (cargo test's CWD) but not in this empty project root — before the
309    // fix the CWD probe returned it as-is, now it must resolve into the root.
310    #[test]
311    fn relative_path_never_resolves_against_process_cwd() {
312        let _environment = crate::core::data_dir::test_env_lock();
313        let cwd = std::env::current_dir().unwrap();
314        assert!(
315            cwd.join("Cargo.toml").exists(),
316            "test premise: CWD contains Cargo.toml"
317        );
318
319        let tmp = std::env::temp_dir().join(format!("lc_pr_nocwd_{}", std::process::id()));
320        fs::create_dir_all(&tmp).unwrap();
321        let root = tmp.to_string_lossy().to_string();
322
323        let out = resolve_tool_path(Some(&root), None, "Cargo.toml").unwrap();
324        // Canonicalize BOTH sides before comparing: on macOS temp_dir() is a
325        // symlink (/var → /private/var) and on Windows it may carry 8.3 short
326        // names (RUNNER~1), so comparing raw strings is platform-flaky. The
327        // resolved file itself does not exist, but its parent does — compare
328        // the canonicalized parents.
329        let canonical_root = crate::core::pathjail::canonicalize_or_self(&tmp);
330        let out_parent = crate::core::pathjail::canonicalize_or_self(
331            Path::new(&out)
332                .parent()
333                .expect("resolved path has a parent"),
334        );
335        assert_eq!(
336            out_parent, canonical_root,
337            "resolved {out} must live under the project root, not the process CWD"
338        );
339        let canonical_cwd = crate::core::pathjail::canonicalize_or_self(&cwd);
340        assert_ne!(
341            out_parent, canonical_cwd,
342            "resolved {out} must not resolve against the process CWD"
343        );
344
345        let _ = fs::remove_dir_all(&tmp);
346    }
347
348    // #403: session-scoped extra_roots must thread through to the jail so an
349    // explicit path under a worktree resolves where the bare resolver rejects
350    // it. Asserts only the Ok case (robust against parallel env mutation): with
351    // the jail on, success here is only possible because extra_roots were honored.
352    #[cfg(not(feature = "no-jail"))]
353    #[test]
354    fn extra_roots_thread_through_resolve_tool_path() {
355        let _environment = crate::core::data_dir::test_env_lock();
356        let base = std::env::temp_dir().join(format!("lc_pr_extra_{}", std::process::id()));
357        let root = base.join("root");
358        let worktree = base.join("worktree");
359        fs::create_dir_all(&root).unwrap();
360        fs::create_dir_all(&worktree).unwrap();
361        let file = worktree.join("a.txt");
362        fs::write(&file, "x").unwrap();
363
364        let root_s = root.to_string_lossy().to_string();
365        let file_abs = file.to_string_lossy().to_string();
366        let extra = vec![worktree.to_string_lossy().to_string()];
367
368        let out = resolve_tool_path_with_roots(Some(&root_s), None, &file_abs, &extra);
369        assert!(
370            out.is_ok(),
371            "extra_roots must thread through the resolver: {out:?}"
372        );
373
374        let _ = fs::remove_dir_all(&base);
375    }
376
377    /// #707: the exact Claude Code `EnterWorktree` topology — a full checkout
378    /// with its own `.git` FILE nested under `<repo>/.claude/worktrees/<n>/`.
379    /// The same relative path exists in both trees; the live shell_cwd
380    /// (worktree) must win over the stale project_root copy.
381    #[test]
382    fn worktree_shell_cwd_outranks_stale_project_root_copy() {
383        // Path authorization reads the process-wide protected-runtime profile.
384        // Serialize with tests that temporarily change that profile.
385        let _environment = crate::core::data_dir::test_env_lock();
386        let base = std::env::temp_dir().join(format!("lc_707_nested_{}", std::process::id()));
387        let repo = base.join("repo");
388        let wt = repo.join(".claude").join("worktrees").join("fix-x");
389        fs::create_dir_all(repo.join("src")).unwrap();
390        fs::create_dir_all(repo.join(".git")).unwrap(); // main checkout: .git dir
391        fs::create_dir_all(wt.join("src")).unwrap();
392        fs::write(wt.join(".git"), "gitdir: ../../.git/worktrees/fix-x\n").unwrap(); // worktree: .git FILE
393        fs::write(repo.join("src/scoring.rs"), "stale").unwrap();
394        fs::write(wt.join("src/scoring.rs"), "fresh").unwrap();
395
396        let out = resolve_tool_path(
397            Some(&repo.to_string_lossy()),
398            Some(&wt.to_string_lossy()),
399            "src/scoring.rs",
400        )
401        .unwrap();
402        assert_eq!(
403            fs::read_to_string(&out).unwrap(),
404            "fresh",
405            "must resolve into the worktree, not the stale root: {out}"
406        );
407
408        // A path that does not exist yet (a write target) also lands in the
409        // worktree — writes after the switch must not touch the stale tree.
410        let new = resolve_tool_path(
411            Some(&repo.to_string_lossy()),
412            Some(&wt.to_string_lossy()),
413            "src/new_file.rs",
414        )
415        .unwrap();
416        assert!(
417            new.contains("worktrees"),
418            "write target must land in the worktree: {new}"
419        );
420
421        let _ = fs::remove_dir_all(&base);
422    }
423
424    /// #707 regression guard from the report: `cd rust/` inside the SAME
425    /// checkout (nested `Cargo.toml`, no own `.git`) must NOT count as a
426    /// divergent checkout — project_root resolution stays authoritative.
427    #[test]
428    fn monorepo_subdir_shell_cwd_is_not_a_divergent_checkout() {
429        let _environment = crate::core::data_dir::test_env_lock();
430        let base = std::env::temp_dir().join(format!("lc_707_mono_{}", std::process::id()));
431        let repo = base.join("repo");
432        fs::create_dir_all(repo.join("rust").join("src")).unwrap();
433        fs::create_dir_all(repo.join(".git")).unwrap();
434        fs::write(repo.join("rust/Cargo.toml"), "[package]").unwrap();
435        fs::write(repo.join("rust/src/main.rs"), "root copy").unwrap();
436
437        assert!(!shell_cwd_is_divergent_checkout(
438            &repo.to_string_lossy(),
439            &repo.join("rust").to_string_lossy(),
440        ));
441
442        let out = resolve_tool_path(
443            Some(&repo.to_string_lossy()),
444            Some(&repo.join("rust").to_string_lossy()),
445            "rust/src/main.rs",
446        )
447        .unwrap();
448        assert_eq!(
449            fs::read_to_string(&out).unwrap(),
450            "root copy",
451            "same-checkout cwd must not divert resolution: {out}"
452        );
453
454        let _ = fs::remove_dir_all(&base);
455    }
456
457    /// #707: divergence needs a `.git` boundary on BOTH sides — a cwd with no
458    /// git upward (scratch dir) gives no signal and must not divert.
459    #[test]
460    fn gitless_shell_cwd_gives_no_divergence_signal() {
461        let base = std::env::temp_dir().join(format!("lc_707_gitless_{}", std::process::id()));
462        let repo = base.join("repo");
463        let scratch = base.join("scratch");
464        fs::create_dir_all(repo.join(".git")).unwrap();
465        fs::create_dir_all(&scratch).unwrap();
466        fs::write(repo.join("a.txt"), "root").unwrap();
467
468        assert!(!shell_cwd_is_divergent_checkout(
469            &repo.to_string_lossy(),
470            &scratch.to_string_lossy(),
471        ));
472
473        let _ = fs::remove_dir_all(&base);
474    }
475
476    #[test]
477    fn tool_context_shape_project_root_only() {
478        let _environment = crate::core::data_dir::test_env_lock();
479        // Mirrors ToolContext::resolve_path_sync (shell_cwd = None).
480        let tmp = std::env::temp_dir().join(format!("lc_pr_ctx_{}", std::process::id()));
481        fs::create_dir_all(&tmp).unwrap();
482        let root = tmp.to_string_lossy().to_string();
483        let out = resolve_tool_path(Some(&root), None, "missing.rs").unwrap();
484        assert!(out.ends_with("missing.rs"), "got {out}");
485        let _ = fs::remove_dir_all(&tmp);
486    }
487
488    // GH #397: on Unix an absolute path under a single-letter root (`/c/…`)
489    // was rewritten to `C:/…`, which `Path::is_absolute()` rejects on Unix —
490    // the path was then re-joined under the (also-translated) project root,
491    // producing the doubled `C:/root/C:/root/file` form from the report.
492    // `/c` cannot be created in this test environment, so the jail may still
493    // reject the path as nonexistent — the regression assertion is that no
494    // `C:/` drive form appears anywhere in the outcome (Ok or Err).
495    #[cfg(not(windows))]
496    #[test]
497    fn single_letter_root_is_never_drive_translated_on_unix() {
498        let _environment = crate::core::data_dir::test_env_lock();
499        for raw in ["/c/Users/me/proj/src/app.ts", "src/app.ts"] {
500            let rendered = match resolve_tool_path(Some("/c/Users/me/proj"), None, raw) {
501                Ok(p) => p,
502                Err(e) => e,
503            };
504            assert!(
505                !rendered.contains("C:/"),
506                "drive translation must not run on unix hosts (raw={raw}): {rendered}"
507            );
508        }
509    }
510
511    // What the audit trail records for a refused path: the boundary's class,
512    // and none for a path that merely does not resolve.
513    #[test]
514    fn a_refusal_names_the_boundary_that_refused() {
515        use crate::core::security_events::RefusalClass;
516        let _environment = crate::core::data_dir::test_env_lock();
517        let tmp = tempfile::tempdir().unwrap();
518        let root = tmp.path().canonicalize().unwrap();
519        fs::write(root.join(".env"), "TOKEN=x").unwrap();
520        let root = root.to_string_lossy().into_owned();
521        let prev = std::env::var("LEAN_CTX_IO_BOUNDARY_MODE").ok();
522        crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", "enforce");
523        let secret = resolve_tool_path_refusing(Some(&root), None, ".env", &[]);
524        let outside =
525            resolve_tool_path_refusing(Some(&root), None, &format!("{root}/../outside.txt"), &[]);
526        match prev {
527            Some(v) => crate::test_env::set_var("LEAN_CTX_IO_BOUNDARY_MODE", v),
528            None => crate::test_env::remove_var("LEAN_CTX_IO_BOUNDARY_MODE"),
529        }
530        let secret = secret.expect_err("enforce mode refuses a secret path");
531        assert_eq!(secret.class, Some(RefusalClass::SecretPath));
532        assert!(
533            secret.message.contains("Secret-like path"),
534            "{}",
535            secret.message
536        );
537        if !cfg!(feature = "no-jail") {
538            let outside = outside.expect_err("the jail refuses a path outside the root");
539            assert_eq!(outside.class, Some(RefusalClass::OutsideRoot));
540        }
541        let unbound = resolve_tool_path_refusing(None, None, "src/no_such_file_lc_9c1.rs", &[]);
542        if let Err(refusal) = unbound {
543            assert_eq!(refusal.class, None, "{}", refusal.message);
544        }
545    }
546}