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