Skip to main content

lean_ctx/core/config/
loader.rs

1//! Config path resolution and disk loading.
2
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5
6use super::{Config, ConfigCacheSlot, default_shell_allowlist};
7
8const CONFIG_PROFILE_ENV: &str = "LEAN_CTX_CONFIG_PROFILE";
9
10pub(super) fn environment_config_profile() -> Option<String> {
11    std::env::var(CONFIG_PROFILE_ENV)
12        .ok()
13        .map(|name| name.trim().to_string())
14        .filter(|name| !name.is_empty())
15}
16
17/// Parses a config and recursively applies one named partial overlay. An
18/// explicit selector (normally the environment) wins over `config_profile`.
19pub(super) fn parse_config_with_profile(
20    raw: &str,
21    explicit_profile: Option<&str>,
22) -> Result<Config, String> {
23    let mut value: toml::Value = toml::from_str(raw).map_err(|error| error.to_string())?;
24    let configured_profile = value.get("config_profile").and_then(toml::Value::as_str);
25    let selected = explicit_profile
26        .map(str::trim)
27        .filter(|name| !name.is_empty())
28        .or(configured_profile);
29
30    if let Some(name) = selected {
31        let profiles = value
32            .get("profiles")
33            .and_then(toml::Value::as_table)
34            .ok_or_else(|| format!("config profile '{name}' selected but [profiles] is missing"))?;
35        let mut overlay = profiles
36            .get(name)
37            .and_then(toml::Value::as_table)
38            .cloned()
39            .ok_or_else(|| format!("config profile '{name}' is not defined"))?;
40        if overlay.remove("profiles").is_some() || overlay.remove("config_profile").is_some() {
41            return Err(format!(
42                "config profile '{name}' cannot override reserved profile keys"
43            ));
44        }
45        merge_toml_tables(
46            value
47                .as_table_mut()
48                .expect("a TOML document always has a root table"),
49            overlay,
50        );
51    }
52
53    value.try_into().map_err(|error| error.to_string())
54}
55
56fn merge_toml_tables(base: &mut toml::Table, overlay: toml::Table) {
57    for (key, overlay_value) in overlay {
58        match (base.get_mut(&key), overlay_value) {
59            (Some(toml::Value::Table(base_table)), toml::Value::Table(overlay_table)) => {
60                merge_toml_tables(base_table, overlay_table);
61            }
62            (_, replacement) => {
63                base.insert(key, replacement);
64            }
65        }
66    }
67}
68
69/// Holds the most recent global `config.toml` parse error, if the file currently
70/// fails to parse. When that happens `Config::load()` silently falls back to the
71/// built-in defaults and only logs to stderr — which is invisible over an MCP/stdio
72/// transport. Recording it here lets callers (e.g. the shell-allowlist diagnostic
73/// and `lean-ctx doctor`) surface "you're on defaults because your config is broken".
74static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
75
76/// Returns the most recent global config parse error, or `None` if the current
77/// `config.toml` parsed successfully (or no config file exists).
78#[must_use]
79pub fn last_config_parse_error() -> Option<String> {
80    LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
81}
82
83fn record_parse_error(err: Option<String>) {
84    if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
85        *guard = err;
86    }
87}
88
89/// Reset every SECURITY-sensitive field of a parsed project-local `Config` back
90/// to its default, returning the names of the ones that actually carried an
91/// override. Used by [`Config::merge_local`] for untrusted workspaces: clearing a
92/// field to its default makes the downstream "== default ⇒ no override" merge
93/// guards skip it automatically, so a single list here gates every sensitive key
94/// without touching the per-field merge arms (security audit #4).
95///
96/// Sensitive = anything that can widen lean-ctx's own boundaries or steer the
97/// agent: the shell allowlist, path-jail roots, proxy upstreams, command
98/// aliases, network passthrough, rules scope/injection, tool surface control
99/// (profile/enabled-list/categories, disabling) and permission inheritance.
100/// Comfort/perf knobs are intentionally NOT listed.
101pub(crate) fn strip_sensitive_overrides(local: &mut Config) -> Vec<&'static str> {
102    let mut withheld: Vec<&'static str> = Vec::new();
103
104    if local.shell_allowlist != default_shell_allowlist() {
105        local.shell_allowlist = default_shell_allowlist();
106        withheld.push("shell_allowlist");
107    }
108    if !local.shell_allowlist_extra.is_empty() {
109        local.shell_allowlist_extra.clear();
110        withheld.push("shell_allowlist_extra");
111    }
112    if !local.allow_paths.is_empty() {
113        local.allow_paths.clear();
114        withheld.push("allow_paths");
115    }
116    if !local.extra_roots.is_empty() {
117        local.extra_roots.clear();
118        withheld.push("extra_roots");
119    }
120    if !local.allow_symlink_roots.is_empty() {
121        local.allow_symlink_roots.clear();
122        withheld.push("allow_symlink_roots");
123    }
124    if !local.custom_aliases.is_empty() {
125        local.custom_aliases.clear();
126        withheld.push("custom_aliases");
127    }
128    if !local.passthrough_urls.is_empty() {
129        local.passthrough_urls.clear();
130        withheld.push("passthrough_urls");
131    }
132    if local.proxy.anthropic_upstream.is_some()
133        || local.proxy.openai_upstream.is_some()
134        || local.proxy.chatgpt_upstream.is_some()
135        || local.proxy.gemini_upstream.is_some()
136    {
137        local.proxy.anthropic_upstream = None;
138        local.proxy.openai_upstream = None;
139        local.proxy.chatgpt_upstream = None;
140        local.proxy.gemini_upstream = None;
141        withheld.push("proxy.*_upstream");
142    }
143    if local.rules_scope.is_some() {
144        local.rules_scope = None;
145        withheld.push("rules_scope");
146    }
147    if local.rules_injection.is_some() {
148        local.rules_injection = None;
149        withheld.push("rules_injection");
150    }
151    if local.permission_inheritance.is_some() {
152        local.permission_inheritance = None;
153        withheld.push("permission_inheritance");
154    }
155    if !local.disabled_tools.is_empty() {
156        local.disabled_tools.clear();
157        withheld.push("disabled_tools");
158    }
159    if local.tool_profile.is_some() {
160        local.tool_profile = None;
161        withheld.push("tool_profile");
162    }
163    if !local.tools_enabled.is_empty() {
164        local.tools_enabled.clear();
165        withheld.push("tools_enabled");
166    }
167    if !local.default_tool_categories.is_empty() {
168        local.default_tool_categories.clear();
169        withheld.push("default_tool_categories");
170    }
171    if !local.index.respect_gitignore {
172        local.index.respect_gitignore = true;
173        withheld.push("index.respect_gitignore");
174    }
175
176    withheld
177}
178
179/// Names of the SECURITY-sensitive overrides a project-local `.lean-ctx.toml`
180/// carries — the keys `strip_sensitive_overrides` would withhold for an
181/// untrusted workspace. Read-only (parses a throwaway `Config`); used by
182/// `lean-ctx trust` to tell the user exactly what trusting will enable.
183#[must_use]
184pub fn local_sensitive_overrides(local_toml: &str) -> Vec<&'static str> {
185    let selected = environment_config_profile();
186    match parse_config_with_profile(local_toml, selected.as_deref()) {
187        Ok(mut parsed) => strip_sensitive_overrides(&mut parsed),
188        Err(_) => Vec::new(),
189    }
190}
191
192impl Config {
193    /// Returns the path to the global config file (`$XDG_CONFIG_HOME/lean-ctx/config.toml`).
194    ///
195    /// Resolves via [`crate::core::paths::config_dir`] so config lives in the
196    /// RO-safe config category. Behavior-neutral today: `config_dir()` equals the
197    /// legacy data dir for existing/single-dir installs (GH #408 / GL #602).
198    pub fn path() -> Option<PathBuf> {
199        crate::core::paths::config_dir()
200            .ok()
201            .map(|d| d.join("config.toml"))
202    }
203
204    /// `Some(path)` when the global config the runtime *resolves* does not exist,
205    /// so lean-ctx is silently on built-in defaults. `None` when a config file is
206    /// present (or HOME is unresolvable).
207    ///
208    /// The directory is layout-dependent (XDG `~/.config/lean-ctx` vs legacy
209    /// `~/.lean-ctx` vs `$LEAN_CTX_DATA_DIR`) and an MCP client may launch the
210    /// server in a sandbox/container with a different `$HOME`. An edit made to a
211    /// *different* `config.toml` than this one is silently ignored; the block
212    /// messages use this to say so out loud over MCP, where the stderr path is
213    /// invisible (#540).
214    #[must_use]
215    pub fn missing_config_path() -> Option<PathBuf> {
216        match Self::path() {
217            Some(p) if !p.exists() => Some(p),
218            _ => None,
219        }
220    }
221
222    /// Returns the path to the project-local config override file.
223    pub fn local_path(project_root: &str) -> PathBuf {
224        PathBuf::from(project_root).join(".lean-ctx.toml")
225    }
226
227    /// Resolves the active project root (env override → session → git toplevel →
228    /// cwd), cached for the process. Exposed crate-wide so workspace-trust and the
229    /// CLI agree with config loading on *which* directory a `.lean-ctx.toml`
230    /// belongs to (GH security audit, finding 4).
231    pub(crate) fn find_project_root() -> Option<String> {
232        static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
233        ROOT_CACHE
234            .get_or_init(Self::find_project_root_inner)
235            .clone()
236    }
237
238    fn find_project_root_inner() -> Option<String> {
239        if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
240            && !env_root.is_empty()
241        {
242            return Some(env_root);
243        }
244
245        let cwd = std::env::current_dir().ok();
246
247        if let Some(root) =
248            crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
249        {
250            let root_path = std::path::Path::new(&root);
251            let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
252            // Route the marker probe through the TCC-guarded helper and never
253            // adopt a ~/Documents project root from a launchd-standalone process
254            // (#356): doing so would later stat its `.lean-ctx.toml`/markers and
255            // pop the macOS privacy prompt in lean-ctx's own name.
256            let has_marker = crate::core::pathutil::has_project_marker(root_path);
257
258            if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
259            {
260                return Some(root);
261            }
262        }
263
264        if let Some(ref cwd) = cwd {
265            // A launchd-standalone process must not shell out to `git` (which
266            // stats the working tree) or adopt cwd as the project root when cwd
267            // is under a TCC-protected dir (#356).
268            let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
269            let git_root = if may_probe_cwd {
270                std::process::Command::new("git")
271                    .args(["rev-parse", "--show-toplevel"])
272                    .current_dir(cwd)
273                    .stdout(std::process::Stdio::piped())
274                    .stderr(std::process::Stdio::null())
275                    .output()
276                    .ok()
277                    .and_then(|o| {
278                        if o.status.success() {
279                            String::from_utf8(o.stdout)
280                                .ok()
281                                .map(|s| s.trim().to_string())
282                        } else {
283                            None
284                        }
285                    })
286            } else {
287                None
288            };
289            if let Some(root) = git_root {
290                return Some(root);
291            }
292            if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
293                return Some(cwd.to_string_lossy().to_string());
294            }
295        }
296        None
297    }
298
299    /// Loads config from disk with caching, merging global + project-local overrides.
300    ///
301    /// The cache is keyed on a **content hash** of the global + project-local
302    /// files, not their mtime. mtime-only invalidation silently served a stale
303    /// `Config` whenever a content edit preserved the mtime (coarse filesystem
304    /// mtime resolution, `cp -p`, atomic save-then-rename, two edits within the
305    /// same second). A long-lived MCP server then kept the old value (e.g.
306    /// `path_jail`) while a fresh `lean-ctx doctor` process — with an empty
307    /// cache — saw the new one (#406). Config files are tiny, so reading +
308    /// hashing them on every load is negligible and guarantees liveness.
309    pub fn load() -> Self {
310        (*Self::load_arc()).clone()
311    }
312
313    /// Shared-ownership variant of [`load`](Self::load): returns the cached
314    /// `Arc<Config>` so the per-dispatch hot path bumps a refcount instead of
315    /// deep-cloning the whole struct. Liveness is identical to `load` — the
316    /// global and project-local files are still read and content-hashed on
317    /// every call (#406); only the cache payload became an `Arc`, so a cache
318    /// hit is a cheap `Arc::clone`.
319    pub fn load_arc() -> Arc<Self> {
320        static CACHE: Mutex<ConfigCacheSlot> = Mutex::new(None);
321
322        let Some(path) = Self::path() else {
323            return Arc::new(Self::default());
324        };
325
326        let project_root = Self::find_project_root();
327        let local_path = project_root.as_deref().map(Self::local_path);
328
329        // Read raw content up front so the cache key is a content hash.
330        let global_content = std::fs::read_to_string(&path).ok();
331        // TCC (#356): never read a project-local `.lean-ctx.toml` under
332        // ~/Documents from a launchd-standalone process — the read pops the
333        // macOS privacy prompt. `find_project_root` already avoids returning
334        // such roots; this also guards the explicit `LEAN_CTX_PROJECT_ROOT` path.
335        let local_content = local_path
336            .as_ref()
337            .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
338            .and_then(|p| std::fs::read_to_string(p).ok());
339
340        let global_hash = global_content.as_deref().map(crate::core::hasher::hash_str);
341        let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
342        let selected_profile = environment_config_profile();
343
344        if let Ok(guard) = CACHE.lock()
345            && let Some((ref cfg, ref cached_global, ref cached_local, ref cached_profile)) = *guard
346            && *cached_global == global_hash
347            && *cached_local == local_hash
348            && *cached_profile == selected_profile
349        {
350            return Arc::clone(cfg);
351        }
352
353        let mut cfg: Config = if let Some(ref content) = global_content {
354            match parse_config_with_profile(content, selected_profile.as_deref()) {
355                Ok(c) => {
356                    record_parse_error(None);
357                    c
358                }
359                Err(e) => {
360                    record_parse_error(Some(e.clone()));
361                    tracing::warn!("config parse error in {}: {e}", path.display());
362                    eprintln!(
363                        "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n  \
364                         Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
365                        path.display()
366                    );
367                    Self::default()
368                }
369            }
370        } else {
371            record_parse_error(None);
372            Self::default()
373        };
374
375        if let Some(ref local) = local_content {
376            // Finding 4: a project-local `.lean-ctx.toml`'s SECURITY-sensitive
377            // overrides (shell allowlist, path-jail widening, proxy upstream, …)
378            // are honoured only for a workspace the user has explicitly trusted.
379            // `local_hash` is exactly the content hash workspace-trust pins, so
380            // editing the file after trust re-gates it (see `workspace_trust`).
381            let trusted = project_root.as_deref().is_some_and(|r| {
382                crate::core::workspace_trust::is_trusted_for(
383                    std::path::Path::new(r),
384                    local_hash.as_deref().unwrap_or_default(),
385                )
386            });
387            cfg.merge_local(local, trusted);
388        }
389
390        cfg.migrate_contribute_to_telemetry();
391
392        let cfg = Arc::new(cfg);
393        if let Ok(mut guard) = CACHE.lock() {
394            *guard = Some((Arc::clone(&cfg), global_hash, local_hash, selected_profile));
395        }
396
397        cfg
398    }
399
400    // `merge_local` is in `merge.rs` (extracted for #660 LOC gate).
401
402    /// Migrate legacy `[cloud] contribute_enabled` → `[telemetry] enabled`.
403    ///
404    /// If the user opted into the old anonymous contribute system but has not
405    /// yet enabled the new unified telemetry flag, flip `telemetry.enabled`
406    /// on and clear `contribute_enabled` so the migration is one-way.
407    /// Persists the change to disk so subsequent loads see the new state.
408    pub(crate) fn migrate_contribute_to_telemetry(&mut self) {
409        if self.cloud.contribute_enabled && !self.telemetry.enabled {
410            self.telemetry.enabled = true;
411            self.cloud.contribute_enabled = false;
412
413            if let Some(path) = Self::path() {
414                if let Ok(raw) = std::fs::read_to_string(&path) {
415                    let mut updated =
416                        raw.replace("contribute_enabled = true", "contribute_enabled = false");
417                    if !updated.contains("[telemetry]") {
418                        if !updated.ends_with('\n') {
419                            updated.push('\n');
420                        }
421                        updated.push_str("\n[telemetry]\nenabled = true\n");
422                    } else if let Some(tpos) = updated.find("[telemetry]") {
423                        let after = &updated[tpos..];
424                        if let Some(epos) = after.find("enabled = false") {
425                            let abs_pos = tpos + epos;
426                            updated.replace_range(
427                                abs_pos..abs_pos + "enabled = false".len(),
428                                "enabled = true",
429                            );
430                        }
431                    }
432                    let _ = crate::config_io::write_atomic_with_backup(&path, &updated);
433                }
434            }
435        }
436    }
437
438    /// Loads ONLY the global config file — never merging project-local
439    /// `.lean-ctx.toml` overrides, and bypassing the in-memory cache. Every
440    /// PERSIST path must use this (or [`Config::update_global`]): [`Config::load`]
441    /// folds per-project overrides into the struct, and [`Config::save`] writes
442    /// the whole struct back to the GLOBAL file — so a `load → mutate → save`
443    /// round-trip silently leaks per-project values (and, historically, reset
444    /// customized keys) into the global config (#443). Reading global-only makes
445    /// the save leak-free by construction.
446    pub fn load_global() -> Self {
447        Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
448    }
449
450    /// Path-parameterized core of [`Config::load_global`] (unit-testable without
451    /// the real config dir). Missing, empty, or unparseable files yield
452    /// defaults; persisting callers that must not clobber a corrupt file use
453    /// [`Config::update_global`], which refuses instead.
454    pub(super) fn load_global_from(path: &Path) -> Self {
455        match std::fs::read_to_string(path) {
456            Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).unwrap_or_default(),
457            _ => Self::default(),
458        }
459    }
460}