1use serde_json::{Map, Value};
3
4use crate::server::tool_trait::{ToolContext, get_str, get_str_array};
5
6const 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 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 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
43fn 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
71pub 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 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 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 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 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 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 #[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 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 #[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 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 #[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}