Skip to main content

lean_ctx/server/
multi_path.rs

1use serde_json::{Map, Value};
2
3use crate::server::tool_trait::{ToolContext, get_str, get_str_array};
4
5#[derive(Debug)]
6pub struct ResolvedPaths {
7    pub roots: Vec<String>,
8    pub is_multi: bool,
9}
10
11/// Resolve tool paths with multi-root support.
12///
13/// Priority:
14/// 0. `repo` argument (multi-repo alias → specific root)
15/// 1. `paths` array argument (explicit multi-root)
16/// 2. `path` string argument (single root, pre-resolved by dispatch)
17/// 3. Session `extra_roots` (default multi-root from config/MCP)
18/// 4. Fallback to `"."` (project root)
19///
20/// Returns `Err` when an **explicit** `path`/`paths` argument was supplied but
21/// could not be resolved (outside the project root, secret-screened, or
22/// non-existent). Silently falling back to the project root in that case made
23/// `ctx_tree path=/outside/repo` return the whole project tree (#401).
24pub fn resolve_tool_paths(
25    args: &Map<String, Value>,
26    ctx: &ToolContext,
27) -> Result<ResolvedPaths, String> {
28    if let Some(repo) = get_str(args, "repo")
29        && let Some(root) = crate::core::multi_repo::resolve_repo_root(&repo)
30    {
31        return Ok(ResolvedPaths {
32            roots: vec![root],
33            is_multi: false,
34        });
35    }
36
37    if let Some(paths) = get_str_array(args, "paths")
38        && !paths.is_empty()
39    {
40        let resolved = resolve_paths_sync(ctx, &paths);
41        if !resolved.is_empty() {
42            return Ok(ResolvedPaths {
43                is_multi: resolved.len() > 1,
44                roots: resolved,
45            });
46        }
47        // The caller explicitly listed paths but none resolved — surface
48        // the failure instead of scanning the project root (#401).
49        return Err(format!(
50            "none of the requested paths could be resolved — they may not exist or are \
51                 outside the project root: {}",
52            paths.join(", ")
53        ));
54    }
55
56    // #846: `file_path` is an alias for `path` in ctx_search — agents
57    // (especially Claude Code) send it expecting file-level scoping.
58    // Without this fallback the parameter is silently ignored and the
59    // search runs on the project root.
60    for key in &["path", "file_path"] {
61        if let Some(p) = ctx.resolved_path(key) {
62            return Ok(ResolvedPaths {
63                roots: vec![p.to_string()],
64                is_multi: false,
65            });
66        }
67        // An explicit path the dispatcher could not resolve lands in
68        // `path_errors`. Do NOT fall back to the project root — return the
69        // resolution error so the agent learns the path is out of scope
70        // rather than silently receiving an unrelated tree (#401).
71        if let Some(detail) = ctx.path_error(key) {
72            return Err(detail.to_string());
73        }
74    }
75
76    if let Some(session_lock) = ctx.session.as_ref() {
77        let (extra, jail_root) = {
78            let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
79            let guard = loop {
80                if let Ok(g) = session_lock.clone().try_read_owned() {
81                    break Some(g);
82                }
83                if std::time::Instant::now() >= deadline {
84                    break None;
85                }
86                std::thread::sleep(std::time::Duration::from_millis(25));
87            };
88            match guard {
89                Some(session) => {
90                    let root = session
91                        .project_root
92                        .clone()
93                        .unwrap_or_else(|| ".".to_string());
94                    (session.extra_roots.clone(), root)
95                }
96                None => (Vec::new(), ".".to_string()),
97            }
98        };
99        if !extra.is_empty() {
100            let jail = std::path::Path::new(&jail_root);
101            let mut roots = vec![ctx.project_root.clone()];
102            for r in &extra {
103                let p = std::path::Path::new(r);
104                if !p.is_dir() {
105                    continue;
106                }
107                match crate::core::pathjail::jail_path(p, jail) {
108                    Ok(_) => roots.push(r.clone()),
109                    Err(e) => tracing::warn!("extra_root rejected by PathJail: {e}"),
110                }
111            }
112            if roots.len() > 1 {
113                return Ok(ResolvedPaths {
114                    is_multi: true,
115                    roots,
116                });
117            }
118        }
119    }
120
121    Ok(ResolvedPaths {
122        roots: vec![".".to_string()],
123        is_multi: false,
124    })
125}
126
127fn resolve_paths_sync(ctx: &ToolContext, raw: &[String]) -> Vec<String> {
128    let mut out = Vec::with_capacity(raw.len());
129    for p in raw {
130        match ctx.resolve_path_sync(p) {
131            Ok(resolved) => out.push(resolved),
132            Err(e) => {
133                tracing::warn!("multi-path resolve failed for {p}: {e}");
134            }
135        }
136    }
137    out
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use serde_json::json;
144
145    fn test_ctx() -> ToolContext {
146        ToolContext {
147            project_root: "/test/project".to_string(),
148            extra_roots: Vec::new(),
149            minimal: false,
150            resolved_paths: std::collections::HashMap::new(),
151            crp_mode: crate::tools::CrpMode::Off,
152            cache: None,
153            session: None,
154            tool_calls: None,
155            agent_id: None,
156            workflow: None,
157            ledger: None,
158            client_name: None,
159            client_role: None,
160            shell_access: None,
161            pipeline_stats: None,
162            call_count: None,
163            autonomy: None,
164            pressure_snapshot: None,
165            path_errors: std::collections::HashMap::new(),
166            bm25_cache: None,
167            progress_sender: None,
168        }
169    }
170
171    #[test]
172    fn fallback_to_dot_when_nothing_set() {
173        let args = Map::new();
174        let ctx = test_ctx();
175        let result = resolve_tool_paths(&args, &ctx).expect("no explicit path → default");
176        assert_eq!(result.roots, vec!["."]);
177        assert!(!result.is_multi);
178    }
179
180    #[test]
181    fn uses_resolved_path_when_present() {
182        let args = Map::new();
183        let mut ctx = test_ctx();
184        ctx.resolved_paths
185            .insert("path".to_string(), "/resolved/dir".to_string());
186        let result = resolve_tool_paths(&args, &ctx).expect("resolved path");
187        assert_eq!(result.roots, vec!["/resolved/dir"]);
188        assert!(!result.is_multi);
189    }
190
191    #[test]
192    fn empty_paths_array_falls_back() {
193        let mut args = Map::new();
194        args.insert("paths".to_string(), json!([]));
195        let mut ctx = test_ctx();
196        ctx.resolved_paths
197            .insert("path".to_string(), "/fallback".to_string());
198        let result = resolve_tool_paths(&args, &ctx).expect("empty paths → fallback");
199        assert_eq!(result.roots, vec!["/fallback"]);
200        assert!(!result.is_multi);
201    }
202
203    // #401: an explicit `path` the dispatcher could not resolve (out of jail,
204    // secret-screened, non-existent) must surface the error — NOT silently
205    // fall back to the project root and return an unrelated tree.
206    #[test]
207    fn explicit_unresolvable_path_errors_instead_of_root_fallback() {
208        let mut args = Map::new();
209        args.insert(
210            "path".to_string(),
211            json!("/home/jules/.claude/skills/mpm-config"),
212        );
213        let mut ctx = test_ctx();
214        // Dispatcher could not resolve it → recorded in path_errors, absent
215        // from resolved_paths (exactly what the daemon does for out-of-jail).
216        ctx.path_errors.insert(
217            "path".to_string(),
218            "path escapes project root: /home/jules/.claude/skills/mpm-config \
219             (root: /test/project)"
220                .to_string(),
221        );
222        let err = resolve_tool_paths(&args, &ctx)
223            .expect_err("out-of-jail explicit path must be an error");
224        assert!(
225            err.contains("escapes project root"),
226            "error must explain the path is out of scope: {err}"
227        );
228    }
229
230    // #401: an explicit `paths` array where nothing resolves must error too.
231    //
232    // Uses *real* directories so the PathJail decision is deterministic across
233    // platforms. Canonicalizing non-existent paths is OS-dependent — the first
234    // version of this test fed bogus absolute paths against a non-existent root
235    // and passed on macOS while letting them through on Linux CI.
236    //
237    // Asserts a jail-enforcement invariant, so it only holds when the jail is
238    // compiled in. Under `--features no-jail` (pulled in by `--all-features`) the
239    // jail is intentionally disabled and out-of-root paths resolve, so skip it.
240    #[cfg(not(feature = "no-jail"))]
241    #[test]
242    fn explicit_unresolvable_paths_array_errors() {
243        let base = tempfile::tempdir().unwrap();
244        let root = base.path().join("project");
245        let outside = base.path().join("outside");
246        std::fs::create_dir_all(&root).unwrap();
247        std::fs::create_dir_all(&outside).unwrap();
248
249        let mut ctx = test_ctx();
250        ctx.project_root = root.to_string_lossy().into_owned();
251
252        let mut args = Map::new();
253        args.insert(
254            "paths".to_string(),
255            json!([outside.to_string_lossy().into_owned()]),
256        );
257        let err = resolve_tool_paths(&args, &ctx)
258            .expect_err("a path outside the project root must be an error");
259        assert!(
260            err.contains("none of the requested paths"),
261            "error must report the unresolved paths: {err}"
262        );
263    }
264}