Skip to main content

lean_ctx_server/server/
multi_path.rs

1// SPDX-License-Identifier: Apache-2.0
2use serde_json::{Map, Value};
3
4use crate::server::tool_trait::{ToolContext, get_str, get_str_array};
5
6/// Refusal for a walk without `path` while the session has no project: the
7/// host started lean-ctx in a broad directory and reported none.
8const NO_PROJECT_GUIDANCE: &str = "lean-ctx has no project for this session yet: the host \
9     started it outside a project (often the home directory) and reported no project \
10     directory. Pass the absolute path of the project directory as `path`, e.g. \
11     path=\"/home/me/code/my-app\"; any path inside a project then stays the session project.";
12
13#[derive(Debug)]
14pub struct ResolvedPaths {
15    pub roots: Vec<String>,
16    pub is_multi: bool,
17    /// Set when a too-broad root was narrowed to the session project; the
18    /// tool puts it in front of its output so the agent sees the scope.
19    pub note: Option<String>,
20}
21
22impl ResolvedPaths {
23    fn single(root: String) -> Self {
24        Self {
25            roots: vec![root],
26            is_multi: false,
27            note: None,
28        }
29    }
30
31    /// Prefixes the scope note, if any, to a tool's output text.
32    pub fn annotate(
33        &self,
34        mut output: crate::server::tool_trait::ToolOutput,
35    ) -> crate::server::tool_trait::ToolOutput {
36        if let Some(note) = &self.note {
37            output.text = format!("{note}\n{}", output.text);
38        }
39        output
40    }
41}
42
43/// Walks over a broad root (`~`, `/`, `~/Documents`) never run: narrow them to
44/// the session project when it lies inside, otherwise refuse with guidance
45/// the agent can act on. `requested` is what the caller asked for (before
46/// jail resolution), `root` what it resolved to, if anything.
47fn guard_broad_root(
48    requested: &str,
49    root: Option<String>,
50    ctx: &ToolContext,
51) -> Option<Result<ResolvedPaths, String>> {
52    let probe = root.as_deref().unwrap_or(requested);
53    if let Some((project, note)) =
54        crate::tools::walk_guard::narrow_to_project(probe, &ctx.project_root)
55    {
56        return Some(Ok(ResolvedPaths {
57            note: Some(note),
58            ..ResolvedPaths::single(project)
59        }));
60    }
61    let root = root?;
62    if crate::tools::walk_guard::deny_unsafe_walk_root(&root).is_some() {
63        return Some(Err(crate::tools::walk_guard::broad_root_guidance(
64            requested,
65            &ctx.project_root,
66        )));
67    }
68    None
69}
70
71/// Resolve tool paths with multi-root support.
72///
73/// Priority:
74/// 0. `repo` argument (multi-repo alias → specific root)
75/// 1. `paths` array argument (explicit multi-root)
76/// 2. `path` string argument (single root, pre-resolved by dispatch)
77/// 3. Session `extra_roots` (default multi-root from config/MCP)
78/// 4. Resolve the active project root (never the process working directory)
79///
80/// Returns `Err` when an **explicit** `path`/`paths` argument was supplied but
81/// could not be resolved (outside the project root, secret-screened, or
82/// non-existent). Silently falling back to the project root in that case made
83/// `ctx_tree path=/outside/repo` return the whole project tree (#401).
84pub fn resolve_tool_paths(
85    args: &Map<String, Value>,
86    ctx: &ToolContext,
87) -> Result<ResolvedPaths, String> {
88    if let Some(repo) = get_str(args, "repo")
89        && let Some(root) = lean_ctx::core::multi_repo::resolve_repo_root(&repo)
90    {
91        return Ok(ResolvedPaths::single(root));
92    }
93
94    if let Some(paths) = get_str_array(args, "paths")
95        && !paths.is_empty()
96    {
97        let resolved = resolve_paths_sync(ctx, &paths);
98        if !resolved.is_empty() {
99            return Ok(ResolvedPaths {
100                is_multi: resolved.len() > 1,
101                roots: resolved,
102                note: None,
103            });
104        }
105        // The caller explicitly listed paths but none resolved — surface
106        // the failure instead of scanning the project root (#401).
107        return Err(format!(
108            "none of the requested paths could be resolved — they may not exist or are \
109                 outside the project root: {}",
110            paths.join(", ")
111        ));
112    }
113
114    // #846: `file_path` is an alias for `path` in ctx_search — agents
115    // (especially Claude Code) send it expecting file-level scoping.
116    // Without this fallback the parameter is silently ignored and the
117    // search runs on the project root.
118    for key in &["path", "file_path"] {
119        let requested = get_str(args, key).unwrap_or_default();
120        if let Some(p) = ctx.resolved_path(key) {
121            if let Some(guarded) = guard_broad_root(&requested, Some(p.to_string()), ctx) {
122                return guarded;
123            }
124            return Ok(ResolvedPaths::single(p.to_string()));
125        }
126        // An explicit path the dispatcher could not resolve lands in
127        // `path_errors`. Do NOT fall back to the project root — return the
128        // resolution error so the agent learns the path is out of scope
129        // rather than silently receiving an unrelated tree (#401). The one
130        // exception is an ancestor of the session project (`~`, `/`): the
131        // agent asked for a superset, so the project is the right answer.
132        if let Some(detail) = ctx.path_error(key) {
133            if !requested.is_empty()
134                && let Some(Ok(narrowed)) = guard_broad_root(&requested, None, ctx)
135            {
136                return Ok(narrowed);
137            }
138            return Err(detail.to_string());
139        }
140    }
141
142    // Validate before extra roots can return a default scope. An empty or
143    // relative primary must never be interpreted against the process CWD.
144    // The refusal says how to recover: hosts that start lean-ctx in the home
145    // directory (OpenCode launched from `~`) hit it on the first call of a
146    // session, and a bare "root is required" left agents repeating the call.
147    if ctx.project_root.trim().is_empty() {
148        return Err(NO_PROJECT_GUIDANCE.to_string());
149    }
150    if !std::path::Path::new(&ctx.project_root).is_absolute() {
151        return Err(NO_PROJECT_GUIDANCE.to_string());
152    }
153
154    if let Some(session_lock) = ctx.session.as_ref() {
155        let (extra, jail_root) = {
156            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
157            let guard = loop {
158                if let Ok(g) = session_lock.clone().try_read_owned() {
159                    break Some(g);
160                }
161                if std::time::Instant::now() >= deadline {
162                    break None;
163                }
164                std::thread::sleep(std::time::Duration::from_millis(25));
165            };
166            match guard {
167                Some(session) => {
168                    let root = session
169                        .project_root
170                        .clone()
171                        .unwrap_or_else(|| ".".to_string());
172                    (session.extra_roots.clone(), root)
173                }
174                None => (Vec::new(), ".".to_string()),
175            }
176        };
177        if !extra.is_empty() {
178            let jail = std::path::Path::new(&jail_root);
179            let mut roots = vec![ctx.project_root.clone()];
180            for r in &extra {
181                let p = std::path::Path::new(r);
182                if !p.is_dir() {
183                    continue;
184                }
185                match lean_ctx::core::pathjail::jail_path(p, jail) {
186                    Ok(_) => roots.push(r.clone()),
187                    Err(e) => tracing::warn!("extra_root rejected by PathJail: {e}"),
188                }
189            }
190            if roots.len() > 1 {
191                return Ok(ResolvedPaths {
192                    is_multi: true,
193                    roots,
194                    note: None,
195                });
196            }
197        }
198    }
199
200    let root = ctx.resolve_path_sync(&ctx.project_root)?;
201    if let Some(guarded) = guard_broad_root(&ctx.project_root, Some(root.clone()), ctx) {
202        return guarded;
203    }
204    Ok(ResolvedPaths::single(root))
205}
206
207fn resolve_paths_sync(ctx: &ToolContext, raw: &[String]) -> Vec<String> {
208    let mut out = Vec::with_capacity(raw.len());
209    for p in raw {
210        match ctx.resolve_path_sync(p) {
211            Ok(resolved) => out.push(resolved),
212            Err(e) => {
213                tracing::warn!("multi-path resolve failed for {p}: {e}");
214            }
215        }
216    }
217    out
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use serde_json::json;
224
225    fn test_ctx() -> ToolContext {
226        ToolContext {
227            project_root: "/test/project".to_string(),
228            extra_roots: Vec::new(),
229            minimal: false,
230            resolved_paths: std::collections::HashMap::new(),
231            crp_mode: crate::tools::CrpMode::Off,
232            cache: None,
233            session: None,
234            tool_calls: None,
235            agent_id: None,
236            workflow: None,
237            ledger: None,
238            client_name: None,
239            client_role: None,
240            shell_access: None,
241            pipeline_stats: None,
242            call_count: None,
243            autonomy: None,
244            path_errors: std::collections::HashMap::new(),
245            bm25_cache: None,
246            progress_sender: None,
247            cancel: None,
248        }
249    }
250
251    #[test]
252    fn fallback_uses_context_project_not_process_cwd() {
253        let args = Map::new();
254        let project = tempfile::tempdir().unwrap();
255        let root = project.path().canonicalize().unwrap();
256        assert_ne!(
257            root,
258            std::env::current_dir().unwrap().canonicalize().unwrap()
259        );
260        let mut ctx = test_ctx();
261        ctx.project_root = root.to_string_lossy().into_owned();
262        let result = resolve_tool_paths(&args, &ctx).expect("no explicit path → default");
263        assert_eq!(result.roots.len(), 1);
264        assert_eq!(
265            std::fs::canonicalize(&result.roots[0]).expect("resolved project root"),
266            std::fs::canonicalize(root).expect("canonical context project root"),
267            "fallback must resolve to the context project, independent of path spelling"
268        );
269        assert!(!result.is_multi);
270    }
271
272    #[test]
273    fn missing_context_root_does_not_search_process_cwd() {
274        let mut ctx = test_ctx();
275        ctx.project_root.clear();
276        let error = resolve_tool_paths(&Map::new(), &ctx).unwrap_err();
277        assert!(error.contains("no project for this session"), "{error}");
278        assert!(
279            error.contains("absolute path of the project directory"),
280            "{error}"
281        );
282    }
283
284    #[test]
285    fn relative_context_root_does_not_search_process_cwd() {
286        for root in [".", "relative-project"] {
287            let mut ctx = test_ctx();
288            ctx.project_root = root.to_string();
289            assert!(
290                resolve_tool_paths(&Map::new(), &ctx).is_err(),
291                "root: {root}"
292            );
293        }
294    }
295
296    #[test]
297    fn extra_roots_cannot_bypass_invalid_primary_root() {
298        let _data = lean_ctx::core::data_dir::isolated_data_dir();
299        let extra = tempfile::tempdir().unwrap();
300        let extra_root = extra
301            .path()
302            .canonicalize()
303            .unwrap()
304            .to_string_lossy()
305            .into_owned();
306        let mut session = lean_ctx::core::session::SessionState::new();
307        session.project_root = Some(extra_root.clone());
308        session.extra_roots = vec![extra_root.clone()];
309        let mut ctx = test_ctx();
310        ctx.session = Some(std::sync::Arc::new(tokio::sync::RwLock::new(session)));
311        for root in ["", ".", "relative-project"] {
312            ctx.project_root = root.to_string();
313            assert!(
314                resolve_tool_paths(&Map::new(), &ctx).is_err(),
315                "root: {root}"
316            );
317        }
318        // A valid absolute primary still permits the existing multi-root path.
319        ctx.project_root = extra_root;
320        let resolved = resolve_tool_paths(&Map::new(), &ctx).unwrap();
321        assert!(resolved.is_multi);
322        assert!(resolved.roots.iter().all(|root| root == &ctx.project_root));
323    }
324
325    // An agent that asks for `/` or `~` wants a superset of its project:
326    // answer with the project and say so, instead of a refusal it repeats.
327    #[test]
328    fn broad_explicit_path_narrows_to_the_session_project() {
329        let project = tempfile::tempdir().unwrap();
330        std::fs::create_dir_all(project.path().join(".git")).unwrap();
331        let root = lean_ctx::core::pathutil::safe_canonicalize(project.path()).unwrap();
332        // The project's own filesystem root: `/`, or `C:\` on Windows.
333        let fs_root = root
334            .ancestors()
335            .last()
336            .unwrap()
337            .to_string_lossy()
338            .into_owned();
339        let mut ctx = test_ctx();
340        ctx.project_root = root.to_string_lossy().into_owned();
341        ctx.path_errors.insert(
342            "path".to_string(),
343            format!("path escapes project root: {fs_root} (root: …)"),
344        );
345        let mut args = Map::new();
346        args.insert("path".to_string(), json!(fs_root));
347        let resolved = resolve_tool_paths(&args, &ctx).expect("narrowed, not refused");
348        assert_eq!(std::path::Path::new(&resolved.roots[0]), root);
349        assert!(
350            resolved
351                .note
352                .as_deref()
353                .is_some_and(|n| n.contains("too broad"))
354        );
355    }
356
357    #[test]
358    fn broad_path_without_a_project_explains_how_to_bind_one() {
359        let mut ctx = test_ctx();
360        ctx.project_root.clear();
361        ctx.resolved_paths
362            .insert("path".to_string(), "/".to_string());
363        let mut args = Map::new();
364        args.insert("path".to_string(), json!("/"));
365        let err = resolve_tool_paths(&args, &ctx).expect_err("nothing to narrow to");
366        assert!(err.contains("not bound this session"), "{err}");
367    }
368
369    #[test]
370    fn uses_resolved_path_when_present() {
371        let args = Map::new();
372        let mut ctx = test_ctx();
373        ctx.resolved_paths
374            .insert("path".to_string(), "/resolved/dir".to_string());
375        let result = resolve_tool_paths(&args, &ctx).expect("resolved path");
376        assert_eq!(result.roots, vec!["/resolved/dir"]);
377        assert!(!result.is_multi);
378    }
379
380    #[test]
381    fn empty_paths_array_falls_back() {
382        let mut args = Map::new();
383        args.insert("paths".to_string(), json!([]));
384        let mut ctx = test_ctx();
385        ctx.resolved_paths
386            .insert("path".to_string(), "/fallback".to_string());
387        let result = resolve_tool_paths(&args, &ctx).expect("empty paths → fallback");
388        assert_eq!(result.roots, vec!["/fallback"]);
389        assert!(!result.is_multi);
390    }
391
392    // #401: an explicit `path` the dispatcher could not resolve (out of jail,
393    // secret-screened, non-existent) must surface the error — NOT silently
394    // fall back to the project root and return an unrelated tree.
395    #[test]
396    fn explicit_unresolvable_path_errors_instead_of_root_fallback() {
397        let mut args = Map::new();
398        args.insert(
399            "path".to_string(),
400            json!("/home/jules/.claude/skills/mpm-config"),
401        );
402        let mut ctx = test_ctx();
403        // Dispatcher could not resolve it → recorded in path_errors, absent
404        // from resolved_paths (exactly what the daemon does for out-of-jail).
405        ctx.path_errors.insert(
406            "path".to_string(),
407            "path escapes project root: /home/jules/.claude/skills/mpm-config \
408             (root: /test/project)"
409                .to_string(),
410        );
411        let err = resolve_tool_paths(&args, &ctx)
412            .expect_err("out-of-jail explicit path must be an error");
413        assert!(
414            err.contains("escapes project root"),
415            "error must explain the path is out of scope: {err}"
416        );
417    }
418
419    // #401: an explicit `paths` array where nothing resolves must error too.
420    //
421    // Uses *real* directories so the PathJail decision is deterministic across
422    // platforms. Canonicalizing non-existent paths is OS-dependent — the first
423    // version of this test fed bogus absolute paths against a non-existent root
424    // and passed on macOS while letting them through on Linux CI.
425    //
426    // Asserts a jail-enforcement invariant, so it only holds when the jail is
427    // compiled in. Under `--features no-jail` (pulled in by `--all-features`) the
428    // jail is intentionally disabled and out-of-root paths resolve, so skip it.
429    #[cfg(not(feature = "no-jail"))]
430    #[test]
431    fn explicit_unresolvable_paths_array_errors() {
432        let base = tempfile::tempdir().unwrap();
433        let root = base.path().join("project");
434        let outside = base.path().join("outside");
435        std::fs::create_dir_all(&root).unwrap();
436        std::fs::create_dir_all(&outside).unwrap();
437
438        let mut ctx = test_ctx();
439        ctx.project_root = root.to_string_lossy().into_owned();
440
441        let mut args = Map::new();
442        args.insert(
443            "paths".to_string(),
444            json!([outside.to_string_lossy().into_owned()]),
445        );
446        let err = resolve_tool_paths(&args, &ctx)
447            .expect_err("a path outside the project root must be an error");
448        assert!(
449            err.contains("none of the requested paths"),
450            "error must report the unresolved paths: {err}"
451        );
452    }
453}