Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions rust/src/core/editor_registry/writers/install/vscode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,21 @@ use super::super::shared::*;
use super::super::{WriteAction, WriteOptions, WriteResult};
use crate::core::editor_registry::types::EditorTarget;

fn vscode_mcp_entry(binary: &str) -> Value {
serde_json::json!({
"type": "stdio",
"command": binary,
"args": [],
"env": { "LEAN_CTX_PROJECT_ROOT": "${workspaceFolder}" }
})
}

pub(crate) fn write_vscode_mcp(
target: &EditorTarget,
binary: &str,
opts: WriteOptions,
) -> Result<WriteResult, String> {
let desired = serde_json::json!({ "type": "stdio", "command": binary, "args": [] });
let desired = vscode_mcp_entry(binary);

if target.config_path.exists() {
let content = std::fs::read_to_string(&target.config_path).map_err(|e| e.to_string())?;
Expand Down Expand Up @@ -64,7 +73,7 @@ pub(crate) fn write_vscode_mcp_fresh(
note: Option<String>,
) -> Result<WriteResult, String> {
let content = serde_json::to_string_pretty(&serde_json::json!({
"servers": { "lean-ctx": { "type": "stdio", "command": binary, "args": [] } }
"servers": { "lean-ctx": vscode_mcp_entry(binary) }
}))
.map_err(|e| e.to_string())?;
crate::config_io::write_atomic_with_backup(path, &content)?;
Expand All @@ -78,6 +87,18 @@ pub(crate) fn write_vscode_mcp_fresh(
})
}

#[cfg(test)]
mod tests {
use super::vscode_mcp_entry;

#[test]
fn vscode_mcp_entry_scopes_server_to_workspace_folder() {
let entry = vscode_mcp_entry("/bin/lean-ctx");

assert_eq!(entry["env"]["LEAN_CTX_PROJECT_ROOT"], "${workspaceFolder}");
}
}

// ---------------------------------------------------------------------------
// Augment VS Code extension writer
//
Expand Down
45 changes: 41 additions & 4 deletions rust/src/core/pathutil.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,17 +243,46 @@ pub fn is_agent_config_dir(dir: &Path) -> bool {
.any(|name| s.ends_with(&format!("/{name}")) || s.contains(&format!("/{name}/")))
}

fn is_wsl_windows_user_profile(dir: &Path) -> bool {
let normalized = dir.to_string_lossy();
if !normalized.starts_with("/mnt/") {
return false;
}

let mut components = normalized
.split('/')
.filter(|component| !component.is_empty());
let (Some(mount), Some(drive), Some(users), Some(username)) = (
components.next(),
components.next(),
components.next(),
components.next(),
) else {
return false;
};

mount.eq_ignore_ascii_case("mnt")
&& drive.len() == 1
&& drive.as_bytes()[0].is_ascii_alphabetic()
&& users.eq_ignore_ascii_case("users")
&& !username.is_empty()
&& components.next().is_none()
}

/// Returns `true` if the directory is too broad to be a valid project root.
/// Rejects the home directory, filesystem root, `.` (bare CWD), and agent/IDE
/// config directories ([`AGENT_CONFIG_DIRS`]). Used to prevent adopting a bogus
/// project root and writing project-scoped data into the global `~/.lean-ctx/`
/// data directory.
/// Rejects home directories (including WSL-mounted Windows profiles), filesystem
/// root, `.` (bare CWD), and agent/IDE config directories
/// ([`AGENT_CONFIG_DIRS`]). Used to prevent adopting a bogus project root and
/// writing project-scoped data into the global `~/.lean-ctx/` data directory.
pub fn is_broad_or_unsafe_root(dir: &Path) -> bool {
if let Some(home) = dirs::home_dir()
&& dir == home
{
return true;
}
if is_wsl_windows_user_profile(dir) {
return true;
}
let s = dir.to_string_lossy();
if s == "/" || s == "\\" || s == "." {
return true;
Expand Down Expand Up @@ -818,6 +847,14 @@ mod tests {
}
}

#[test]
fn broad_root_rejects_wsl_windows_user_profile() {
assert!(is_broad_or_unsafe_root(Path::new("/mnt/c/Users/dev")));
assert!(!is_broad_or_unsafe_root(Path::new(
"/mnt/c/Users/dev/projects/my-app"
)));
}

#[test]
fn broad_root_rejects_filesystem_root() {
assert!(is_broad_or_unsafe_root(Path::new("/")));
Expand Down
30 changes: 11 additions & 19 deletions rust/src/server/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,6 @@ pub fn build_claude_code_static_instructions_for_test() -> String {
crate::instructions::claude_code_static_instructions_for_test()
}

fn is_home_or_agent_dir(dir: &std::path::Path) -> bool {
if let Some(home) = dirs::home_dir()
&& dir == home
{
return true;
}
crate::core::pathutil::is_agent_config_dir(dir)
}

fn git_toplevel_from(dir: &std::path::Path) -> Option<String> {
std::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
Expand All @@ -87,7 +78,7 @@ pub fn derive_project_root_from_cwd() -> Option<String> {
let cwd = std::env::current_dir().ok()?;
let canonical = crate::core::pathutil::safe_canonicalize_or_self(&cwd);

if is_home_or_agent_dir(&canonical) {
if crate::core::pathutil::is_broad_or_unsafe_root(&canonical) {
return git_toplevel_from(&canonical);
}

Expand Down Expand Up @@ -125,10 +116,11 @@ use crate::core::pathutil::is_broad_or_unsafe_root;
/// itself, but contains child directories that do. In this case, use the
/// parent as jail root and auto-allow all child projects via LEAN_CTX_ALLOW_PATH.
fn detect_multi_root_workspace(dir: &std::path::Path) -> Option<String> {
// Never enumerate the home dir or macOS TCC-protected dirs (Documents/Desktop/
// Downloads): read_dir there triggers a macOS privacy prompt (#356), and a real
// project under them is already handled upstream via has_project_marker.
if crate::core::pathutil::is_tcc_sensitive_home_dir(dir) {
// Never enumerate broad roots or macOS TCC-protected dirs: read_dir there
// can scan an entire user profile or trigger a macOS privacy prompt.
if crate::core::pathutil::is_broad_or_unsafe_root(dir)
|| crate::core::pathutil::is_tcc_sensitive_home_dir(dir)
{
return None;
}
let entries = std::fs::read_dir(dir).ok()?;
Expand Down Expand Up @@ -245,20 +237,20 @@ mod tests {
}

#[test]
fn home_dir_detected_as_agent_dir() {
fn home_dir_detected_as_broad_root() {
if let Some(home) = dirs::home_dir() {
assert!(is_home_or_agent_dir(&home));
assert!(is_broad_or_unsafe_root(&home));
}
}

#[test]
fn agent_dirs_detected() {
let claude = std::path::PathBuf::from("/home/user/.claude");
assert!(is_home_or_agent_dir(&claude));
assert!(is_broad_or_unsafe_root(&claude));
let codex = std::path::PathBuf::from("/home/user/.codex");
assert!(is_home_or_agent_dir(&codex));
assert!(is_broad_or_unsafe_root(&codex));
let project = std::path::PathBuf::from("/home/user/projects/myapp");
assert!(!is_home_or_agent_dir(&project));
assert!(!is_broad_or_unsafe_root(&project));
}

#[test]
Expand Down
6 changes: 5 additions & 1 deletion vscode-extension/src/cli-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,11 @@ export async function cmdConfigureMcp(): Promise<void> {
if (!config.servers) {
config.servers = {};
}
config.servers["lean-ctx"] = { type: "stdio", command: resolveBinaryPath() };
config.servers["lean-ctx"] = {
type: "stdio",
command: resolveBinaryPath(),
env: { LEAN_CTX_PROJECT_ROOT: "${workspaceFolder}" },
};

fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
vscode.window.showInformationMessage(
Expand Down
2 changes: 2 additions & 0 deletions vscode-extension/src/dashboard-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export async function cmdDashboard(
const token = randomToken();
const bin = resolveBinaryPath();
const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const projectArg = cwd ? [`--project=${cwd}`] : [];

let spawnError: string | undefined;
try {
Expand All @@ -122,6 +123,7 @@ export async function cmdDashboard(
"--host=127.0.0.1",
`--port=${port}`,
`--auth-token=${token}`,
...projectArg,
],
{ cwd, env: { ...process.env, NO_COLOR: "1" } }
);
Expand Down
Loading