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