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