lean_ctx/core/config/mod.rs
1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::sync::Mutex;
5
6use super::memory_policy::MemoryPolicy;
7
8mod defaults_allowlist;
9mod enums;
10mod memory;
11mod provenance;
12mod proxy;
13mod render;
14pub mod schema;
15mod sections;
16mod serde_defaults;
17pub mod setter;
18mod shell_activation;
19pub use render::render_annotated_config;
20pub use sections::*;
21#[cfg(test)]
22mod tests;
23
24pub(crate) use defaults_allowlist::{cloud_infra_commands, default_shell_allowlist};
25pub use enums::{
26 CompressionLevel, OutputDensity, PermissionInheritance, ResponseVerbosity, RulesInjection,
27 RulesScope, TeeMode, TerseAgent,
28};
29pub use memory::{MemoryCleanup, MemoryGuardConfig, MemoryProfile, SavingsFooter};
30pub use provenance::{ConfigProvenance, EnvOverride};
31pub use proxy::{
32 HistoryMode, ProseRole, ProxyConfig, ProxyProvider, RoleAggressiveness, UpstreamDrift,
33 Upstreams, diagnose_drift, env_upstream_override, is_local_proxy_url, normalize_url,
34 normalize_url_opt,
35};
36pub use shell_activation::ShellActivation;
37
38/// Default BM25 cache cap from config (also used by `bm25_index` heuristics).
39pub fn default_bm25_max_cache_mb() -> u64 {
40 serde_defaults::default_bm25_max_cache_mb()
41}
42
43/// Effective on-disk ceiling (MB) for the persisted BM25 index when nothing is
44/// explicitly configured (no `bm25_max_cache_mb`, no `max_disk_mb` budget).
45///
46/// Deliberately decoupled from the RAM `MemoryProfile` (64/128/512 MB): this is
47/// a *disk* file, and tying it to the profile silently refused persistence on
48/// large repos under Low/Balanced, forcing a cold rebuild on every call (the
49/// perpetual "index warming" of issue #249). 512 MB compressed covers
50/// essentially every real repo; RAM pressure is governed separately by the
51/// eviction orchestrator (which measures real heap).
52pub const DEFAULT_BM25_PERSIST_MB: u64 = 512;
53
54// Compile-time regression guard (#249): the default disk ceiling must stay well
55// above the old RAM-profile caps (64/128 MB) that starved large repos.
56const _: () = assert!(DEFAULT_BM25_PERSIST_MB >= 512);
57
58/// lean-ctx tools whose sole purpose is editing the user's source files. When
59/// `prefer_native_editor` is set (#454) these are hidden from `list_tools` and
60/// refused at dispatch so the host's native editor handles edits instead.
61///
62/// Deliberately narrow: only the dedicated edit tool is blocked. LSP refactor
63/// (`ctx_refactor`) also exposes read-only sub-actions (references/definition),
64/// so it is left available; users wanting it gone can add it to `disabled_tools`.
65pub const EDIT_TOOL_NAMES: &[&str] = &["ctx_edit"];
66
67/// Global lean-ctx configuration loaded from `config.toml`, merged with project-local overrides.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(default)]
70pub struct Config {
71 pub ultra_compact: bool,
72 #[serde(default, deserialize_with = "serde_defaults::deserialize_tee_mode")]
73 pub tee_mode: TeeMode,
74 #[serde(default)]
75 pub output_density: OutputDensity,
76 pub checkpoint_interval: u32,
77 pub excluded_commands: Vec<String>,
78 pub passthrough_urls: Vec<String>,
79 pub custom_aliases: Vec<AliasEntry>,
80 /// Output formats that are already compact/token-oriented and must be
81 /// preserved verbatim instead of being recompressed (#342). Matched against
82 /// the *output shape* (not the command name), so any tool emitting the
83 /// format is covered without enumerating commands in `excluded_commands`.
84 /// Default: `["toon"]`. Set to `[]` to disable and always recompress.
85 #[serde(default = "serde_defaults::default_preserve_compact_formats")]
86 pub preserve_compact_formats: Vec<String>,
87 /// Commands taking longer than this threshold (ms) are recorded in the slow log.
88 /// Set to 0 to disable slow logging.
89 pub slow_command_threshold_ms: u64,
90 #[serde(default = "serde_defaults::default_theme")]
91 pub theme: String,
92 #[serde(default)]
93 pub cloud: CloudConfig,
94 #[serde(default)]
95 pub gain: GainConfig,
96 /// Model declaration for measured-vs-estimated cost reporting (MCP-only IDEs).
97 #[serde(default)]
98 pub cost: CostConfig,
99 #[serde(default)]
100 pub autonomy: AutonomyConfig,
101 #[serde(default)]
102 pub providers: ProvidersConfig,
103 #[serde(default)]
104 pub proxy: ProxyConfig,
105 /// Whether the API proxy is enabled. Tri-state:
106 /// - None: undecided (fresh install, will prompt on interactive setup)
107 /// - Some(true): user opted in, proxy managed by lean-ctx
108 /// - Some(false): user opted out, never touch proxy or endpoints
109 #[serde(default)]
110 pub proxy_enabled: Option<bool>,
111 #[serde(default)]
112 pub proxy_port: Option<u16>,
113 /// Proxy reachability timeout in milliseconds. Default: 200.
114 /// Override via LEAN_CTX_PROXY_TIMEOUT_MS env var.
115 #[serde(default)]
116 pub proxy_timeout_ms: Option<u64>,
117 /// Strict proxy auth: when true, authenticate ONLY via the Bearer token
118 /// (`LEAN_CTX_PROXY_TOKEN`) and disable the provider-API-key fallback. Default
119 /// false keeps the loopback-friendly behavior where any local AI tool's own
120 /// provider key authenticates (the proxy never injects upstream credentials —
121 /// it forwards the caller's key verbatim). Enable on shared/multi-user hosts to
122 /// require the token; clients must then send `Authorization: Bearer <token>`.
123 #[serde(default)]
124 pub proxy_require_token: bool,
125 #[serde(default = "serde_defaults::default_buddy_enabled")]
126 pub buddy_enabled: bool,
127 #[serde(default = "serde_defaults::default_true")]
128 pub enable_wakeup_ctx: bool,
129 #[serde(default)]
130 pub redirect_exclude: Vec<String>,
131 /// Tools to exclude from the MCP tool list returned by list_tools.
132 /// Accepts exact tool names (e.g. `["ctx_graph", "ctx_agent"]`).
133 /// Empty by default — all tools listed, no behaviour change.
134 #[serde(default)]
135 pub disabled_tools: Vec<String>,
136 /// Prefer the host agent's native editor over lean-ctx edit operations (#454).
137 /// When true, the lean-ctx edit tool(s) (see [`EDIT_TOOL_NAMES`]) are neither
138 /// advertised in `list_tools` nor dispatchable (direct or via `ctx_call`), so
139 /// the agent falls back to the host's built-in editing UI. Reads / search /
140 /// shell / memory tools are unaffected. Override via
141 /// `LEAN_CTX_PREFER_NATIVE_EDITOR=1`.
142 #[serde(default)]
143 pub prefer_native_editor: bool,
144 /// Tool categories to activate by default for dynamic-tool-capable clients.
145 /// Values: "core" (always on), "arch", "debug", "memory", "metrics", "session".
146 /// Example: `default_tool_categories = ["core", "arch", "memory"]`
147 /// Override via LCTX_DEFAULT_CATEGORIES env var (comma-separated).
148 /// Empty = lean-ctx default (core + session).
149 #[serde(default)]
150 pub default_tool_categories: Vec<String>,
151 /// Disable all automatic read-mode degradation (auto_degrade + context_gate pressure).
152 /// When true, lean-ctx never downgrades requested read modes regardless of pressure.
153 /// Override via LCTX_NO_DEGRADE=1 env var.
154 #[serde(default)]
155 pub no_degrade: bool,
156 /// Serve explicit `full`/`lines:N-M` re-reads of session-cached files as
157 /// deltas: when the file changed on disk since it was cached, the read
158 /// returns `mode=diff` instead of re-emitting content the model already
159 /// holds. First reads are unaffected; `fresh=true` always bypasses.
160 /// Opt-in. Override via LCTX_DELTA_EXPLICIT=1/0 env var.
161 #[serde(default)]
162 pub delta_explicit: bool,
163 /// Persistent profile name. Checked after LEAN_CTX_PROFILE env var.
164 /// Set via `lean-ctx config set profile passthrough` or editing config.toml.
165 #[serde(default)]
166 pub profile: Option<String>,
167 /// Tool visibility profile: "minimal" (6), "standard" (22), or "power" (all).
168 /// Override via LEAN_CTX_TOOL_PROFILE env var.
169 /// Existing installs default to "power" (backward compat).
170 #[serde(default)]
171 pub tool_profile: Option<String>,
172 /// Explicit list of enabled tool names (overrides tool_profile when non-empty).
173 /// Example: `tools_enabled = ["ctx_read", "ctx_shell", "ctx_search"]`
174 #[serde(default)]
175 pub tools_enabled: Vec<String>,
176 /// Active context persona (`persona-spec-v1`). Selects the domain bundle —
177 /// tool surface, read-mode/compressor/chunker defaults, intent taxonomy,
178 /// sensitivity floor. Override via `LEAN_CTX_PERSONA`. Defaults to `coding`.
179 #[serde(default)]
180 pub persona: Option<String>,
181 #[serde(default)]
182 pub loop_detection: LoopDetectionConfig,
183 /// Controls where lean-ctx installs agent rule files.
184 /// Values: "both" (default), "global" (home-dir only), "project" (repo-local only).
185 /// Override via LEAN_CTX_RULES_SCOPE env var.
186 #[serde(default)]
187 pub rules_scope: Option<String>,
188 /// Controls how rules are injected for shared-instruction-file agents.
189 /// Values: "shared" (default, marker block in CLAUDE.md/CODEBUDDY.md/AGENTS.md/GEMINI.md),
190 /// "dedicated" (never touch those files; use each agent's config-driven
191 /// auto-load: SessionStart hook / instructions[] / context.fileName, #343), or
192 /// "off" (write no rules file at all — for hosts that supply their own
193 /// tool-steering workflow or phase-isolated/non-caching harnesses, #361).
194 /// Override via LEAN_CTX_RULES_INJECTION env var.
195 #[serde(default)]
196 pub rules_injection: Option<String>,
197 /// Mirror the host IDE's tool-permission rules onto lean-ctx's own MCP tools.
198 /// Values: "off" (default) or "on". When "on", lean-ctx reads the active
199 /// IDE's permission config (v1: OpenCode) and applies the equivalent
200 /// deny/ask/allow decision to the matching lean-ctx tool — so `ctx_shell`
201 /// honors your `bash`/`rm *` rules instead of bypassing them.
202 /// Override via LEAN_CTX_PERMISSION_INHERITANCE env var.
203 #[serde(default)]
204 pub permission_inheritance: Option<String>,
205 /// Extra glob patterns to ignore in graph/overview/preload (repo-local).
206 /// Example: `["externals/**", "target/**", "temp/**"]`
207 #[serde(default)]
208 pub extra_ignore_patterns: Vec<String>,
209 /// Controls agent output verbosity via instructions injection.
210 /// Values: "off" (default), "lite", "full", "ultra".
211 /// Override via LEAN_CTX_TERSE_AGENT env var.
212 #[serde(default)]
213 pub terse_agent: TerseAgent,
214 /// Unified compression level (replaces separate terse_agent + output_density).
215 /// Values: "off" (default), "lite", "standard", "max".
216 /// Override via LEAN_CTX_COMPRESSION env var.
217 #[serde(default)]
218 pub compression_level: CompressionLevel,
219 /// Global compression intensity 0.0 (lossless) – 1.0 (max), mapped onto the
220 /// read modes / entropy / IB stages (see `core::aggressiveness`). `None`
221 /// (default) keeps each mode's built-in default. Override via the
222 /// `LEAN_CTX_AGGRESSIVENESS` env var or the `ctx_read` `aggressiveness` arg.
223 #[serde(default)]
224 pub compression_aggressiveness: Option<f64>,
225 /// Archive configuration for zero-loss compression.
226 #[serde(default)]
227 pub archive: ArchiveConfig,
228 /// Memory policy (knowledge/episodic/procedural/lifecycle budgets & thresholds).
229 #[serde(default)]
230 pub memory: MemoryPolicy,
231 /// Additional paths allowed by PathJail (absolute).
232 /// Useful for multi-project workspaces where the jail root is a parent directory.
233 /// Override via LEAN_CTX_ALLOW_PATH env var (path-list separator).
234 #[serde(default)]
235 pub allow_paths: Vec<String>,
236 /// Allow jailed tool access to home-level IDE config dirs (~/.cursor,
237 /// ~/.claude, ~/.codebuddy, …). Default false: those dirs expose other projects'
238 /// sessions, MCP configs and credentials. `~/.lean-ctx` (own data dir)
239 /// is always allowed. Override via LEAN_CTX_ALLOW_IDE_DIRS=1.
240 #[serde(default)]
241 pub allow_ide_config_dirs: bool,
242 /// Extra project roots for multi-root workspaces.
243 /// Tools like ctx_tree and ctx_search can scan across all roots in a single call.
244 /// These paths are automatically added to PathJail's allow-list.
245 /// Override via LEAN_CTX_EXTRA_ROOTS env var (path-list separator).
246 #[serde(default)]
247 pub extra_roots: Vec<String>,
248 /// Enable content-defined chunking (Rabin-Karp) for cache-optimal output ordering.
249 /// Stable chunks are emitted first to maximize prompt cache hits.
250 #[serde(default)]
251 pub content_defined_chunking: bool,
252 /// Skip session/knowledge/gotcha blocks in MCP instructions to minimize token overhead.
253 /// Override via LEAN_CTX_MINIMAL env var.
254 #[serde(default)]
255 pub minimal_overhead: bool,
256 /// Opt-in: substitute long identifiers with short α-codes (+ a `§MAP` table)
257 /// in `aggressive` reads for projects with >50 source files. Off by default —
258 /// the abbreviated form is confusing for editing/refactoring, where the agent
259 /// needs the real package and symbol names. Enable for max exploration savings.
260 #[serde(default)]
261 pub symbol_map_auto: bool,
262 /// Opt-in: bias `auto` toward structure-first reads (`map`) for medium code
263 /// files on a cold read. Off by default — interactive sessions keep the
264 /// conservative `full` floor that avoids a follow-up body read. Enable for
265 /// phase-isolated harnesses (no warm-session cache payback), where a cold
266 /// `full` read is pure overhead and structure-first reads aid localization.
267 /// Override via the LEAN_CTX_STRUCTURE_FIRST env var.
268 #[serde(default)]
269 pub structure_first: bool,
270 /// Opt-in: let the adaptive *learning* signals (predictor, bandit, heatmap,
271 /// adaptive policy, bounce/path memory) participate in `auto` mode
272 /// resolution. Off by default (#683): the default cascade is a deterministic
273 /// function of (file, task) — only capability guards and the size/task
274 /// heuristic decide — which keeps output byte-stable for provider prompt
275 /// caching (#498) and avoids per-read disk I/O from the learning stores.
276 /// Override via the LEAN_CTX_AUTO_MODE_LEARNING env var.
277 #[serde(default)]
278 pub auto_mode_learning: bool,
279 /// Team server URL for opt-in savings roll-up.
280 /// Set via `lean-ctx config set team_url https://...` or `[team] url` in config.toml.
281 /// Override via LEAN_CTX_TEAM_URL env var.
282 #[serde(default)]
283 pub team_url: Option<String>,
284 /// Bearer token for the team server (Authorization header on savings push /
285 /// pull). Set via `lean-ctx config set team_token <tok>` or `team_token` in
286 /// config.toml. Override via the LEAN_CTX_TEAM_TOKEN env var.
287 #[serde(default)]
288 pub team_token: Option<String>,
289 /// Opt-in: when true, the running daemon periodically pushes this machine's
290 /// signed savings batch to `team_url` so the team roll-up fills itself (no
291 /// manual `savings push` per dev). Off by default; requires `team_url` +
292 /// `team_token`. Set via `lean-ctx config set team_auto_push true`.
293 #[serde(default)]
294 pub team_auto_push: bool,
295 /// Enable human-readable activity journal (~/.lean-ctx/journal.md).
296 #[serde(default)]
297 pub journal_enabled: bool,
298 /// Opt-in: auto-persist interesting findings as knowledge facts.
299 #[serde(default)]
300 pub auto_capture: bool,
301 /// Hybrid search weights (BM25/dense/candidates).
302 #[serde(default)]
303 pub search: crate::core::hybrid_search::HybridConfig,
304 /// Code-graph settings, including traversal (co-access) edges (#289).
305 #[serde(default)]
306 pub graph: GraphConfig,
307 /// Skillify miner settings (#290): codify recurring patterns into rules.
308 #[serde(default)]
309 pub skillify: SkillifyConfig,
310 /// AI session-summary settings (#292): periodic, semantically-recallable summaries.
311 #[serde(default)]
312 pub summaries: SummariesConfig,
313 /// Optional LLM enhancement (query expansion, contradiction explanation).
314 #[serde(default)]
315 pub llm: crate::core::llm_enhance::LlmConfig,
316 /// Semantic-embedding engine settings (which local ONNX model to use).
317 #[serde(default)]
318 pub embedding: EmbeddingConfig,
319 /// Disable shell hook injection (the _lc() function that wraps CLI commands).
320 /// Override via LEAN_CTX_NO_HOOK env var.
321 #[serde(default)]
322 pub shell_hook_disabled: bool,
323 /// Shadow mode: transparently intercepts native tool calls (Read/Grep/Shell)
324 /// via hooks, strengthens MCP instructions to MUST-level, and activates
325 /// immediate bypass hints on first native tool use. Enables "transparent
326 /// replacement" so agents use ctx_* without explicit opt-in.
327 #[serde(default)]
328 pub shadow_mode: bool,
329 /// Controls when the shell hook auto-activates aliases.
330 /// - `always`: (Default) Aliases active in every interactive shell.
331 /// - `agents-only`: Aliases only active when an AI agent env var is detected.
332 /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
333 ///
334 /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
335 #[serde(default)]
336 pub shell_activation: ShellActivation,
337 /// Disable the daily version check against leanctx.com/version.txt.
338 /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
339 #[serde(default)]
340 pub update_check_disabled: bool,
341 #[serde(default)]
342 pub updates: UpdatesConfig,
343 /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
344 /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
345 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
346 pub bm25_max_cache_mb: u64,
347 /// Maximum number of files scanned by the lightweight JSON graph index.
348 /// 0 = unlimited (default). Set >0 to cap for constrained systems.
349 #[serde(default = "serde_defaults::default_graph_index_max_files")]
350 pub graph_index_max_files: u64,
351 /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
352 /// Override via LEAN_CTX_MEMORY_PROFILE env var.
353 #[serde(default)]
354 pub memory_profile: MemoryProfile,
355 /// Controls how aggressively memory is freed when idle.
356 /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
357 /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
358 #[serde(default)]
359 pub memory_cleanup: MemoryCleanup,
360 /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
361 /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
362 #[serde(default = "serde_defaults::default_max_ram_percent")]
363 pub max_ram_percent: u8,
364 /// Simplified disk budget (MB). When set and detail values are at defaults,
365 /// distributes proportionally: archive=25%, bm25=10%, remainder for stores.
366 /// 0 = disabled (use individual settings). Override via LEAN_CTX_MAX_DISK_MB.
367 #[serde(default)]
368 pub max_disk_mb: u64,
369 /// Auto-purge data older than this many days. 0 = disabled.
370 /// Flows into archive.max_age_hours and lifecycle idle TTL.
371 #[serde(default)]
372 pub max_staleness_days: u32,
373 /// Cap on the rayon worker threads used by the CPU-heavy index build
374 /// (call graph etc.). 0 = rayon default (all cores). Set >0 to bound
375 /// per-instance CPU so a fleet of concurrent sessions can't saturate the
376 /// host on startup. Override via LEANCTX_INDEX_THREADS env var.
377 #[serde(default)]
378 pub max_index_threads: usize,
379 /// Controls visibility of token savings footers in tool output.
380 /// Values: "always" (default, show on every response), "never", "auto" (legacy compatibility).
381 /// Override via LEAN_CTX_SAVINGS_FOOTER or LEAN_CTX_SHOW_SAVINGS=1|0 env var.
382 #[serde(default)]
383 pub savings_footer: SavingsFooter,
384 /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
385 /// This prevents accidental home-directory scans when running from $HOME.
386 /// Override via LEAN_CTX_PROJECT_ROOT env var.
387 #[serde(default)]
388 pub project_root: Option<String>,
389 /// LSP server overrides. Map language name to custom binary path.
390 /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
391 #[serde(default)]
392 pub lsp: std::collections::HashMap<String, String>,
393 /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
394 /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
395 /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
396 #[serde(default)]
397 pub ide_paths: HashMap<String, Vec<String>>,
398 /// Custom model context window overrides.
399 /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
400 #[serde(default)]
401 pub model_context_windows: HashMap<String, usize>,
402 /// Controls how much detail tool responses include.
403 ///
404 /// - `full` (default): complete compressed output
405 /// - `headers_only`: metadata line only (path, mode, token count)
406 ///
407 /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
408 #[serde(default)]
409 pub response_verbosity: ResponseVerbosity,
410 /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
411 /// a hint is appended to the next tool response.
412 /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
413 /// Override via LEAN_CTX_BYPASS_HINTS env var.
414 #[serde(default)]
415 pub bypass_hints: Option<String>,
416 /// Cache policy for ctx_read. Controls behavior on cache hits.
417 /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
418 /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
419 /// Override via LEAN_CTX_CACHE_POLICY env var.
420 #[serde(default)]
421 pub cache_policy: Option<String>,
422 /// Cross-project boundary policy.
423 /// Controls whether cross-project search/import is allowed and whether access is audited.
424 #[serde(default)]
425 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
426 #[serde(default)]
427 pub secret_detection: SecretDetectionConfig,
428 /// Per-item sensitivity model with a uniform policy floor (#212).
429 /// Disabled by default → fully no-op until `sensitivity.enabled = true`.
430 #[serde(default)]
431 pub sensitivity: crate::core::sensitivity::SensitivityConfig,
432 /// MCP Tool-Catalog Gateway (#210): aggregate + query-route downstream MCP
433 /// servers. Global-only (never merged from project-local config) and a full
434 /// no-op until `gateway.enabled = true`.
435 #[serde(default)]
436 pub gateway: crate::core::gateway::GatewayConfig,
437 /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
438 /// When false (default), absolute paths outside the jail are rejected without re-rooting.
439 /// Override via LEAN_CTX_ALLOW_REROOT env var.
440 #[serde(default)]
441 pub allow_auto_reroot: bool,
442 /// Disable PathJail entirely by setting `path_jail = false` in config.toml.
443 /// Useful in container/Docker environments where the sandbox is the boundary.
444 /// (The former `LEAN_CTX_NO_JAIL=1` env override was removed in v3.7.3.)
445 #[serde(default)]
446 pub path_jail: Option<bool>,
447 /// Sandbox level for code execution (ctx_exec).
448 /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
449 /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
450 #[serde(default)]
451 pub sandbox_level: u8,
452 /// When true, large tool outputs (>4000 chars) are stored as references
453 /// and a short URI is returned instead of the full content.
454 /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
455 #[serde(default)]
456 pub reference_results: bool,
457 /// Default per-agent token budget. 0 means unlimited.
458 /// Override per-agent via ctx_session or programmatically.
459 #[serde(default)]
460 pub agent_token_budget: usize,
461 /// Optional shell command allowlist. When non-empty, only commands whose base binary
462 /// is in this list are permitted by ctx_shell. Empty = disable allowlist (allow all).
463 /// Default includes common dev tools. Set to `[]` to disable.
464 /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
465 #[serde(default = "default_shell_allowlist")]
466 pub shell_allowlist: Vec<String>,
467
468 /// Extra commands MERGED on top of the effective `shell_allowlist` without replacing
469 /// the defaults. Setting `shell_allowlist` replaces the whole built-in list (a common
470 /// footgun); entries here are purely additive, which is what `lean-ctx allow <cmd>`
471 /// writes. Only applied in restricted mode (when the base allowlist is non-empty).
472 #[serde(default)]
473 pub shell_allowlist_extra: Vec<String>,
474
475 /// When true, block command substitution ($(), backticks) and process substitution
476 /// (<(), >()) in shell arguments. When false (default), only warn via tracing.
477 /// Default false preserves backward compatibility — set true for maximum security.
478 #[serde(default)]
479 pub shell_strict_mode: bool,
480 /// Setup behavior: controls what gets injected during setup and updates.
481 #[serde(default)]
482 pub setup: SetupConfig,
483}
484
485impl Default for Config {
486 fn default() -> Self {
487 Self {
488 ultra_compact: false,
489 tee_mode: TeeMode::default(),
490 output_density: OutputDensity::default(),
491 checkpoint_interval: 15,
492 excluded_commands: Vec::new(),
493 passthrough_urls: Vec::new(),
494 custom_aliases: Vec::new(),
495 preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
496 slow_command_threshold_ms: 5000,
497 theme: serde_defaults::default_theme(),
498 cloud: CloudConfig::default(),
499 gain: GainConfig::default(),
500 cost: CostConfig::default(),
501 autonomy: AutonomyConfig::default(),
502 providers: ProvidersConfig::default(),
503 proxy: ProxyConfig::default(),
504 proxy_enabled: None,
505 proxy_port: None,
506 proxy_timeout_ms: None,
507 proxy_require_token: false,
508 buddy_enabled: serde_defaults::default_buddy_enabled(),
509 enable_wakeup_ctx: true,
510 redirect_exclude: Vec::new(),
511 disabled_tools: Vec::new(),
512 prefer_native_editor: false,
513 default_tool_categories: Vec::new(),
514 no_degrade: false,
515 delta_explicit: false,
516 profile: None,
517 tool_profile: None,
518 tools_enabled: Vec::new(),
519 persona: None,
520 loop_detection: LoopDetectionConfig::default(),
521 rules_scope: None,
522 rules_injection: None,
523 permission_inheritance: None,
524 extra_ignore_patterns: Vec::new(),
525 terse_agent: TerseAgent::default(),
526 compression_level: CompressionLevel::default(),
527 compression_aggressiveness: None,
528 archive: ArchiveConfig::default(),
529 memory: MemoryPolicy::default(),
530 allow_paths: Vec::new(),
531 allow_ide_config_dirs: false,
532 extra_roots: Vec::new(),
533 content_defined_chunking: false,
534 minimal_overhead: true,
535 symbol_map_auto: false,
536 structure_first: false,
537 auto_mode_learning: false,
538 team_url: None,
539 team_token: None,
540 team_auto_push: false,
541 journal_enabled: true,
542 auto_capture: true,
543 search: crate::core::hybrid_search::HybridConfig::default(),
544 graph: GraphConfig::default(),
545 skillify: SkillifyConfig::default(),
546 summaries: SummariesConfig::default(),
547 llm: crate::core::llm_enhance::LlmConfig::default(),
548 embedding: EmbeddingConfig::default(),
549 shell_hook_disabled: false,
550 shadow_mode: false,
551 shell_activation: ShellActivation::default(),
552 update_check_disabled: false,
553 updates: UpdatesConfig::default(),
554 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
555 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
556 memory_profile: MemoryProfile::default(),
557 memory_cleanup: MemoryCleanup::default(),
558 max_ram_percent: serde_defaults::default_max_ram_percent(),
559 max_disk_mb: 0,
560 max_staleness_days: 0,
561 max_index_threads: 0,
562 savings_footer: SavingsFooter::default(),
563 project_root: None,
564 lsp: std::collections::HashMap::new(),
565 ide_paths: HashMap::new(),
566 model_context_windows: HashMap::new(),
567 response_verbosity: ResponseVerbosity::default(),
568 bypass_hints: None,
569 cache_policy: None,
570 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
571 secret_detection: SecretDetectionConfig::default(),
572 sensitivity: crate::core::sensitivity::SensitivityConfig::default(),
573 gateway: crate::core::gateway::GatewayConfig::default(),
574 allow_auto_reroot: false,
575 path_jail: None,
576 sandbox_level: 0,
577 reference_results: false,
578 agent_token_budget: 0,
579 shell_allowlist: default_shell_allowlist(),
580 shell_allowlist_extra: Vec::new(),
581 shell_strict_mode: false,
582 setup: SetupConfig::default(),
583 }
584 }
585}
586
587/// Holds the most recent global `config.toml` parse error, if the file currently
588/// fails to parse. When that happens `Config::load()` silently falls back to the
589/// built-in defaults and only logs to stderr — which is invisible over an MCP/stdio
590/// transport. Recording it here lets callers (e.g. the shell-allowlist diagnostic
591/// and `lean-ctx doctor`) surface "you're on defaults because your config is broken".
592static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
593
594/// Returns the most recent global config parse error, or `None` if the current
595/// `config.toml` parsed successfully (or no config file exists).
596#[must_use]
597pub fn last_config_parse_error() -> Option<String> {
598 LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
599}
600
601fn record_parse_error(err: Option<String>) {
602 if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
603 *guard = err;
604 }
605}
606
607impl Config {
608 /// Returns the effective rules scope, preferring env var over config file.
609 pub fn rules_scope_effective(&self) -> RulesScope {
610 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
611 .ok()
612 .or_else(|| self.rules_scope.clone())
613 .unwrap_or_default();
614 match raw.trim().to_lowercase().as_str() {
615 "global" => RulesScope::Global,
616 "project" => RulesScope::Project,
617 _ => RulesScope::Both,
618 }
619 }
620
621 /// Returns the effective rules injection mode, preferring env var over config.
622 /// Default is `Shared` (zero-config discovery via a CLAUDE.md/CODEBUDDY.md/AGENTS.md block).
623 pub fn rules_injection_effective(&self) -> RulesInjection {
624 let raw = std::env::var("LEAN_CTX_RULES_INJECTION")
625 .ok()
626 .or_else(|| self.rules_injection.clone())
627 .unwrap_or_default();
628 match raw.trim().to_lowercase().as_str() {
629 "dedicated" => RulesInjection::Dedicated,
630 "off" | "none" | "disabled" => RulesInjection::Off,
631 _ => RulesInjection::Shared,
632 }
633 }
634
635 /// Returns the effective permission-inheritance mode, preferring the
636 /// `LEAN_CTX_PERMISSION_INHERITANCE` env var over config. Default is `Off`.
637 /// Accepts `on`/`true`/`1` as enabled.
638 #[must_use]
639 pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
640 let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
641 .ok()
642 .or_else(|| self.permission_inheritance.clone())
643 .unwrap_or_default();
644 match raw.trim().to_lowercase().as_str() {
645 "on" | "true" | "1" | "inherit" => PermissionInheritance::On,
646 _ => PermissionInheritance::Off,
647 }
648 }
649
650 /// True when lean-ctx should inject its rules via each agent's dedicated,
651 /// non-polluting auto-load path *and* global rules are in scope.
652 ///
653 /// Gates the Claude/Codex `SessionStart` `additionalContext` summary: it
654 /// stands in for the (now-skipped) shared CLAUDE.md/CODEBUDDY.md/AGENTS.md block, so it
655 /// only fires when injection is `Dedicated` and the scope isn't project-only.
656 #[must_use]
657 pub fn dedicated_session_context_active(&self) -> bool {
658 self.rules_injection_effective() == RulesInjection::Dedicated
659 && self.rules_scope_effective() != RulesScope::Project
660 }
661
662 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
663 val.split(',')
664 .map(|s| s.trim().to_string())
665 .filter(|s| !s.is_empty())
666 .collect()
667 }
668
669 /// Returns the effective disabled tools list, preferring env var over config
670 /// file. When `prefer_native_editor` is active, the lean-ctx edit tools are
671 /// folded in so they are hidden from `list_tools` (#454).
672 pub fn disabled_tools_effective(&self) -> Vec<String> {
673 let mut list = if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
674 Self::parse_disabled_tools_env(&val)
675 } else {
676 self.disabled_tools.clone()
677 };
678 if self.prefer_native_editor_effective() {
679 for name in EDIT_TOOL_NAMES {
680 if !list.iter().any(|t| t == name) {
681 list.push((*name).to_string());
682 }
683 }
684 }
685 list
686 }
687
688 /// Whether lean-ctx edit operations are disabled in favour of the host's
689 /// native editor (#454). `LEAN_CTX_PREFER_NATIVE_EDITOR` wins over config.
690 pub fn prefer_native_editor_effective(&self) -> bool {
691 match std::env::var("LEAN_CTX_PREFER_NATIVE_EDITOR") {
692 Ok(raw) => matches!(
693 raw.trim().to_lowercase().as_str(),
694 "1" | "true" | "yes" | "on"
695 ),
696 Err(_) => self.prefer_native_editor,
697 }
698 }
699
700 /// Cap on the rayon index-build worker threads. `LEANCTX_INDEX_THREADS` wins
701 /// over config; `0` means "no cap" — rayon's all-cores default is kept.
702 pub fn max_index_threads_effective(&self) -> usize {
703 std::env::var("LEANCTX_INDEX_THREADS")
704 .ok()
705 .and_then(|raw| raw.trim().parse::<usize>().ok())
706 .unwrap_or(self.max_index_threads)
707 }
708
709 /// Whether `name` is a lean-ctx edit operation that must be blocked from
710 /// dispatch (direct and via `ctx_call`) when [`Self::prefer_native_editor_effective`]
711 /// is set (#454). Read/search/shell/memory tools are never blocked.
712 pub fn edit_tool_blocked(&self, name: &str) -> bool {
713 self.prefer_native_editor_effective() && EDIT_TOOL_NAMES.contains(&name)
714 }
715
716 /// Returns `true` if minimal overhead is enabled via env var or config.
717 pub fn minimal_overhead_effective(&self) -> bool {
718 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
719 }
720
721 /// Returns `true` if structure-first auto reads are enabled.
722 ///
723 /// The `LEAN_CTX_STRUCTURE_FIRST` env var wins over the config field, and
724 /// accepts the usual truthy/falsy spellings so a harness can flip it per run
725 /// (`LEAN_CTX_STRUCTURE_FIRST=0` forces it off even if config enables it).
726 pub fn structure_first_effective(&self) -> bool {
727 match std::env::var("LEAN_CTX_STRUCTURE_FIRST") {
728 Ok(raw) => matches!(
729 raw.trim().to_lowercase().as_str(),
730 "1" | "true" | "yes" | "on"
731 ),
732 Err(_) => self.structure_first,
733 }
734 }
735
736 /// Returns `true` when the adaptive learning signals may participate in
737 /// `auto` mode resolution (#683). Off by default for a deterministic,
738 /// I/O-light cascade; the `LEAN_CTX_AUTO_MODE_LEARNING` env var wins over the
739 /// config field and accepts the usual truthy/falsy spellings.
740 pub fn auto_mode_learning_effective(&self) -> bool {
741 match std::env::var("LEAN_CTX_AUTO_MODE_LEARNING") {
742 Ok(raw) => matches!(
743 raw.trim().to_lowercase().as_str(),
744 "1" | "true" | "yes" | "on"
745 ),
746 Err(_) => self.auto_mode_learning,
747 }
748 }
749
750 /// Returns `true` if minimal overhead should be enabled for this MCP client.
751 ///
752 /// This is a superset of `minimal_overhead_effective()`:
753 /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
754 /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
755 /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
756 /// large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
757 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
758 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
759 match raw.trim().to_lowercase().as_str() {
760 "minimal" => return true,
761 "full" => return self.minimal_overhead_effective(),
762 _ => {}
763 }
764 }
765
766 if self.minimal_overhead_effective() {
767 return true;
768 }
769
770 let client_lower = client_name.trim().to_lowercase();
771 if !client_lower.is_empty() {
772 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
773 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
774 if !needle.is_empty() && client_lower.contains(&needle) {
775 return true;
776 }
777 }
778 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
779 return true;
780 }
781 }
782
783 let model = std::env::var("LEAN_CTX_MODEL")
784 .or_else(|_| std::env::var("LCTX_MODEL"))
785 .unwrap_or_default();
786 let model = model.trim().to_lowercase();
787 if !model.is_empty() {
788 let m = model.replace(['_', ' '], "-");
789 if m.contains("minimax")
790 || m.contains("mini-max")
791 || m.contains("m2.7")
792 || m.contains("m2-7")
793 {
794 return true;
795 }
796 }
797
798 false
799 }
800
801 /// Returns `true` if shell hook injection is disabled via env var or config.
802 pub fn shell_hook_disabled_effective(&self) -> bool {
803 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
804 }
805
806 /// Returns the effective shell activation mode (env var > config > default).
807 pub fn shell_activation_effective(&self) -> ShellActivation {
808 ShellActivation::effective(self)
809 }
810
811 /// Returns `true` if the daily update check is disabled via env var or config.
812 pub fn update_check_disabled_effective(&self) -> bool {
813 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
814 }
815
816 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
817 let mut policy = self.memory.clone();
818 policy.apply_env_overrides();
819
820 // Scale memory limits proportionally when max_disk_mb is set
821 // and individual limits are still at their defaults.
822 let budget = self.max_disk_mb_effective();
823 if budget > 0 {
824 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
825 let default_policy = MemoryPolicy::default();
826 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
827 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
828 }
829 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
830 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
831 }
832 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
833 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
834 }
835 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
836 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
837 }
838 }
839
840 policy.validate()?;
841 Ok(policy)
842 }
843
844 /// Returns the effective set of default tool categories.
845 /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
846 pub fn default_tool_categories_effective(&self) -> Vec<String> {
847 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
848 return val
849 .split(',')
850 .map(|s| s.trim().to_lowercase())
851 .filter(|s| !s.is_empty())
852 .collect();
853 }
854 if !self.default_tool_categories.is_empty() {
855 return self
856 .default_tool_categories
857 .iter()
858 .map(|s| s.to_lowercase())
859 .collect();
860 }
861 vec!["core".to_string(), "session".to_string()]
862 }
863
864 /// Returns the effective tool profile.
865 /// Priority: LEAN_CTX_TOOL_PROFILE env > config tool_profile > config
866 /// tools_enabled > active persona's tool surface > power.
867 ///
868 /// Explicit settings win (backward compatible); when none are set, the
869 /// active persona supplies the tool surface (the `coding` default resolves
870 /// to `power`, so existing installs are unaffected).
871 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
872 super::persona::Persona::resolve(self).effective_tool_profile(self)
873 }
874
875 /// Returns `true` if all automatic read-mode degradation is disabled.
876 /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
877 pub fn no_degrade_effective(&self) -> bool {
878 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
879 return val == "1" || val.eq_ignore_ascii_case("true");
880 }
881 self.no_degrade
882 }
883
884 /// Returns `true` if explicit `full`/`lines:N-M` re-reads of
885 /// cached-but-changed files should be served as deltas (`mode=diff`)
886 /// instead of re-emitting full content.
887 ///
888 /// Checks the `LCTX_DELTA_EXPLICIT` env var first, then the config.toml
889 /// field. Unlike a presence-only knob, an explicit `0`/`false` in the env
890 /// forces the feature OFF even when the config field is `true`, so the env
891 /// can fully override config in both directions.
892 pub fn delta_explicit_effective(&self) -> bool {
893 if let Ok(val) = std::env::var("LCTX_DELTA_EXPLICIT") {
894 return val == "1" || val.eq_ignore_ascii_case("true");
895 }
896 self.delta_explicit
897 }
898
899 /// Effective max_disk_mb from env or config.
900 pub fn max_disk_mb_effective(&self) -> u64 {
901 std::env::var("LEAN_CTX_MAX_DISK_MB")
902 .ok()
903 .and_then(|v| v.parse().ok())
904 .unwrap_or(self.max_disk_mb)
905 }
906
907 /// Effective max_staleness_days from env or config.
908 pub fn max_staleness_days_effective(&self) -> u32 {
909 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
910 .ok()
911 .and_then(|v| v.parse().ok())
912 .unwrap_or(self.max_staleness_days)
913 }
914
915 /// Archive max_disk_mb derived from simplified max_disk_mb if the detail
916 /// value is still at its default. Explicit overrides take priority.
917 pub fn archive_max_disk_mb_effective(&self) -> u64 {
918 let budget = self.max_disk_mb_effective();
919 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
920 budget * 25 / 100
921 } else {
922 self.archive.max_disk_mb
923 }
924 }
925
926 /// Archive max_age_hours derived from max_staleness_days if the detail
927 /// value is still at its default. Explicit overrides take priority.
928 pub fn archive_max_age_hours_effective(&self) -> u64 {
929 let staleness = self.max_staleness_days_effective();
930 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
931 staleness as u64 * 24
932 } else {
933 self.archive.max_age_hours
934 }
935 }
936
937 /// Effective on-disk ceiling (MB) for the persisted BM25 index. Single source
938 /// of truth for `save`/`load`, `cache prune`, and the doctor health check.
939 ///
940 /// Priority: explicit `bm25_max_cache_mb` › `max_disk_mb` budget (10%) ›
941 /// generous default ([`DEFAULT_BM25_PERSIST_MB`]). The default is decoupled
942 /// from the RAM profile so large repos persist instead of rebuilding forever
943 /// (issue #249).
944 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
945 // Explicit per-key override always wins.
946 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
947 return self.bm25_max_cache_mb;
948 }
949 // Otherwise derive from an explicit overall disk budget when present …
950 let budget = self.max_disk_mb_effective();
951 if budget > 0 {
952 return budget * 10 / 100;
953 }
954 // … else fall back to the generous, profile-independent disk default.
955 DEFAULT_BM25_PERSIST_MB
956 }
957}
958
959impl Config {
960 /// Returns the path to the global config file (`$XDG_CONFIG_HOME/lean-ctx/config.toml`).
961 ///
962 /// Resolves via [`crate::core::paths::config_dir`] so config lives in the
963 /// RO-safe config category. Behavior-neutral today: `config_dir()` equals the
964 /// legacy data dir for existing/single-dir installs (GH #408 / GL #602).
965 pub fn path() -> Option<PathBuf> {
966 crate::core::paths::config_dir()
967 .ok()
968 .map(|d| d.join("config.toml"))
969 }
970
971 /// Returns the path to the project-local config override file.
972 pub fn local_path(project_root: &str) -> PathBuf {
973 PathBuf::from(project_root).join(".lean-ctx.toml")
974 }
975
976 fn find_project_root() -> Option<String> {
977 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
978 ROOT_CACHE
979 .get_or_init(Self::find_project_root_inner)
980 .clone()
981 }
982
983 fn find_project_root_inner() -> Option<String> {
984 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
985 && !env_root.is_empty()
986 {
987 return Some(env_root);
988 }
989
990 let cwd = std::env::current_dir().ok();
991
992 if let Some(root) =
993 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
994 {
995 let root_path = std::path::Path::new(&root);
996 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
997 // Route the marker probe through the TCC-guarded helper and never
998 // adopt a ~/Documents project root from a launchd-standalone process
999 // (#356): doing so would later stat its `.lean-ctx.toml`/markers and
1000 // pop the macOS privacy prompt in lean-ctx's own name.
1001 let has_marker = crate::core::pathutil::has_project_marker(root_path);
1002
1003 if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
1004 {
1005 return Some(root);
1006 }
1007 }
1008
1009 if let Some(ref cwd) = cwd {
1010 // A launchd-standalone process must not shell out to `git` (which
1011 // stats the working tree) or adopt cwd as the project root when cwd
1012 // is under a TCC-protected dir (#356).
1013 let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
1014 let git_root = if may_probe_cwd {
1015 std::process::Command::new("git")
1016 .args(["rev-parse", "--show-toplevel"])
1017 .current_dir(cwd)
1018 .stdout(std::process::Stdio::piped())
1019 .stderr(std::process::Stdio::null())
1020 .output()
1021 .ok()
1022 .and_then(|o| {
1023 if o.status.success() {
1024 String::from_utf8(o.stdout)
1025 .ok()
1026 .map(|s| s.trim().to_string())
1027 } else {
1028 None
1029 }
1030 })
1031 } else {
1032 None
1033 };
1034 if let Some(root) = git_root {
1035 return Some(root);
1036 }
1037 if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1038 return Some(cwd.to_string_lossy().to_string());
1039 }
1040 }
1041 None
1042 }
1043
1044 /// Loads config from disk with caching, merging global + project-local overrides.
1045 ///
1046 /// The cache is keyed on a **content hash** of the global + project-local
1047 /// files, not their mtime. mtime-only invalidation silently served a stale
1048 /// `Config` whenever a content edit preserved the mtime (coarse filesystem
1049 /// mtime resolution, `cp -p`, atomic save-then-rename, two edits within the
1050 /// same second). A long-lived MCP server then kept the old value (e.g.
1051 /// `path_jail`) while a fresh `lean-ctx doctor` process — with an empty
1052 /// cache — saw the new one (#406). Config files are tiny, so reading +
1053 /// hashing them on every load is negligible and guarantees liveness.
1054 pub fn load() -> Self {
1055 static CACHE: Mutex<Option<(Config, Option<String>, Option<String>)>> = Mutex::new(None);
1056
1057 let Some(path) = Self::path() else {
1058 return Self::default();
1059 };
1060
1061 let local_path = Self::find_project_root().map(|r| Self::local_path(&r));
1062
1063 // Read raw content up front so the cache key is a content hash.
1064 let global_content = std::fs::read_to_string(&path).ok();
1065 // TCC (#356): never read a project-local `.lean-ctx.toml` under
1066 // ~/Documents from a launchd-standalone process — the read pops the
1067 // macOS privacy prompt. `find_project_root` already avoids returning
1068 // such roots; this also guards the explicit `LEAN_CTX_PROJECT_ROOT` path.
1069 let local_content = local_path
1070 .as_ref()
1071 .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
1072 .and_then(|p| std::fs::read_to_string(p).ok());
1073
1074 let global_hash = global_content.as_deref().map(crate::core::hasher::hash_str);
1075 let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
1076
1077 if let Ok(guard) = CACHE.lock()
1078 && let Some((ref cfg, ref cached_global, ref cached_local)) = *guard
1079 && *cached_global == global_hash
1080 && *cached_local == local_hash
1081 {
1082 return cfg.clone();
1083 }
1084
1085 let mut cfg: Config = if let Some(ref content) = global_content {
1086 match toml::from_str(content) {
1087 Ok(c) => {
1088 record_parse_error(None);
1089 c
1090 }
1091 Err(e) => {
1092 record_parse_error(Some(format!("{e}")));
1093 tracing::warn!("config parse error in {}: {e}", path.display());
1094 eprintln!(
1095 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
1096 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1097 path.display()
1098 );
1099 Self::default()
1100 }
1101 }
1102 } else {
1103 record_parse_error(None);
1104 Self::default()
1105 };
1106
1107 if let Some(ref local) = local_content {
1108 cfg.merge_local(local);
1109 }
1110
1111 if let Ok(mut guard) = CACHE.lock() {
1112 *guard = Some((cfg.clone(), global_hash, local_hash));
1113 }
1114
1115 cfg
1116 }
1117
1118 fn merge_local(&mut self, local_toml: &str) {
1119 let local: Config = match toml::from_str(local_toml) {
1120 Ok(c) => c,
1121 Err(e) => {
1122 tracing::warn!("local config parse error: {e}");
1123 eprintln!(
1124 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
1125 Local overrides skipped.\x1b[0m"
1126 );
1127 return;
1128 }
1129 };
1130 if local.ultra_compact {
1131 self.ultra_compact = true;
1132 }
1133 if local.tee_mode != TeeMode::default() {
1134 self.tee_mode = local.tee_mode;
1135 }
1136 if local.output_density != OutputDensity::default() {
1137 self.output_density = local.output_density;
1138 }
1139 if local.checkpoint_interval != 15 {
1140 self.checkpoint_interval = local.checkpoint_interval;
1141 }
1142 if !local.excluded_commands.is_empty() {
1143 self.excluded_commands.extend(local.excluded_commands);
1144 }
1145 if !local.passthrough_urls.is_empty() {
1146 self.passthrough_urls.extend(local.passthrough_urls);
1147 }
1148 if !local.custom_aliases.is_empty() {
1149 self.custom_aliases.extend(local.custom_aliases);
1150 }
1151 // Additive merge with dedup: project-local config can add formats on top
1152 // of the global default (`["toon"]`) without re-listing it.
1153 for fmt in local.preserve_compact_formats {
1154 if !self
1155 .preserve_compact_formats
1156 .iter()
1157 .any(|f| f.eq_ignore_ascii_case(&fmt))
1158 {
1159 self.preserve_compact_formats.push(fmt);
1160 }
1161 }
1162 if local.slow_command_threshold_ms != 5000 {
1163 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1164 }
1165 if local.theme != "default" {
1166 self.theme = local.theme;
1167 }
1168 if !local.buddy_enabled {
1169 self.buddy_enabled = false;
1170 }
1171 if !local.enable_wakeup_ctx {
1172 self.enable_wakeup_ctx = false;
1173 }
1174 if !local.redirect_exclude.is_empty() {
1175 self.redirect_exclude.extend(local.redirect_exclude);
1176 }
1177 if !local.disabled_tools.is_empty() {
1178 self.disabled_tools.extend(local.disabled_tools);
1179 }
1180 if local.prefer_native_editor {
1181 self.prefer_native_editor = true;
1182 }
1183 if !local.extra_ignore_patterns.is_empty() {
1184 self.extra_ignore_patterns
1185 .extend(local.extra_ignore_patterns);
1186 }
1187 if local.rules_scope.is_some() {
1188 self.rules_scope = local.rules_scope;
1189 }
1190 if local.rules_injection.is_some() {
1191 self.rules_injection = local.rules_injection;
1192 }
1193 if local.permission_inheritance.is_some() {
1194 self.permission_inheritance = local.permission_inheritance;
1195 }
1196 if local.proxy.anthropic_upstream.is_some() {
1197 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1198 }
1199 if local.proxy.openai_upstream.is_some() {
1200 self.proxy.openai_upstream = local.proxy.openai_upstream;
1201 }
1202 if local.proxy.gemini_upstream.is_some() {
1203 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1204 }
1205 if !local.autonomy.enabled {
1206 self.autonomy.enabled = false;
1207 }
1208 if !local.autonomy.auto_preload {
1209 self.autonomy.auto_preload = false;
1210 }
1211 if !local.autonomy.auto_dedup {
1212 self.autonomy.auto_dedup = false;
1213 }
1214 if !local.autonomy.auto_related {
1215 self.autonomy.auto_related = false;
1216 }
1217 if !local.autonomy.auto_consolidate {
1218 self.autonomy.auto_consolidate = false;
1219 }
1220 if local.autonomy.silent_preload {
1221 self.autonomy.silent_preload = true;
1222 }
1223 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1224 self.autonomy.silent_preload = false;
1225 }
1226 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1227 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1228 }
1229 if local.autonomy.consolidate_every_calls
1230 != AutonomyConfig::default().consolidate_every_calls
1231 {
1232 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1233 }
1234 if local.autonomy.consolidate_cooldown_secs
1235 != AutonomyConfig::default().consolidate_cooldown_secs
1236 {
1237 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1238 }
1239 if !local.autonomy.cognition_loop_enabled {
1240 self.autonomy.cognition_loop_enabled = false;
1241 }
1242 if local.autonomy.cognition_loop_interval_secs
1243 != AutonomyConfig::default().cognition_loop_interval_secs
1244 {
1245 self.autonomy.cognition_loop_interval_secs =
1246 local.autonomy.cognition_loop_interval_secs;
1247 }
1248 if local.autonomy.cognition_loop_max_steps
1249 != AutonomyConfig::default().cognition_loop_max_steps
1250 {
1251 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1252 }
1253 if local_toml.contains("compression_level") {
1254 self.compression_level = local.compression_level;
1255 }
1256 if local_toml.contains("compression_aggressiveness") {
1257 self.compression_aggressiveness = local.compression_aggressiveness;
1258 }
1259 if local_toml.contains("terse_agent") {
1260 self.terse_agent = local.terse_agent;
1261 }
1262 if !local.archive.enabled {
1263 self.archive.enabled = false;
1264 }
1265 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1266 self.archive.threshold_chars = local.archive.threshold_chars;
1267 }
1268 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1269 self.archive.max_age_hours = local.archive.max_age_hours;
1270 }
1271 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1272 self.archive.max_disk_mb = local.archive.max_disk_mb;
1273 }
1274 if !local.archive.ephemeral {
1275 self.archive.ephemeral = false;
1276 }
1277 if local.archive.ephemeral_min_tokens != ArchiveConfig::default().ephemeral_min_tokens {
1278 self.archive.ephemeral_min_tokens = local.archive.ephemeral_min_tokens;
1279 }
1280 let mem_def = MemoryPolicy::default();
1281 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1282 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1283 }
1284 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1285 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1286 }
1287 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1288 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1289 }
1290 if local.memory.knowledge.contradiction_threshold
1291 != mem_def.knowledge.contradiction_threshold
1292 {
1293 self.memory.knowledge.contradiction_threshold =
1294 local.memory.knowledge.contradiction_threshold;
1295 }
1296
1297 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1298 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1299 }
1300 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1301 {
1302 self.memory.episodic.max_actions_per_episode =
1303 local.memory.episodic.max_actions_per_episode;
1304 }
1305 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1306 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1307 }
1308
1309 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1310 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1311 }
1312 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1313 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1314 }
1315 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1316 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1317 }
1318 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1319 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1320 }
1321
1322 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1323 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1324 }
1325 if local.memory.lifecycle.low_confidence_threshold
1326 != mem_def.lifecycle.low_confidence_threshold
1327 {
1328 self.memory.lifecycle.low_confidence_threshold =
1329 local.memory.lifecycle.low_confidence_threshold;
1330 }
1331 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1332 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1333 }
1334 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1335 self.memory.lifecycle.similarity_threshold =
1336 local.memory.lifecycle.similarity_threshold;
1337 }
1338
1339 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1340 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1341 }
1342 if !local.allow_paths.is_empty() {
1343 self.allow_paths.extend(local.allow_paths);
1344 }
1345 if !local.extra_roots.is_empty() {
1346 self.extra_roots.extend(local.extra_roots);
1347 }
1348 if local.minimal_overhead {
1349 self.minimal_overhead = true;
1350 }
1351 if local.shell_hook_disabled {
1352 self.shell_hook_disabled = true;
1353 }
1354 if local.shell_activation != ShellActivation::default() {
1355 self.shell_activation = local.shell_activation.clone();
1356 }
1357 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1358 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1359 }
1360 if local.memory_profile != MemoryProfile::default() {
1361 self.memory_profile = local.memory_profile;
1362 }
1363 if local.memory_cleanup != MemoryCleanup::default() {
1364 self.memory_cleanup = local.memory_cleanup;
1365 }
1366 // Only override when the local file actually defines `shell_allowlist`.
1367 // The field carries `#[serde(default = "default_shell_allowlist")]`, so a
1368 // local `.lean-ctx.toml` that omits the key still deserializes to the full
1369 // 201-entry built-in list — an `is_empty()` guard would then silently clobber
1370 // a deliberately shorter global allowlist with the defaults. Comparing against
1371 // the default (the same pattern used for every other merged field) treats
1372 // "omitted" as "no override".
1373 if local.shell_allowlist != default_shell_allowlist() {
1374 self.shell_allowlist = local.shell_allowlist;
1375 }
1376 if !local.shell_allowlist_extra.is_empty() {
1377 self.shell_allowlist_extra
1378 .extend(local.shell_allowlist_extra);
1379 }
1380 if !local.default_tool_categories.is_empty() {
1381 self.default_tool_categories = local.default_tool_categories;
1382 }
1383 if local.tool_profile.is_some() {
1384 self.tool_profile = local.tool_profile;
1385 }
1386 if !local.tools_enabled.is_empty() {
1387 self.tools_enabled = local.tools_enabled;
1388 }
1389 if local.no_degrade {
1390 self.no_degrade = true;
1391 }
1392 if local.delta_explicit {
1393 self.delta_explicit = true;
1394 }
1395 if local.profile.is_some() {
1396 self.profile = local.profile;
1397 }
1398 if local.proxy_timeout_ms.is_some() {
1399 self.proxy_timeout_ms = local.proxy_timeout_ms;
1400 }
1401 }
1402
1403 /// Loads ONLY the global config file — never merging project-local
1404 /// `.lean-ctx.toml` overrides, and bypassing the in-memory cache. Every
1405 /// PERSIST path must use this (or [`Config::update_global`]): [`Config::load`]
1406 /// folds per-project overrides into the struct, and [`Config::save`] writes
1407 /// the whole struct back to the GLOBAL file — so a `load → mutate → save`
1408 /// round-trip silently leaks per-project values (and, historically, reset
1409 /// customized keys) into the global config (#443). Reading global-only makes
1410 /// the save leak-free by construction.
1411 pub fn load_global() -> Self {
1412 Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
1413 }
1414
1415 /// Path-parameterized core of [`Config::load_global`] (unit-testable without
1416 /// the real config dir). Missing, empty, or unparseable files yield
1417 /// defaults; persisting callers that must not clobber a corrupt file use
1418 /// [`Config::update_global`], which refuses instead.
1419 fn load_global_from(path: &Path) -> Self {
1420 match std::fs::read_to_string(path) {
1421 Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).unwrap_or_default(),
1422 _ => Self::default(),
1423 }
1424 }
1425
1426 /// Safely mutate and persist the GLOBAL config. Reads the global file only
1427 /// (no project-local merge), applies `f`, then writes minimally. Refuses
1428 /// (returns `Err`) when the file exists but is unparseable, so a typo can
1429 /// never clobber a customized config (#443). Returns the saved `Config`.
1430 ///
1431 /// This is the canonical persistence entry point: prefer it over
1432 /// `Config::load()` followed by `save()`, which leaks project-local
1433 /// overrides into the global file.
1434 pub fn update_global<F>(f: F) -> std::result::Result<Self, super::error::LeanCtxError>
1435 where
1436 F: FnOnce(&mut Self),
1437 {
1438 let path = Self::path().ok_or_else(|| {
1439 super::error::LeanCtxError::Config("cannot determine home directory".into())
1440 })?;
1441 Self::update_global_at(&path, f)
1442 }
1443
1444 /// Path-parameterized core of [`Config::update_global`] (unit-testable).
1445 fn update_global_at<F>(
1446 path: &Path,
1447 f: F,
1448 ) -> std::result::Result<Self, super::error::LeanCtxError>
1449 where
1450 F: FnOnce(&mut Self),
1451 {
1452 let mut cfg = match std::fs::read_to_string(path) {
1453 Ok(raw) if !raw.trim().is_empty() => toml::from_str::<Self>(&raw).map_err(|e| {
1454 super::error::LeanCtxError::Config(format!(
1455 "refusing to modify an unparseable config.toml ({e}); fix it \
1456 manually or run `lean-ctx doctor --fix`, then retry"
1457 ))
1458 })?,
1459 _ => Self::default(),
1460 };
1461 f(&mut cfg);
1462 cfg.save_to(path)?;
1463 Ok(cfg)
1464 }
1465
1466 /// Persists the current config to the global config file.
1467 ///
1468 /// Preserves user comments, formatting, and unknown keys, keeps the file
1469 /// minimal (defaults that were never set on disk stay implicit), and writes
1470 /// atomically with a `.bak` backup so customizations are always recoverable.
1471 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1472 let path = Self::path().ok_or_else(|| {
1473 super::error::LeanCtxError::Config("cannot determine home directory".into())
1474 })?;
1475 self.save_to(&path)
1476 }
1477
1478 /// Path-parameterized core of [`Config::save`] (unit-testable).
1479 fn save_to(&self, path: &Path) -> std::result::Result<(), super::error::LeanCtxError> {
1480 if let Some(parent) = path.parent() {
1481 std::fs::create_dir_all(parent)?;
1482 }
1483 let content = toml::to_string_pretty(self)
1484 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1485 // Baseline = what loading an empty config yields. This honors serde's
1486 // field-level `#[serde(default)]` (which can diverge from the struct's
1487 // `Default` impl), so minimal mode skips exactly the keys that a fresh
1488 // load would produce — no spurious lines on save.
1489 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1490 let defaults = toml::to_string_pretty(&baseline)
1491 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1492 crate::config_io::write_toml_preserving_minimal(path, &content, &defaults)
1493 .map_err(super::error::LeanCtxError::Config)?;
1494 Ok(())
1495 }
1496
1497 /// Formats the current config as a human-readable string with file paths.
1498 pub fn show(&self) -> String {
1499 let global_path = Self::path().map_or_else(
1500 || "~/.lean-ctx/config.toml".to_string(),
1501 |p| p.to_string_lossy().to_string(),
1502 );
1503 let content = toml::to_string_pretty(self).unwrap_or_default();
1504 let mut out = format!("Global config: {global_path}\n\n{content}");
1505
1506 if let Some(root) = Self::find_project_root() {
1507 let local = Self::local_path(&root);
1508 if local.exists() {
1509 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1510 } else {
1511 out.push_str(&format!(
1512 "\n\nLocal config: not found (create {} to override per-project)\n",
1513 local.display()
1514 ));
1515 }
1516 }
1517 out
1518 }
1519}