1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
//! Shared root guard for the user-facing directory walks
//! (`ctx_search`, `ctx_tree`, `ctx_glob`).
//!
//! The MCP server process is often spawned by the editor with `cwd == $HOME`
//! (Cursor starts user-level servers from the home directory). A tool call
//! whose `path` falls back to `"."` would then walk the **entire home
//! directory** — on macOS every `stat` under `~/Library`, `~/Desktop`,
//! `~/Pictures`, … fires a TCC privacy prompt (the #356 class), and on
//! Windows it would hydrate cloud placeholders (#363).
//!
//! The graph/BM25/search-index builders already refuse such roots via
//! `is_safe_scan_root_public`; this module applies the same policy to the
//! direct walk fallbacks. Relative paths are absolutized against the process
//! cwd first, so `lean-ctx grep` / `lean-ctx ls` from inside a real project
//! keep working unchanged.
/// Returns an actionable `ERROR:` message when `dir` must not be walked,
/// or `None` when the walk is safe to proceed.
pub(crate) fn deny_unsafe_walk_root(dir: &str) -> Option<String> {
let abs = std::path::absolute(dir)
.map_or_else(|_| dir.to_string(), |p| p.to_string_lossy().to_string());
if crate::core::graph_index::is_safe_scan_root_public(&abs) {
return None;
}
Some(format!(
"ERROR: refusing to scan '{dir}' — it resolves to a broad or privacy-protected directory ({abs}). Pass a specific project directory as `path`."
))
}
#[cfg(test)]
mod tests {
use super::deny_unsafe_walk_root;
#[test]
fn refuses_filesystem_root() {
let msg = deny_unsafe_walk_root("/").expect("filesystem root must be refused");
assert!(msg.starts_with("ERROR:"), "actionable error: {msg}");
}
#[test]
fn refuses_home_directory() {
let home = dirs::home_dir().expect("home dir in test env");
let msg =
deny_unsafe_walk_root(&home.to_string_lossy()).expect("home directory must be refused");
assert!(msg.contains("privacy-protected") || msg.contains("broad"));
}
#[test]
fn allows_plain_temp_project_dir() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
deny_unsafe_walk_root(&dir.path().to_string_lossy()),
None,
"temp project dirs stay walkable"
);
}
#[test]
fn relative_dot_is_absolutized_not_blanket_refused() {
// The test process runs inside the lean-ctx repo (a real project with
// markers), so "." must absolutize to a safe root — this is exactly
// the `lean-ctx grep` / `lean-ctx ls` CLI case.
assert_eq!(deny_unsafe_walk_root("."), None);
}
}