Skip to main content

lean_ctx/server/
multi_path.rs

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