Skip to main content

lean_ctx/core/
command_root.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The project a command-line invocation acts for, and the read boundary a
3//! command-line read enforces against it.
4//!
5//! The root comes from `--root`/`--project-root`, then `LEAN_CTX_PROJECT_ROOT`
6//! (reconciled with the working directory, AP04-G10), then the git root above
7//! the working directory. Shared by the CLI commands, the daemon client and the
8//! shell-hook rewriters, so every entry point names the same project.
9
10use std::path::Path;
11
12/// The project this process acts for, from `args` and the working directory.
13pub fn detect_project_root(args: &[String]) -> String {
14    detect_project_root_in(args, std::env::current_dir().ok().as_deref())
15}
16
17/// The project a command that writes project-scoped state acts for; `Err`
18/// instead of `~`, `/` or an agent directory (AP04-G10).
19pub fn require_project_root() -> Result<String, String> {
20    require_project_root_in(std::env::current_dir().ok().as_deref())
21}
22
23fn require_project_root_in(cwd: Option<&Path>) -> Result<String, String> {
24    let root = detect_project_root_in(&[], cwd);
25    if crate::core::pathutil::is_broad_or_unsafe_root(Path::new(&root)) {
26        return Err(format!(
27            "{root} is not a project — run inside a project or set LEAN_CTX_PROJECT_ROOT"
28        ));
29    }
30    Ok(root)
31}
32
33fn detect_project_root_in(args: &[String], cwd: Option<&Path>) -> String {
34    let mut it = args.iter().peekable();
35    while let Some(a) = it.next() {
36        if let Some(v) = a.strip_prefix("--root=")
37            && !v.trim().is_empty()
38        {
39            return normalize_explicit_root(v);
40        }
41        if let Some(v) = a.strip_prefix("--project-root=")
42            && !v.trim().is_empty()
43        {
44            return normalize_explicit_root(v);
45        }
46        if (a == "--root" || a == "--project-root")
47            && let Some(v) = it.peek()
48            && !v.starts_with("--")
49            && !v.trim().is_empty()
50        {
51            return normalize_explicit_root(v);
52        }
53    }
54    let cwd_root = cwd.map(|cwd| promote_to_git_root(&cwd.to_string_lossy()));
55    if let Ok(root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
56        && !root.trim().is_empty()
57    {
58        return crate::core::mcp_roots::reconcile_pinned_root(
59            normalize_explicit_root(&root),
60            cwd_root,
61        );
62    }
63    cwd_root.unwrap_or_else(|| ".".to_string())
64}
65
66fn normalize_explicit_root(path: &str) -> String {
67    let expanded = expand_home(path.trim());
68    crate::core::index_paths::normalize_project_root(&expanded)
69}
70
71fn expand_home(path: &str) -> String {
72    if path == "~" {
73        return dirs::home_dir().map_or_else(
74            || path.to_string(),
75            |home| home.to_string_lossy().to_string(),
76        );
77    }
78    if let Some(rest) = path.strip_prefix("~/") {
79        return dirs::home_dir().map_or_else(
80            || path.to_string(),
81            |home| home.join(rest).to_string_lossy().to_string(),
82        );
83    }
84    path.to_string()
85}
86
87pub fn promote_to_git_root(path: &str) -> String {
88    let mut p = Path::new(path);
89    loop {
90        // A dotfiles repository at `~` is not the project of everything below.
91        if p.join(".git").exists() && !crate::core::pathutil::is_broad_or_unsafe_root(p) {
92            return p.to_string_lossy().to_string();
93        }
94        match p.parent() {
95            Some(parent) => p = parent,
96            None => return path.to_string(),
97        }
98    }
99}
100
101/// #1903: `lean-ctx read` enforces the same boundary as MCP `ctx_read` — the
102/// PathJail (project root + `allow_paths`/`extra_roots`/`read_only_roots` + the
103/// lean-ctx state dir) and the secret-path policy — with the same error text.
104/// A relative path is resolved against the process CWD (a shell user's
105/// intent), never re-anchored at the project root. A broad root (home, `/`,
106/// agent config dir) is refused like `lean-ctx call` does, unless the jail is
107/// disabled (`path_jail = false`), because jailing to `~` would admit every
108/// file the user owns.
109pub fn jail_cli_read_path(raw: &str, project_root: &str) -> Result<String, String> {
110    let candidate = if Path::new(raw).is_relative() {
111        std::env::current_dir().map_or_else(
112            |_| raw.to_string(),
113            |cwd| cwd.join(raw).to_string_lossy().into_owned(),
114        )
115    } else {
116        raw.to_string()
117    };
118    let jail_disabled = crate::core::config::Config::load().path_jail == Some(false);
119    if !jail_disabled && crate::core::pathutil::is_broad_or_unsafe_root(Path::new(project_root)) {
120        return Err(format!(
121            "path escapes project root: {candidate} (root: {project_root}). Access denied: \
122             no project detected — run inside a project, or pass --root <dir> \
123             (or set LEAN_CTX_PROJECT_ROOT)"
124        ));
125    }
126    crate::core::path_resolve::resolve_tool_path(Some(project_root), None, &candidate)
127}
128
129/// Whether `lean-ctx read <raw>` from this process would be refused (#1903).
130/// Shell-hook rewriters keep such reads on the native command instead of
131/// turning a working `cat` into an access-denied error.
132pub fn cli_read_is_refused(raw: &str) -> bool {
133    jail_cli_read_path(raw, &detect_project_root(&[])).is_err()
134}
135
136#[cfg(test)]
137#[path = "command_root_tests.rs"]
138mod tests;