Skip to main content

lean_ctx/core/mcp_roots/
derive.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Project-root derivation from a working directory: the rule an MCP session
3//! and the dashboard use to bind a folder to one project. Moved down from
4//! `server/mod.rs`.
5
6use std::path::Path;
7
8use super::has_project_marker;
9
10fn git_toplevel_from(dir: &Path) -> Option<String> {
11    std::process::Command::new("git")
12        .args(["rev-parse", "--show-toplevel"])
13        .current_dir(dir)
14        .stdout(std::process::Stdio::piped())
15        .stderr(std::process::Stdio::null())
16        .output()
17        .ok()
18        .and_then(|o| {
19            if o.status.success() {
20                String::from_utf8(o.stdout)
21                    .ok()
22                    .map(|s| s.trim().to_string())
23            } else {
24                None
25            }
26        })
27}
28
29pub fn derive_project_root_from_cwd() -> Option<String> {
30    derive_project_root_from(&std::env::current_dir().ok()?)
31}
32
33/// The project an MCP session started in `cwd` binds to. The dashboard binds
34/// a folder by the same rule, so both name one project the same way.
35pub fn derive_project_root_from(cwd: &Path) -> Option<String> {
36    let canonical = crate::core::pathutil::safe_canonicalize_or_self(cwd);
37
38    if crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
39        return git_toplevel_from(&canonical);
40    }
41
42    if has_project_marker(&canonical) {
43        return Some(canonical.to_string_lossy().to_string());
44    }
45
46    if let Some(git_root) = git_toplevel_from(&canonical) {
47        return Some(git_root);
48    }
49
50    if let Some(root) = detect_multi_root_workspace(&canonical) {
51        return Some(root);
52    }
53
54    // Fallback: use CWD as project root if it's a specific, safe directory.
55    // This ensures bare directories (no .git, no markers) still work.
56    // Guard: reject home dir, filesystem root, and agent sandbox dirs.
57    if !crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
58        tracing::info!(
59            "No project markers found — using CWD as project root: {}",
60            canonical.display()
61        );
62        return Some(canonical.to_string_lossy().to_string());
63    }
64
65    None
66}
67
68/// Detect a multi-root workspace: a directory that has no project markers
69/// itself, but contains child directories that do. In this case, use the
70/// parent as jail root and auto-allow all child projects via LEAN_CTX_ALLOW_PATH.
71fn detect_multi_root_workspace(dir: &Path) -> Option<String> {
72    // Never enumerate broad roots or macOS TCC-protected dirs: read_dir there
73    // can scan an entire user profile or trigger a macOS privacy prompt.
74    if crate::core::pathutil::is_broad_or_unsafe_root(dir)
75        || crate::core::pathutil::is_tcc_sensitive_home_dir(dir)
76    {
77        return None;
78    }
79    let entries = std::fs::read_dir(dir).ok()?;
80    let mut child_projects: Vec<String> = Vec::new();
81
82    for entry in entries.flatten() {
83        let path = entry.path();
84        if path.is_dir() && has_project_marker(&path) {
85            let canonical = crate::core::pathutil::safe_canonicalize_or_self(&path);
86            child_projects.push(canonical.to_string_lossy().to_string());
87        }
88    }
89
90    if child_projects.len() >= 2 {
91        crate::core::runtime_flags::add_allow_paths(
92            child_projects
93                .iter()
94                .map(std::path::PathBuf::from)
95                .collect(),
96        );
97        tracing::info!(
98            "Multi-root workspace detected at {}: auto-allowing {} child projects",
99            dir.display(),
100            child_projects.len()
101        );
102        return Some(dir.to_string_lossy().to_string());
103    }
104
105    None
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn project_markers_detected() {
114        let tmp = tempfile::tempdir().unwrap();
115        let root = tmp.path().join("myproject");
116        std::fs::create_dir_all(&root).unwrap();
117        assert!(!has_project_marker(&root));
118
119        std::fs::create_dir(root.join(".git")).unwrap();
120        assert!(has_project_marker(&root));
121    }
122
123    #[test]
124    fn detect_multi_root_workspace_with_child_projects() {
125        // detect_multi_root_workspace adds the child projects to the
126        // process-wide runtime allow-list (`runtime_flags::add_allow_paths`),
127        // which is never cleared and makes every later jail test in the same
128        // process see a relaxation (#695). Run it in its own process.
129        if crate::test_env::run_in_own_process(
130            "core::mcp_roots::derive::tests::detect_multi_root_workspace_with_child_projects",
131            &[],
132        ) {
133            return;
134        }
135        let _guard = crate::core::data_dir::test_env_lock();
136        let tmp = tempfile::tempdir().unwrap();
137        let workspace = tmp.path().join("workspace");
138        std::fs::create_dir_all(&workspace).unwrap();
139
140        let proj_a = workspace.join("project-a");
141        let proj_b = workspace.join("project-b");
142        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
143        std::fs::create_dir_all(&proj_b).unwrap();
144        std::fs::write(proj_b.join("package.json"), "{}").unwrap();
145
146        let result = detect_multi_root_workspace(&workspace);
147        assert!(
148            result.is_some(),
149            "should detect workspace with 2 child projects"
150        );
151
152        crate::test_env::remove_var("LEAN_CTX_ALLOW_PATH");
153    }
154
155    #[test]
156    fn detect_multi_root_workspace_returns_none_for_single_project() {
157        let tmp = tempfile::tempdir().unwrap();
158        let workspace = tmp.path().join("workspace");
159        std::fs::create_dir_all(&workspace).unwrap();
160
161        let proj_a = workspace.join("project-a");
162        std::fs::create_dir_all(proj_a.join(".git")).unwrap();
163
164        let result = detect_multi_root_workspace(&workspace);
165        assert!(
166            result.is_none(),
167            "should not detect workspace with only 1 child project"
168        );
169    }
170
171    #[test]
172    fn derive_project_root_falls_back_to_bare_cwd() {
173        let tmp = tempfile::tempdir().unwrap();
174        let bare = tmp.path().join("bare-dir");
175        std::fs::create_dir_all(&bare).unwrap();
176
177        let result = {
178            let _cwd = crate::test_env::CurrentDirGuard::enter(&bare);
179            derive_project_root_from_cwd()
180        };
181
182        assert!(result.is_some(), "bare dir should produce a project root");
183        let root = result.unwrap();
184        assert!(
185            root.contains("bare-dir"),
186            "fallback should use the bare dir path"
187        );
188    }
189}