Skip to main content

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