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 /// Index-time file filters (#735): include/exclude globs + gitignore
403 /// handling, applied by every index builder via `core::index_filter`.
404 #[serde(default)]
405 pub index: IndexConfig,
406 /// Skillify miner settings (#290): codify recurring patterns into rules.
407 #[serde(default)]
408 pub skillify: SkillifyConfig,
409 /// AI session-summary settings (#292): periodic, semantically-recallable summaries.
410 #[serde(default)]
411 pub summaries: SummariesConfig,
412 /// Optional LLM enhancement (query expansion, contradiction explanation).
413 #[serde(default)]
414 pub llm: crate::core::llm_enhance::LlmConfig,
415 /// Semantic-embedding engine settings (which local ONNX model to use).
416 #[serde(default)]
417 pub embedding: EmbeddingConfig,
418 /// Disable shell hook injection (the _lc() function that wraps CLI commands).
419 /// Override via LEAN_CTX_NO_HOOK env var.
420 #[serde(default)]
421 pub shell_hook_disabled: bool,
422 /// Shadow mode: transparently intercepts native tool calls (Read/Grep/Shell)
423 /// via hooks, strengthens MCP instructions to MUST-level, and activates
424 /// immediate bypass hints on first native tool use. Enables "transparent
425 /// replacement" so agents use ctx_* without explicit opt-in.
426 #[serde(default)]
427 pub shadow_mode: bool,
428 /// Opt-in (#520): write a human-readable debug log of intercepted MCP tool
429 /// calls and hook routing decisions (lean-ctx vs native, with reasons) to
430 /// `<state_dir>/logs/debug.log`. Override via the LEAN_CTX_DEBUG_LOG env var.
431 #[serde(default)]
432 pub debug_log: bool,
433 /// Controls when the shell hook auto-activates aliases.
434 /// - `agents-only`: (Default since #699) Aliases only active when an AI
435 /// agent env var is detected — transparent in plain human terminals.
436 /// - `always`: Aliases active in every interactive shell (pre-#699 default).
437 /// - `off`: Aliases never auto-activate (user must call `lean-ctx-on` manually).
438 ///
439 /// Override via `LEAN_CTX_SHELL_ACTIVATION` env var.
440 #[serde(default)]
441 pub shell_activation: ShellActivation,
442 /// Controls the native-Read → `ctx_read` redirect hook (#637).
443 /// - `auto`: (Default) redirect everywhere except hosts with a native
444 /// read-before-write guard (Claude Code / CodeBuddy), where the path-swap
445 /// would break native Write/Edit.
446 /// - `on`: always redirect (legacy behavior).
447 /// - `off`: never redirect native Read.
448 ///
449 /// Override via the `LEAN_CTX_READ_REDIRECT` env var.
450 #[serde(default)]
451 pub read_redirect: ReadRedirect,
452 /// Controls the PostToolUse native-Read re-read dedup (GL #1140).
453 /// - `auto`: (Default) replace only re-reads of unchanged files, and only on
454 /// guard hosts (Claude Code / CodeBuddy) where the PreToolUse redirect is
455 /// disabled — the guard-safe way to win the dedup savings back.
456 /// - `on`: dedup wherever the PostToolUse hook fires.
457 /// - `off`: never replace a Read result.
458 ///
459 /// Override via the `LEAN_CTX_READ_DEDUP` env var.
460 #[serde(default)]
461 pub read_dedup: ReadDedup,
462 /// Disable the daily version check against leanctx.com/version.txt.
463 /// Override via LEAN_CTX_NO_UPDATE_CHECK env var.
464 #[serde(default)]
465 pub update_check_disabled: bool,
466 #[serde(default)]
467 pub updates: UpdatesConfig,
468 /// Fixed-context budget accounting for `doctor overhead` / `gain` (#964).
469 #[serde(default)]
470 pub context: ContextConfig,
471 /// Maximum BM25 cache file size in MB. Indexes exceeding this are quarantined on load
472 /// and refused on save. Override via LEAN_CTX_BM25_MAX_CACHE_MB env var.
473 #[serde(default = "serde_defaults::default_bm25_max_cache_mb")]
474 pub bm25_max_cache_mb: u64,
475 /// Maximum number of files scanned by the lightweight JSON graph index.
476 /// 0 = unlimited (default). Set >0 to cap for constrained systems.
477 #[serde(default = "serde_defaults::default_graph_index_max_files")]
478 pub graph_index_max_files: u64,
479 /// Controls RAM vs feature trade-off. Values: "low", "balanced" (default), "performance".
480 /// Override via LEAN_CTX_MEMORY_PROFILE env var.
481 #[serde(default)]
482 pub memory_profile: MemoryProfile,
483 /// Controls how aggressively memory is freed when idle.
484 /// Values: "aggressive" (default, 5 min TTL), "shared" (30 min TTL for multi-IDE use).
485 /// Override via LEAN_CTX_MEMORY_CLEANUP env var.
486 #[serde(default)]
487 pub memory_cleanup: MemoryCleanup,
488 /// Maximum percentage of system RAM that lean-ctx may use (default: 5).
489 /// Override via LEAN_CTX_MAX_RAM_PERCENT env var.
490 #[serde(default = "serde_defaults::default_max_ram_percent")]
491 pub max_ram_percent: u8,
492 /// Simplified disk budget (MB). When set and detail values are at defaults,
493 /// distributes proportionally: archive=25%, bm25=10%, remainder for stores.
494 /// 0 = disabled (use individual settings). Override via LEAN_CTX_MAX_DISK_MB.
495 #[serde(default)]
496 pub max_disk_mb: u64,
497 /// Auto-purge data older than this many days. 0 = disabled.
498 /// Flows into archive.max_age_hours and lifecycle idle TTL.
499 #[serde(default)]
500 pub max_staleness_days: u32,
501 /// Cap on the rayon worker threads used by the CPU-heavy index build
502 /// (call graph etc.). 0 = rayon default (all cores). Set >0 to bound
503 /// per-instance CPU so a fleet of concurrent sessions can't saturate the
504 /// host on startup. Override via LEANCTX_INDEX_THREADS env var.
505 #[serde(default)]
506 pub max_index_threads: usize,
507 /// Controls visibility of token savings footers in tool output.
508 /// Values: "always" (default, show on every response), "never", "auto" (legacy compatibility).
509 /// Override via LEAN_CTX_SAVINGS_FOOTER or LEAN_CTX_SHOW_SAVINGS=1|0 env var.
510 #[serde(default)]
511 pub savings_footer: SavingsFooter,
512 /// Explicit project root override. When set, lean-ctx uses this instead of auto-detection.
513 /// This prevents accidental home-directory scans when running from $HOME.
514 /// Override via LEAN_CTX_PROJECT_ROOT env var.
515 #[serde(default)]
516 pub project_root: Option<String>,
517 /// LSP server overrides. Map language name to custom binary path.
518 /// Example: `[lsp]\nrust = "/opt/rust-analyzer"\npython = "~/.venvs/main/bin/pylsp"`
519 #[serde(default)]
520 pub lsp: std::collections::HashMap<String, String>,
521 /// Per-IDE allowed paths. Restricts which directories lean-ctx will scan/index for each IDE.
522 /// Example: `[ide_paths]\ncursor = ["/home/user/projects/app1"]\ncodex = ["/home/user/codex"]`
523 /// When set, only these paths are indexed for the matching agent. Global `allow_paths` still applies.
524 #[serde(default)]
525 pub ide_paths: HashMap<String, Vec<String>>,
526 /// Custom model context window overrides.
527 /// Example: `[model_context_windows]\n"my-custom-model" = 500000`
528 #[serde(default)]
529 pub model_context_windows: HashMap<String, usize>,
530 /// Controls how much detail tool responses include.
531 ///
532 /// - `full` (default): complete compressed output
533 /// - `headers_only`: metadata line only (path, mode, token count)
534 ///
535 /// Override via `LEAN_CTX_RESPONSE_VERBOSITY` env var.
536 #[serde(default)]
537 pub response_verbosity: ResponseVerbosity,
538 /// Bypass hint mode. When agents use native Read/Grep instead of lean-ctx tools,
539 /// a hint is appended to the next tool response.
540 /// Values: "on" (default), "off", "aggressive" (hint on every call, no cooldown).
541 /// Override via LEAN_CTX_BYPASS_HINTS env var.
542 #[serde(default)]
543 pub bypass_hints: Option<String>,
544 /// Cache policy for ctx_read. Controls behavior on cache hits.
545 /// Values: "aggressive" (default, 13-tok stubs + compaction-aware reset),
546 /// "safe" (delivers map instead of stub), "off" (no caching, always disk read).
547 /// Override via LEAN_CTX_CACHE_POLICY env var.
548 #[serde(default)]
549 pub cache_policy: Option<String>,
550 /// Token budget for the in-memory `ctx_read` cache. When the cached total
551 /// plus an incoming read would exceed this, lean-ctx evicts the least-valuable
552 /// entries *immediately* (RRF: recency × frequency × size) so the read always
553 /// proceeds — eviction is never deferred to the staleness TTL. `0` uses the
554 /// built-in default (500k). `LEAN_CTX_CACHE_MAX_TOKENS` env var overrides this.
555 #[serde(default)]
556 pub cache_max_tokens: usize,
557 /// Cross-project boundary policy.
558 /// Controls whether cross-project search/import is allowed and whether access is audited.
559 #[serde(default)]
560 pub boundary_policy: crate::core::memory_boundary::BoundaryPolicy,
561 #[serde(default)]
562 pub secret_detection: SecretDetectionConfig,
563 /// Per-item sensitivity model with a uniform policy floor (#212).
564 /// Disabled by default → fully no-op until `sensitivity.enabled = true`.
565 #[serde(default)]
566 pub sensitivity: crate::core::sensitivity::SensitivityConfig,
567 /// MCP Tool-Catalog Gateway (#210): aggregate + query-route downstream MCP
568 /// servers. Global-only (never merged from project-local config) and a full
569 /// no-op until `gateway.enabled = true`.
570 #[serde(default)]
571 pub gateway: crate::core::gateway::GatewayConfig,
572 /// Self-hosted org gateway server (`[gateway_server]`, enterprise#20):
573 /// deployment parameters for the usage cockpit — seat count for the
574 /// org-wide projection, display label, and the central admin API the local
575 /// cockpit may read from. All optional; absent = local-only behavior.
576 #[serde(default)]
577 pub gateway_server: GatewayServerConfig,
578 /// Addon ecosystem security floor (#863): install policy, registry-signature
579 /// requirement and sandboxing for spawned addon servers. Global-only (never
580 /// merged from project-local config) and fully permissive by default.
581 #[serde(default)]
582 pub addons: crate::core::addons::AddonsConfig,
583 /// Allow automatic project-root re-rooting when absolute paths outside the jail are seen.
584 /// When false (default), absolute paths outside the jail are rejected without re-rooting.
585 /// Override via LEAN_CTX_ALLOW_REROOT env var.
586 #[serde(default)]
587 pub allow_auto_reroot: bool,
588 /// Verbatim binary path/expression for generated agent-hook commands
589 /// (#708). Users who sync agent settings (`~/.claude/settings.json`, …)
590 /// across machines with different usernames need an env-based form like
591 /// `$HOME/.local/bin/lean-ctx` — agent hosts run hook commands through a
592 /// shell, which expands it. When set (env `LEAN_CTX_HOOK_BINARY` wins,
593 /// then this key), every hook writer emits the value verbatim instead of
594 /// the machine-absolute exe path, so `init`/`doctor --fix`/`update` stop
595 /// rewriting synced files into sync ping-pong. Autostart plists/services
596 /// and daemon spawns are NOT affected — launchd/systemd do not expand
597 /// shell variables, so those keep the real absolute path. Empty (default)
598 /// = automatic absolute-path resolution (#367).
599 #[serde(default)]
600 pub hook_binary: Option<String>,
601 /// Disable PathJail entirely by setting `path_jail = false` in config.toml.
602 /// Useful in container/Docker environments where the sandbox is the boundary.
603 /// (The former `LEAN_CTX_NO_JAIL=1` env override was removed in v3.7.3.)
604 #[serde(default)]
605 pub path_jail: Option<bool>,
606 /// Sandbox level for code execution (ctx_exec).
607 /// 0 = subprocess only (current), 1 = OS-level restriction (Seatbelt/Landlock).
608 /// Override via LEAN_CTX_SANDBOX_LEVEL env var.
609 #[serde(default)]
610 pub sandbox_level: u8,
611 /// When true, large tool outputs (>4000 chars) are stored as references
612 /// and a short URI is returned instead of the full content.
613 /// Override via LEAN_CTX_REFERENCE_RESULTS env var.
614 #[serde(default)]
615 pub reference_results: bool,
616 /// Default per-agent token budget. 0 means unlimited.
617 /// Override per-agent via ctx_session or programmatically.
618 #[serde(default)]
619 pub agent_token_budget: usize,
620 /// Optional shell command allowlist. When non-empty, only commands whose base binary
621 /// is in this list are permitted by ctx_shell. Empty = disable allowlist (allow all).
622 /// Default includes common dev tools. Set to `[]` to disable.
623 /// Override via LEAN_CTX_SHELL_ALLOWLIST env var (comma-separated).
624 #[serde(default = "default_shell_allowlist")]
625 pub shell_allowlist: Vec<String>,
626
627 /// Extra commands MERGED on top of the effective `shell_allowlist` without replacing
628 /// the defaults. Setting `shell_allowlist` replaces the whole built-in list (a common
629 /// footgun); entries here are purely additive, which is what `lean-ctx allow <cmd>`
630 /// writes. Only applied in restricted mode (when the base allowlist is non-empty).
631 #[serde(default)]
632 pub shell_allowlist_extra: Vec<String>,
633
634 /// When true, block command substitution ($(), backticks) and process substitution
635 /// (<(), >()) in shell arguments. When false (default), only warn via tracing.
636 /// Default false preserves backward compatibility — set true for maximum security.
637 #[serde(default)]
638 pub shell_strict_mode: bool,
639
640 /// Shell-security mode for ctx_shell / `lean-ctx -c` command gating (GL #788):
641 /// `enforce` (default, secure), `warn` (run checks, log violations, never
642 /// block) or `off` (skip the allowlist + dangerous-pattern blocks entirely —
643 /// a deliberate opt-out; compression stays active). Override via
644 /// LEAN_CTX_SHELL_SECURITY. `None` resolves to `enforce`.
645 #[serde(default)]
646 pub shell_security: Option<String>,
647
648 /// Default shell-command timeout in seconds for *normal* commands. `None`
649 /// resolves to the built-in 2-minute default; heavy builds/tests use
650 /// [`Config::shell_heavy_timeout_secs`]. Override via
651 /// `LEAN_CTX_SHELL_TIMEOUT_SECS` (`LEAN_CTX_SHELL_TIMEOUT_MS` still wins over
652 /// both, in milliseconds).
653 #[serde(default)]
654 pub shell_timeout_secs: Option<u64>,
655
656 /// Shell-command timeout in seconds for *heavy* commands (cargo build/test,
657 /// make, docker build, git commit/push, …). `None` resolves to the built-in
658 /// 10-minute ceiling. Override via `LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS`.
659 #[serde(default)]
660 pub shell_heavy_timeout_secs: Option<u64>,
661
662 /// When true, `ctx_shell` accepts shell file-write redirects (`>`, `>>`,
663 /// `tee`, heredoc-to-file, `curl -o`, `wget` default mode). Default false —
664 /// the native Write/Edit tool is preferred. Opt-in for power users who want
665 /// classic shell syntax; the real command gating (allowlist,
666 /// dangerous-pattern and interpreter-eval blocks) still applies. Override
667 /// via `LEAN_CTX_SHELL_ALLOW_WRITES=1`.
668 #[serde(default)]
669 pub shell_allow_writes: bool,
670
671 /// Setup behavior: controls what gets injected during setup and updates.
672 #[serde(default)]
673 pub setup: SetupConfig,
674}
675
676impl Default for Config {
677 fn default() -> Self {
678 Self {
679 ultra_compact: false,
680 tee_mode: TeeMode::default(),
681 recovery_hints: RecoveryHints::default(),
682 output_density: OutputDensity::default(),
683 checkpoint_interval: 15,
684 excluded_commands: Vec::new(),
685 passthrough_urls: Vec::new(),
686 custom_aliases: Vec::new(),
687 preserve_compact_formats: serde_defaults::default_preserve_compact_formats(),
688 crush_verbatim_json: false,
689 slow_command_threshold_ms: 5000,
690 theme: serde_defaults::default_theme(),
691 cloud: CloudConfig::default(),
692 gain: GainConfig::default(),
693 cost: CostConfig::default(),
694 code_health: CodeHealthConfig::default(),
695 autonomy: AutonomyConfig::default(),
696 providers: ProvidersConfig::default(),
697 proxy: ProxyConfig::default(),
698 proxy_enabled: None,
699 proxy_port: None,
700 proxy_timeout_ms: None,
701 proxy_require_token: false,
702 proxy_bind_host: None,
703 proxy_allowed_hosts: Vec::new(),
704 proxy_max_rps: None,
705 dashboard_auth: true,
706 buddy_enabled: serde_defaults::default_buddy_enabled(),
707 enable_wakeup_ctx: true,
708 redirect_exclude: Vec::new(),
709 disabled_tools: Vec::new(),
710 prefer_native_editor: false,
711 default_tool_categories: Vec::new(),
712 no_degrade: false,
713 delta_explicit: false,
714 profile: None,
715 tool_profile: None,
716 tools_enabled: Vec::new(),
717 persona: None,
718 loop_detection: LoopDetectionConfig::default(),
719 rules_scope: None,
720 rules_injection: None,
721 permission_inheritance: None,
722 extra_ignore_patterns: Vec::new(),
723 terse_agent: TerseAgent::default(),
724 compression_level: CompressionLevel::default(),
725 compression_aggressiveness: None,
726 archive: ArchiveConfig::default(),
727 memory: MemoryPolicy::default(),
728 allow_paths: Vec::new(),
729 allow_ide_config_dirs: None,
730 extra_roots: Vec::new(),
731 read_only_roots: Vec::new(),
732 allow_symlink_roots: Vec::new(),
733 content_defined_chunking: false,
734 minimal_overhead: true,
735 symbol_map_auto: false,
736 structure_first: false,
737 auto_mode_learning: false,
738 team_url: None,
739 team_token: None,
740 team_auto_push: false,
741 journal_enabled: true,
742 auto_capture: true,
743 search: crate::core::hybrid_search::HybridConfig::default(),
744 graph: GraphConfig::default(),
745 index: IndexConfig::default(),
746 skillify: SkillifyConfig::default(),
747 summaries: SummariesConfig::default(),
748 llm: crate::core::llm_enhance::LlmConfig::default(),
749 embedding: EmbeddingConfig::default(),
750 shell_hook_disabled: false,
751 shadow_mode: false,
752 debug_log: false,
753 shell_activation: ShellActivation::default(),
754 read_redirect: ReadRedirect::default(),
755 read_dedup: ReadDedup::default(),
756 update_check_disabled: false,
757 updates: UpdatesConfig::default(),
758 context: ContextConfig::default(),
759 graph_index_max_files: serde_defaults::default_graph_index_max_files(),
760 bm25_max_cache_mb: serde_defaults::default_bm25_max_cache_mb(),
761 memory_profile: MemoryProfile::default(),
762 memory_cleanup: MemoryCleanup::default(),
763 max_ram_percent: serde_defaults::default_max_ram_percent(),
764 max_disk_mb: 0,
765 max_staleness_days: 0,
766 max_index_threads: 0,
767 savings_footer: SavingsFooter::default(),
768 project_root: None,
769 lsp: std::collections::HashMap::new(),
770 ide_paths: HashMap::new(),
771 model_context_windows: HashMap::new(),
772 response_verbosity: ResponseVerbosity::default(),
773 bypass_hints: None,
774 cache_policy: None,
775 cache_max_tokens: 0,
776 boundary_policy: crate::core::memory_boundary::BoundaryPolicy::default(),
777 secret_detection: SecretDetectionConfig::default(),
778 sensitivity: crate::core::sensitivity::SensitivityConfig::default(),
779 gateway: crate::core::gateway::GatewayConfig::default(),
780 gateway_server: GatewayServerConfig::default(),
781 addons: crate::core::addons::AddonsConfig::default(),
782 allow_auto_reroot: false,
783 hook_binary: None,
784 path_jail: None,
785 sandbox_level: 0,
786 reference_results: false,
787 agent_token_budget: 0,
788 shell_allowlist: default_shell_allowlist(),
789 shell_allowlist_extra: Vec::new(),
790 shell_strict_mode: false,
791 shell_security: None,
792 shell_timeout_secs: None,
793 shell_heavy_timeout_secs: None,
794 shell_allow_writes: false,
795 setup: SetupConfig::default(),
796 }
797 }
798}
799
800/// Holds the most recent global `config.toml` parse error, if the file currently
801/// fails to parse. When that happens `Config::load()` silently falls back to the
802/// built-in defaults and only logs to stderr — which is invisible over an MCP/stdio
803/// transport. Recording it here lets callers (e.g. the shell-allowlist diagnostic
804/// and `lean-ctx doctor`) surface "you're on defaults because your config is broken".
805static LAST_PARSE_ERROR: Mutex<Option<String>> = Mutex::new(None);
806
807/// Returns the most recent global config parse error, or `None` if the current
808/// `config.toml` parsed successfully (or no config file exists).
809#[must_use]
810pub fn last_config_parse_error() -> Option<String> {
811 LAST_PARSE_ERROR.lock().ok().and_then(|g| g.clone())
812}
813
814fn record_parse_error(err: Option<String>) {
815 if let Ok(mut guard) = LAST_PARSE_ERROR.lock() {
816 *guard = err;
817 }
818}
819
820/// Reset every SECURITY-sensitive field of a parsed project-local `Config` back
821/// to its default, returning the names of the ones that actually carried an
822/// override. Used by [`Config::merge_local`] for untrusted workspaces: clearing a
823/// field to its default makes the downstream "== default ⇒ no override" merge
824/// guards skip it automatically, so a single list here gates every sensitive key
825/// without touching the per-field merge arms (security audit #4).
826///
827/// Sensitive = anything that can widen lean-ctx's own boundaries or steer the
828/// agent: the shell allowlist, path-jail roots, proxy upstreams, command
829/// aliases, network passthrough, rules scope/injection, tool disabling and
830/// permission inheritance. Comfort/perf knobs are intentionally NOT listed.
831fn strip_sensitive_overrides(local: &mut Config) -> Vec<&'static str> {
832 let mut withheld: Vec<&'static str> = Vec::new();
833
834 if local.shell_allowlist != default_shell_allowlist() {
835 local.shell_allowlist = default_shell_allowlist();
836 withheld.push("shell_allowlist");
837 }
838 if !local.shell_allowlist_extra.is_empty() {
839 local.shell_allowlist_extra.clear();
840 withheld.push("shell_allowlist_extra");
841 }
842 if !local.allow_paths.is_empty() {
843 local.allow_paths.clear();
844 withheld.push("allow_paths");
845 }
846 if !local.extra_roots.is_empty() {
847 local.extra_roots.clear();
848 withheld.push("extra_roots");
849 }
850 if !local.allow_symlink_roots.is_empty() {
851 local.allow_symlink_roots.clear();
852 withheld.push("allow_symlink_roots");
853 }
854 if !local.custom_aliases.is_empty() {
855 local.custom_aliases.clear();
856 withheld.push("custom_aliases");
857 }
858 if !local.passthrough_urls.is_empty() {
859 local.passthrough_urls.clear();
860 withheld.push("passthrough_urls");
861 }
862 if local.proxy.anthropic_upstream.is_some()
863 || local.proxy.openai_upstream.is_some()
864 || local.proxy.chatgpt_upstream.is_some()
865 || local.proxy.gemini_upstream.is_some()
866 {
867 local.proxy.anthropic_upstream = None;
868 local.proxy.openai_upstream = None;
869 local.proxy.chatgpt_upstream = None;
870 local.proxy.gemini_upstream = None;
871 withheld.push("proxy.*_upstream");
872 }
873 if local.rules_scope.is_some() {
874 local.rules_scope = None;
875 withheld.push("rules_scope");
876 }
877 if local.rules_injection.is_some() {
878 local.rules_injection = None;
879 withheld.push("rules_injection");
880 }
881 if local.permission_inheritance.is_some() {
882 local.permission_inheritance = None;
883 withheld.push("permission_inheritance");
884 }
885 if !local.disabled_tools.is_empty() {
886 local.disabled_tools.clear();
887 withheld.push("disabled_tools");
888 }
889
890 withheld
891}
892
893/// Names of the SECURITY-sensitive overrides a project-local `.lean-ctx.toml`
894/// carries — the keys `strip_sensitive_overrides` would withhold for an
895/// untrusted workspace. Read-only (parses a throwaway `Config`); used by
896/// `lean-ctx trust` to tell the user exactly what trusting will enable.
897#[must_use]
898pub fn local_sensitive_overrides(local_toml: &str) -> Vec<&'static str> {
899 match toml::from_str::<Config>(local_toml) {
900 Ok(mut parsed) => strip_sensitive_overrides(&mut parsed),
901 Err(_) => Vec::new(),
902 }
903}
904
905impl Config {
906 /// Whether opt-in lossless JSON crushing of verbatim data commands (#936) is
907 /// active. `LEAN_CTX_CRUSH_VERBATIM_JSON` (any value) wins, then the
908 /// `crush_verbatim_json` config flag, else `false`.
909 pub fn crush_verbatim_json_enabled(&self) -> bool {
910 std::env::var("LEAN_CTX_CRUSH_VERBATIM_JSON").is_ok() || self.crush_verbatim_json
911 }
912
913 /// Effective proxy bind address (gateway mode, enterprise#8). Precedence:
914 /// `LEAN_CTX_PROXY_BIND_HOST` env > `proxy_bind_host` config > loopback.
915 /// The value must parse as an IP address; anything else (including a blank)
916 /// resolves to `127.0.0.1` — a typo can only ever *narrow* exposure, never
917 /// silently open the listener.
918 #[must_use]
919 pub fn resolved_proxy_bind_host(&self) -> std::net::IpAddr {
920 let raw = std::env::var("LEAN_CTX_PROXY_BIND_HOST")
921 .ok()
922 .filter(|v| !v.trim().is_empty())
923 .or_else(|| self.proxy_bind_host.clone());
924 match raw.as_deref().map(str::trim) {
925 Some(v) if !v.is_empty() => v.parse().unwrap_or_else(|_| {
926 tracing::warn!(
927 "proxy_bind_host '{v}' is not a valid IP address — binding 127.0.0.1"
928 );
929 std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
930 }),
931 _ => std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST),
932 }
933 }
934
935 /// Returns the effective rules scope, preferring env var over config file.
936 pub fn rules_scope_effective(&self) -> RulesScope {
937 let raw = std::env::var("LEAN_CTX_RULES_SCOPE")
938 .ok()
939 .or_else(|| self.rules_scope.clone())
940 .unwrap_or_default();
941 match raw.trim().to_lowercase().as_str() {
942 "global" => RulesScope::Global,
943 "project" => RulesScope::Project,
944 _ => RulesScope::Both,
945 }
946 }
947
948 /// Returns the effective rules injection mode, preferring env var over config.
949 /// Default is `Shared` (zero-config discovery via a CLAUDE.md/CODEBUDDY.md/AGENTS.md block).
950 pub fn rules_injection_effective(&self) -> RulesInjection {
951 let raw = std::env::var("LEAN_CTX_RULES_INJECTION")
952 .ok()
953 .or_else(|| self.rules_injection.clone())
954 .unwrap_or_default();
955 match raw.trim().to_lowercase().as_str() {
956 "dedicated" => RulesInjection::Dedicated,
957 "off" | "none" | "disabled" => RulesInjection::Off,
958 _ => RulesInjection::Shared,
959 }
960 }
961
962 /// Returns the effective permission-inheritance mode, preferring the
963 /// `LEAN_CTX_PERMISSION_INHERITANCE` env var over config. Default is `Off`.
964 /// Accepts `on`/`true`/`1` as enabled.
965 #[must_use]
966 pub fn permission_inheritance_effective(&self) -> PermissionInheritance {
967 let raw = std::env::var("LEAN_CTX_PERMISSION_INHERITANCE")
968 .ok()
969 .or_else(|| self.permission_inheritance.clone())
970 .unwrap_or_default();
971 match raw.trim().to_lowercase().as_str() {
972 "on" | "true" | "1" | "inherit" => PermissionInheritance::On,
973 _ => PermissionInheritance::Off,
974 }
975 }
976
977 /// True when lean-ctx should inject its rules via each agent's dedicated,
978 /// non-polluting auto-load path *and* global rules are in scope.
979 ///
980 /// Gates the Claude/Codex `SessionStart` `additionalContext` summary: it
981 /// stands in for the (now-skipped) shared CLAUDE.md/CODEBUDDY.md/AGENTS.md block, so it
982 /// only fires when injection is `Dedicated` and the scope isn't project-only.
983 #[must_use]
984 pub fn dedicated_session_context_active(&self) -> bool {
985 self.rules_injection_effective() == RulesInjection::Dedicated
986 && self.rules_scope_effective() != RulesScope::Project
987 }
988
989 fn parse_disabled_tools_env(val: &str) -> Vec<String> {
990 val.split(',')
991 .map(|s| s.trim().to_string())
992 .filter(|s| !s.is_empty())
993 .collect()
994 }
995
996 /// Returns the effective disabled tools list, preferring env var over config
997 /// file. When `prefer_native_editor` is active, the lean-ctx edit tools are
998 /// folded in so they are hidden from `list_tools` (#454).
999 pub fn disabled_tools_effective(&self) -> Vec<String> {
1000 let mut list = if let Ok(val) = std::env::var("LEAN_CTX_DISABLED_TOOLS") {
1001 Self::parse_disabled_tools_env(&val)
1002 } else {
1003 self.disabled_tools.clone()
1004 };
1005 if self.prefer_native_editor_effective() {
1006 for name in EDIT_TOOL_NAMES {
1007 if !list.iter().any(|t| t == name) {
1008 list.push((*name).to_string());
1009 }
1010 }
1011 }
1012 list
1013 }
1014
1015 /// Whether lean-ctx edit operations are disabled in favour of the host's
1016 /// native editor (#454). `LEAN_CTX_PREFER_NATIVE_EDITOR` wins over config.
1017 pub fn prefer_native_editor_effective(&self) -> bool {
1018 match std::env::var("LEAN_CTX_PREFER_NATIVE_EDITOR") {
1019 Ok(raw) => matches!(
1020 raw.trim().to_lowercase().as_str(),
1021 "1" | "true" | "yes" | "on"
1022 ),
1023 Err(_) => self.prefer_native_editor,
1024 }
1025 }
1026
1027 /// Cap on the rayon index-build worker threads. `LEANCTX_INDEX_THREADS` wins
1028 /// over config; `0` means "no cap" — rayon's all-cores default is kept.
1029 pub fn max_index_threads_effective(&self) -> usize {
1030 std::env::var("LEANCTX_INDEX_THREADS")
1031 .ok()
1032 .and_then(|raw| raw.trim().parse::<usize>().ok())
1033 .unwrap_or(self.max_index_threads)
1034 }
1035
1036 /// Whether `name` is a lean-ctx edit operation that must be blocked from
1037 /// dispatch (direct and via `ctx_call`) when [`Self::prefer_native_editor_effective`]
1038 /// is set (#454). Read/search/shell/memory tools are never blocked.
1039 pub fn edit_tool_blocked(&self, name: &str) -> bool {
1040 self.prefer_native_editor_effective() && EDIT_TOOL_NAMES.contains(&name)
1041 }
1042
1043 /// Returns `true` if minimal overhead is enabled via env var or config.
1044 pub fn minimal_overhead_effective(&self) -> bool {
1045 std::env::var("LEAN_CTX_MINIMAL").is_ok() || self.minimal_overhead
1046 }
1047
1048 /// Returns `true` if structure-first auto reads are enabled.
1049 ///
1050 /// The `LEAN_CTX_STRUCTURE_FIRST` env var wins over the config field, and
1051 /// accepts the usual truthy/falsy spellings so a harness can flip it per run
1052 /// (`LEAN_CTX_STRUCTURE_FIRST=0` forces it off even if config enables it).
1053 pub fn structure_first_effective(&self) -> bool {
1054 match std::env::var("LEAN_CTX_STRUCTURE_FIRST") {
1055 Ok(raw) => matches!(
1056 raw.trim().to_lowercase().as_str(),
1057 "1" | "true" | "yes" | "on"
1058 ),
1059 Err(_) => self.structure_first,
1060 }
1061 }
1062
1063 /// Returns `true` when the adaptive learning signals may participate in
1064 /// `auto` mode resolution (#683). Off by default for a deterministic,
1065 /// I/O-light cascade; the `LEAN_CTX_AUTO_MODE_LEARNING` env var wins over the
1066 /// config field and accepts the usual truthy/falsy spellings.
1067 pub fn auto_mode_learning_effective(&self) -> bool {
1068 match std::env::var("LEAN_CTX_AUTO_MODE_LEARNING") {
1069 Ok(raw) => matches!(
1070 raw.trim().to_lowercase().as_str(),
1071 "1" | "true" | "yes" | "on"
1072 ),
1073 Err(_) => self.auto_mode_learning,
1074 }
1075 }
1076
1077 /// Returns `true` when probabilistic exploration (Thompson sampling,
1078 /// Boltzmann-temperature eviction, simulated annealing) may influence
1079 /// decisions. Off by default so tool output stays a deterministic, byte-
1080 /// stable function of (content, mode, task) — the determinism contract
1081 /// (#498) that lets provider prompt caching apply. The `LEAN_CTX_STOCHASTIC`
1082 /// env var wins (the usual truthy/falsy spellings); otherwise it follows
1083 /// [`Self::auto_mode_learning_effective`], which is itself off by default.
1084 pub fn is_stochastic_enabled(&self) -> bool {
1085 match std::env::var("LEAN_CTX_STOCHASTIC") {
1086 Ok(raw) => matches!(
1087 raw.trim().to_lowercase().as_str(),
1088 "1" | "true" | "yes" | "on"
1089 ),
1090 Err(_) => self.auto_mode_learning_effective(),
1091 }
1092 }
1093
1094 /// Returns `true` if minimal overhead should be enabled for this MCP client.
1095 ///
1096 /// This is a superset of `minimal_overhead_effective()`:
1097 /// - `LEAN_CTX_OVERHEAD_MODE=minimal` forces minimal overhead
1098 /// - `LEAN_CTX_OVERHEAD_MODE=full` disables client/model heuristics (still honors LEAN_CTX_MINIMAL / config)
1099 /// - In auto mode (default), certain low-context clients/models are treated as minimal to prevent
1100 /// large metadata blocks from destabilizing smaller context windows (e.g. Hermes + MiniMax).
1101 pub fn minimal_overhead_effective_for_client(&self, client_name: &str) -> bool {
1102 if let Ok(raw) = std::env::var("LEAN_CTX_OVERHEAD_MODE") {
1103 match raw.trim().to_lowercase().as_str() {
1104 "minimal" => return true,
1105 "full" => return self.minimal_overhead_effective(),
1106 _ => {}
1107 }
1108 }
1109
1110 if self.minimal_overhead_effective() {
1111 return true;
1112 }
1113
1114 let client_lower = client_name.trim().to_lowercase();
1115 if !client_lower.is_empty() {
1116 if let Ok(list) = std::env::var("LEAN_CTX_MINIMAL_CLIENTS") {
1117 for needle in list.split(',').map(|s| s.trim().to_lowercase()) {
1118 if !needle.is_empty() && client_lower.contains(&needle) {
1119 return true;
1120 }
1121 }
1122 } else if client_lower.contains("hermes") || client_lower.contains("minimax") {
1123 return true;
1124 }
1125 }
1126
1127 let model = std::env::var("LEAN_CTX_MODEL")
1128 .or_else(|_| std::env::var("LCTX_MODEL"))
1129 .unwrap_or_default();
1130 let model = model.trim().to_lowercase();
1131 if !model.is_empty() {
1132 let m = model.replace(['_', ' '], "-");
1133 if m.contains("minimax")
1134 || m.contains("mini-max")
1135 || m.contains("m2.7")
1136 || m.contains("m2-7")
1137 {
1138 return true;
1139 }
1140 }
1141
1142 false
1143 }
1144
1145 /// Returns `true` if shell hook injection is disabled via env var or config.
1146 pub fn shell_hook_disabled_effective(&self) -> bool {
1147 std::env::var("LEAN_CTX_NO_HOOK").is_ok() || self.shell_hook_disabled
1148 }
1149
1150 /// Returns the effective shell activation mode (env var > config > default).
1151 pub fn shell_activation_effective(&self) -> ShellActivation {
1152 ShellActivation::effective(self)
1153 }
1154
1155 /// Returns `true` if `ctx_shell` may accept shell file-write redirects.
1156 /// `LEAN_CTX_SHELL_ALLOW_WRITES` (`1`/`true`/`yes`/`on`) overrides
1157 /// `config.toml`. The real command gating still applies either way.
1158 pub fn shell_allow_writes_effective(&self) -> bool {
1159 match std::env::var("LEAN_CTX_SHELL_ALLOW_WRITES") {
1160 Ok(raw) => matches!(
1161 raw.trim().to_ascii_lowercase().as_str(),
1162 "1" | "true" | "yes" | "on"
1163 ),
1164 Err(_) => self.shell_allow_writes,
1165 }
1166 }
1167
1168 /// Returns `true` if the daily update check is disabled via env var or config.
1169 pub fn update_check_disabled_effective(&self) -> bool {
1170 std::env::var("LEAN_CTX_NO_UPDATE_CHECK").is_ok() || self.update_check_disabled
1171 }
1172
1173 pub fn memory_policy_effective(&self) -> Result<MemoryPolicy, String> {
1174 let mut policy = self.memory.clone();
1175 policy.apply_env_overrides();
1176
1177 let budget = self.max_disk_mb_effective();
1178 if budget > 0 {
1179 let scale_factor = (budget as f64 / 500.0).clamp(0.5, 10.0);
1180 let default_policy = MemoryPolicy::default();
1181 if policy.knowledge.max_facts == default_policy.knowledge.max_facts {
1182 policy.knowledge.max_facts = (200.0 * scale_factor) as usize;
1183 }
1184 if policy.knowledge.max_patterns == default_policy.knowledge.max_patterns {
1185 policy.knowledge.max_patterns = (50.0 * scale_factor) as usize;
1186 }
1187 if policy.episodic.max_episodes == default_policy.episodic.max_episodes {
1188 policy.episodic.max_episodes = (500.0 * scale_factor) as usize;
1189 }
1190 if policy.procedural.max_procedures == default_policy.procedural.max_procedures {
1191 policy.procedural.max_procedures = (100.0 * scale_factor) as usize;
1192 }
1193 }
1194
1195 policy.validate()?;
1196 Ok(policy)
1197 }
1198
1199 /// Returns the effective set of default tool categories.
1200 /// Priority: LCTX_DEFAULT_CATEGORIES env var > config.toml > hardcoded default.
1201 pub fn default_tool_categories_effective(&self) -> Vec<String> {
1202 if let Ok(val) = std::env::var("LCTX_DEFAULT_CATEGORIES") {
1203 return val
1204 .split(',')
1205 .map(|s| s.trim().to_lowercase())
1206 .filter(|s| !s.is_empty())
1207 .collect();
1208 }
1209 if !self.default_tool_categories.is_empty() {
1210 return self
1211 .default_tool_categories
1212 .iter()
1213 .map(|s| s.to_lowercase())
1214 .collect();
1215 }
1216 vec!["core".to_string(), "session".to_string()]
1217 }
1218
1219 /// Returns the effective tool profile.
1220 /// Priority: LEAN_CTX_TOOL_PROFILE env > config tool_profile > config
1221 /// tools_enabled > active persona's tool surface > power.
1222 ///
1223 /// Explicit settings win (backward compatible); when none are set, the
1224 /// active persona supplies the tool surface (the `coding` default resolves
1225 /// to `power`, so existing installs are unaffected).
1226 pub fn tool_profile_effective(&self) -> super::tool_profiles::ToolProfile {
1227 super::persona::Persona::resolve(self).effective_tool_profile(self)
1228 }
1229
1230 /// The `[sensitivity]` config with the active persona's floor folded in
1231 /// (persona-spec-v1). Enforcement chokepoints use this instead of the raw
1232 /// field so a persona like `lead-gen` (`sensitivity_floor = "confidential"`)
1233 /// protects PII out of the box. The `coding` default (`public`) passes the
1234 /// config through unchanged.
1235 #[must_use]
1236 pub fn sensitivity_effective(&self) -> crate::core::sensitivity::SensitivityConfig {
1237 self.sensitivity
1238 .clone()
1239 .with_persona_floor(super::persona::Persona::resolve(self).sensitivity_floor)
1240 }
1241
1242 /// Returns `true` if all automatic read-mode degradation is disabled.
1243 /// Checks LCTX_NO_DEGRADE env var first, then config.toml field.
1244 pub fn no_degrade_effective(&self) -> bool {
1245 if let Ok(val) = std::env::var("LCTX_NO_DEGRADE") {
1246 return val == "1" || val.eq_ignore_ascii_case("true");
1247 }
1248 self.no_degrade
1249 }
1250
1251 /// Returns `true` if explicit `full`/`lines:N-M` re-reads of
1252 /// cached-but-changed files should be served as deltas (`mode=diff`)
1253 /// instead of re-emitting full content.
1254 ///
1255 /// Checks the `LCTX_DELTA_EXPLICIT` env var first, then the config.toml
1256 /// field. Unlike a presence-only knob, an explicit `0`/`false` in the env
1257 /// forces the feature OFF even when the config field is `true`, so the env
1258 /// can fully override config in both directions.
1259 pub fn delta_explicit_effective(&self) -> bool {
1260 if let Ok(val) = std::env::var("LCTX_DELTA_EXPLICIT") {
1261 return val == "1" || val.eq_ignore_ascii_case("true");
1262 }
1263 self.delta_explicit
1264 }
1265
1266 /// Effective max_disk_mb from env or config.
1267 pub fn max_disk_mb_effective(&self) -> u64 {
1268 std::env::var("LEAN_CTX_MAX_DISK_MB")
1269 .ok()
1270 .and_then(|v| v.parse().ok())
1271 .unwrap_or(self.max_disk_mb)
1272 }
1273
1274 /// Effective max_staleness_days from env or config.
1275 pub fn max_staleness_days_effective(&self) -> u32 {
1276 std::env::var("LEAN_CTX_MAX_STALENESS_DAYS")
1277 .ok()
1278 .and_then(|v| v.parse().ok())
1279 .unwrap_or(self.max_staleness_days)
1280 }
1281
1282 /// Effective fixed-context budget (tokens) from env or config (#964). `0`
1283 /// (env or config) disables the warning; otherwise the per-session footprint
1284 /// is checked against this in `doctor overhead` and `gain`.
1285 pub fn context_budget_tokens_effective(&self) -> usize {
1286 std::env::var("LEAN_CTX_CONTEXT_BUDGET_TOKENS")
1287 .ok()
1288 .and_then(|v| v.parse().ok())
1289 .unwrap_or(self.context.budget_tokens)
1290 }
1291
1292 /// Archive max_disk_mb derived from simplified max_disk_mb if the detail
1293 /// value is still at its default. Explicit overrides take priority.
1294 pub fn archive_max_disk_mb_effective(&self) -> u64 {
1295 let budget = self.max_disk_mb_effective();
1296 if budget > 0 && self.archive.max_disk_mb == ArchiveConfig::default().max_disk_mb {
1297 budget * 25 / 100
1298 } else {
1299 self.archive.max_disk_mb
1300 }
1301 }
1302
1303 /// Archive max_age_hours derived from max_staleness_days if the detail
1304 /// value is still at its default. Explicit overrides take priority.
1305 pub fn archive_max_age_hours_effective(&self) -> u64 {
1306 let staleness = self.max_staleness_days_effective();
1307 if staleness > 0 && self.archive.max_age_hours == ArchiveConfig::default().max_age_hours {
1308 staleness as u64 * 24
1309 } else {
1310 self.archive.max_age_hours
1311 }
1312 }
1313
1314 /// Effective on-disk ceiling (MB) for the persisted BM25 index. Single source
1315 /// of truth for `save`/`load`, `cache prune`, and the doctor health check.
1316 ///
1317 /// Priority: explicit `bm25_max_cache_mb` › `max_disk_mb` budget (10%) ›
1318 /// generous default ([`DEFAULT_BM25_PERSIST_MB`]). The default is decoupled
1319 /// from the RAM profile so large repos persist instead of rebuilding forever
1320 /// (issue #249).
1321 pub fn bm25_max_cache_mb_effective(&self) -> u64 {
1322 if self.bm25_max_cache_mb != serde_defaults::default_bm25_max_cache_mb() {
1323 return self.bm25_max_cache_mb;
1324 }
1325 let budget = self.max_disk_mb_effective();
1326 if budget > 0 {
1327 return budget * 10 / 100;
1328 }
1329 DEFAULT_BM25_PERSIST_MB
1330 }
1331}
1332
1333impl Config {
1334 /// Returns the path to the global config file (`$XDG_CONFIG_HOME/lean-ctx/config.toml`).
1335 ///
1336 /// Resolves via [`crate::core::paths::config_dir`] so config lives in the
1337 /// RO-safe config category. Behavior-neutral today: `config_dir()` equals the
1338 /// legacy data dir for existing/single-dir installs (GH #408 / GL #602).
1339 pub fn path() -> Option<PathBuf> {
1340 crate::core::paths::config_dir()
1341 .ok()
1342 .map(|d| d.join("config.toml"))
1343 }
1344
1345 /// `Some(path)` when the global config the runtime *resolves* does not exist,
1346 /// so lean-ctx is silently on built-in defaults. `None` when a config file is
1347 /// present (or HOME is unresolvable).
1348 ///
1349 /// The directory is layout-dependent (XDG `~/.config/lean-ctx` vs legacy
1350 /// `~/.lean-ctx` vs `$LEAN_CTX_DATA_DIR`) and an MCP client may launch the
1351 /// server in a sandbox/container with a different `$HOME`. An edit made to a
1352 /// *different* `config.toml` than this one is silently ignored; the block
1353 /// messages use this to say so out loud over MCP, where the stderr path is
1354 /// invisible (#540).
1355 #[must_use]
1356 pub fn missing_config_path() -> Option<PathBuf> {
1357 match Self::path() {
1358 Some(p) if !p.exists() => Some(p),
1359 _ => None,
1360 }
1361 }
1362
1363 /// Returns the path to the project-local config override file.
1364 pub fn local_path(project_root: &str) -> PathBuf {
1365 PathBuf::from(project_root).join(".lean-ctx.toml")
1366 }
1367
1368 /// Resolves the active project root (env override → session → git toplevel →
1369 /// cwd), cached for the process. Exposed crate-wide so workspace-trust and the
1370 /// CLI agree with config loading on *which* directory a `.lean-ctx.toml`
1371 /// belongs to (GH security audit, finding 4).
1372 pub(crate) fn find_project_root() -> Option<String> {
1373 static ROOT_CACHE: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
1374 ROOT_CACHE
1375 .get_or_init(Self::find_project_root_inner)
1376 .clone()
1377 }
1378
1379 fn find_project_root_inner() -> Option<String> {
1380 if let Ok(env_root) = std::env::var("LEAN_CTX_PROJECT_ROOT")
1381 && !env_root.is_empty()
1382 {
1383 return Some(env_root);
1384 }
1385
1386 let cwd = std::env::current_dir().ok();
1387
1388 if let Some(root) =
1389 crate::core::session::SessionState::load_latest().and_then(|s| s.project_root)
1390 {
1391 let root_path = std::path::Path::new(&root);
1392 let cwd_is_under_root = cwd.as_ref().is_some_and(|c| c.starts_with(root_path));
1393 // Route the marker probe through the TCC-guarded helper and never
1394 // adopt a ~/Documents project root from a launchd-standalone process
1395 // (#356): doing so would later stat its `.lean-ctx.toml`/markers and
1396 // pop the macOS privacy prompt in lean-ctx's own name.
1397 let has_marker = crate::core::pathutil::has_project_marker(root_path);
1398
1399 if (cwd_is_under_root || has_marker) && crate::core::pathutil::may_probe_path(root_path)
1400 {
1401 return Some(root);
1402 }
1403 }
1404
1405 if let Some(ref cwd) = cwd {
1406 // A launchd-standalone process must not shell out to `git` (which
1407 // stats the working tree) or adopt cwd as the project root when cwd
1408 // is under a TCC-protected dir (#356).
1409 let may_probe_cwd = crate::core::pathutil::may_probe_path(cwd);
1410 let git_root = if may_probe_cwd {
1411 std::process::Command::new("git")
1412 .args(["rev-parse", "--show-toplevel"])
1413 .current_dir(cwd)
1414 .stdout(std::process::Stdio::piped())
1415 .stderr(std::process::Stdio::null())
1416 .output()
1417 .ok()
1418 .and_then(|o| {
1419 if o.status.success() {
1420 String::from_utf8(o.stdout)
1421 .ok()
1422 .map(|s| s.trim().to_string())
1423 } else {
1424 None
1425 }
1426 })
1427 } else {
1428 None
1429 };
1430 if let Some(root) = git_root {
1431 return Some(root);
1432 }
1433 if may_probe_cwd && !crate::core::pathutil::is_broad_or_unsafe_root(cwd) {
1434 return Some(cwd.to_string_lossy().to_string());
1435 }
1436 }
1437 None
1438 }
1439
1440 /// Loads config from disk with caching, merging global + project-local overrides.
1441 ///
1442 /// The cache is keyed on a **content hash** of the global + project-local
1443 /// files, not their mtime. mtime-only invalidation silently served a stale
1444 /// `Config` whenever a content edit preserved the mtime (coarse filesystem
1445 /// mtime resolution, `cp -p`, atomic save-then-rename, two edits within the
1446 /// same second). A long-lived MCP server then kept the old value (e.g.
1447 /// `path_jail`) while a fresh `lean-ctx doctor` process — with an empty
1448 /// cache — saw the new one (#406). Config files are tiny, so reading +
1449 /// hashing them on every load is negligible and guarantees liveness.
1450 pub fn load() -> Self {
1451 (*Self::load_arc()).clone()
1452 }
1453
1454 /// Shared-ownership variant of [`load`](Self::load): returns the cached
1455 /// `Arc<Config>` so the per-dispatch hot path bumps a refcount instead of
1456 /// deep-cloning the whole struct. Liveness is identical to `load` — the
1457 /// global and project-local files are still read and content-hashed on
1458 /// every call (#406); only the cache payload became an `Arc`, so a cache
1459 /// hit is a cheap `Arc::clone`.
1460 pub fn load_arc() -> Arc<Self> {
1461 static CACHE: Mutex<ConfigCacheSlot> = Mutex::new(None);
1462
1463 let Some(path) = Self::path() else {
1464 return Arc::new(Self::default());
1465 };
1466
1467 let project_root = Self::find_project_root();
1468 let local_path = project_root.as_deref().map(Self::local_path);
1469
1470 // Read raw content up front so the cache key is a content hash.
1471 let global_content = std::fs::read_to_string(&path).ok();
1472 // TCC (#356): never read a project-local `.lean-ctx.toml` under
1473 // ~/Documents from a launchd-standalone process — the read pops the
1474 // macOS privacy prompt. `find_project_root` already avoids returning
1475 // such roots; this also guards the explicit `LEAN_CTX_PROJECT_ROOT` path.
1476 let local_content = local_path
1477 .as_ref()
1478 .filter(|p| crate::core::pathutil::may_probe_path(p.as_path()))
1479 .and_then(|p| std::fs::read_to_string(p).ok());
1480
1481 let global_hash = global_content.as_deref().map(crate::core::hasher::hash_str);
1482 let local_hash = local_content.as_deref().map(crate::core::hasher::hash_str);
1483
1484 if let Ok(guard) = CACHE.lock()
1485 && let Some((ref cfg, ref cached_global, ref cached_local)) = *guard
1486 && *cached_global == global_hash
1487 && *cached_local == local_hash
1488 {
1489 return Arc::clone(cfg);
1490 }
1491
1492 let mut cfg: Config = if let Some(ref content) = global_content {
1493 match toml::from_str(content) {
1494 Ok(c) => {
1495 record_parse_error(None);
1496 c
1497 }
1498 Err(e) => {
1499 record_parse_error(Some(format!("{e}")));
1500 tracing::warn!("config parse error in {}: {e}", path.display());
1501 eprintln!(
1502 "\x1b[33m[lean-ctx] WARNING: config parse error in {}: {e}\n \
1503 Using defaults. Run `lean-ctx doctor --fix` to repair.\x1b[0m",
1504 path.display()
1505 );
1506 Self::default()
1507 }
1508 }
1509 } else {
1510 record_parse_error(None);
1511 Self::default()
1512 };
1513
1514 if let Some(ref local) = local_content {
1515 // Finding 4: a project-local `.lean-ctx.toml`'s SECURITY-sensitive
1516 // overrides (shell allowlist, path-jail widening, proxy upstream, …)
1517 // are honoured only for a workspace the user has explicitly trusted.
1518 // `local_hash` is exactly the content hash workspace-trust pins, so
1519 // editing the file after trust re-gates it (see `workspace_trust`).
1520 let trusted = project_root.as_deref().is_some_and(|r| {
1521 crate::core::workspace_trust::is_trusted_for(
1522 std::path::Path::new(r),
1523 local_hash.as_deref().unwrap_or_default(),
1524 )
1525 });
1526 cfg.merge_local(local, trusted);
1527 }
1528
1529 let cfg = Arc::new(cfg);
1530 if let Ok(mut guard) = CACHE.lock() {
1531 *guard = Some((Arc::clone(&cfg), global_hash, local_hash));
1532 }
1533
1534 cfg
1535 }
1536
1537 /// Merge a project-local `.lean-ctx.toml` onto `self`.
1538 ///
1539 /// `trusted` reflects [`crate::core::workspace_trust`]: when `false`, the
1540 /// security-sensitive overrides (shell allowlist, path-jail widening, proxy
1541 /// upstream, command aliases, rules scope, …) are withheld and a warning is
1542 /// emitted — comfort-only overrides (compression, theme, memory tuning) still
1543 /// apply. This stops a cloned, untrusted repo from silently weakening
1544 /// lean-ctx's own boundaries through its bundled config (security audit #4).
1545 fn merge_local(&mut self, local_toml: &str, trusted: bool) {
1546 let mut local: Config = match toml::from_str(local_toml) {
1547 Ok(c) => c,
1548 Err(e) => {
1549 tracing::warn!("local config parse error: {e}");
1550 eprintln!(
1551 "\x1b[33m[lean-ctx] WARNING: local .lean-ctx.toml parse error: {e}\n \
1552 Local overrides skipped.\x1b[0m"
1553 );
1554 return;
1555 }
1556 };
1557 if !trusted {
1558 let withheld = strip_sensitive_overrides(&mut local);
1559 if !withheld.is_empty() {
1560 tracing::warn!(
1561 "[SECURITY] untrusted workspace: ignoring {} security-sensitive \
1562 .lean-ctx.toml override(s): {} — run `lean-ctx trust` to apply them",
1563 withheld.len(),
1564 withheld.join(", ")
1565 );
1566 }
1567 }
1568 if local.ultra_compact {
1569 self.ultra_compact = true;
1570 }
1571 if local.tee_mode != TeeMode::default() {
1572 self.tee_mode = local.tee_mode;
1573 }
1574 if local.recovery_hints != RecoveryHints::default() {
1575 self.recovery_hints = local.recovery_hints;
1576 }
1577 if local.output_density != OutputDensity::default() {
1578 self.output_density = local.output_density;
1579 }
1580 if local.checkpoint_interval != 15 {
1581 self.checkpoint_interval = local.checkpoint_interval;
1582 }
1583 if !local.excluded_commands.is_empty() {
1584 self.excluded_commands.extend(local.excluded_commands);
1585 }
1586 if !local.passthrough_urls.is_empty() {
1587 self.passthrough_urls.extend(local.passthrough_urls);
1588 }
1589 if !local.custom_aliases.is_empty() {
1590 self.custom_aliases.extend(local.custom_aliases);
1591 }
1592 // Additive merge with dedup: project-local config can add formats on top
1593 // of the global default (`["toon"]`) without re-listing it.
1594 for fmt in local.preserve_compact_formats {
1595 if !self
1596 .preserve_compact_formats
1597 .iter()
1598 .any(|f| f.eq_ignore_ascii_case(&fmt))
1599 {
1600 self.preserve_compact_formats.push(fmt);
1601 }
1602 }
1603 if local.slow_command_threshold_ms != 5000 {
1604 self.slow_command_threshold_ms = local.slow_command_threshold_ms;
1605 }
1606 if local.theme != "default" {
1607 self.theme = local.theme;
1608 }
1609 if !local.buddy_enabled {
1610 self.buddy_enabled = false;
1611 }
1612 if !local.enable_wakeup_ctx {
1613 self.enable_wakeup_ctx = false;
1614 }
1615 if !local.redirect_exclude.is_empty() {
1616 self.redirect_exclude.extend(local.redirect_exclude);
1617 }
1618 if !local.disabled_tools.is_empty() {
1619 self.disabled_tools.extend(local.disabled_tools);
1620 }
1621 if local.prefer_native_editor {
1622 self.prefer_native_editor = true;
1623 }
1624 if !local.extra_ignore_patterns.is_empty() {
1625 self.extra_ignore_patterns
1626 .extend(local.extra_ignore_patterns);
1627 }
1628 // Index filters (#735): repo-local excludes extend the global list; a
1629 // repo-local include set (the stricter, corpus-defining axis) replaces
1630 // the global one; gitignore handling can only be switched off locally
1631 // (same only-tighten pattern as the bool flags above).
1632 if !local.index.exclude.is_empty() {
1633 self.index.exclude.extend(local.index.exclude);
1634 }
1635 if !local.index.include.is_empty() {
1636 self.index.include = local.index.include;
1637 }
1638 if !local.index.respect_gitignore {
1639 self.index.respect_gitignore = false;
1640 }
1641 if local.rules_scope.is_some() {
1642 self.rules_scope = local.rules_scope;
1643 }
1644 if local.rules_injection.is_some() {
1645 self.rules_injection = local.rules_injection;
1646 }
1647 if local.permission_inheritance.is_some() {
1648 self.permission_inheritance = local.permission_inheritance;
1649 }
1650 if local.proxy.anthropic_upstream.is_some() {
1651 self.proxy.anthropic_upstream = local.proxy.anthropic_upstream;
1652 }
1653 if local.proxy.openai_upstream.is_some() {
1654 self.proxy.openai_upstream = local.proxy.openai_upstream;
1655 }
1656 if local.proxy.chatgpt_upstream.is_some() {
1657 self.proxy.chatgpt_upstream = local.proxy.chatgpt_upstream;
1658 }
1659 if local.proxy.gemini_upstream.is_some() {
1660 self.proxy.gemini_upstream = local.proxy.gemini_upstream;
1661 }
1662 if !local.autonomy.enabled {
1663 self.autonomy.enabled = false;
1664 }
1665 if !local.autonomy.auto_preload {
1666 self.autonomy.auto_preload = false;
1667 }
1668 if !local.autonomy.auto_dedup {
1669 self.autonomy.auto_dedup = false;
1670 }
1671 if !local.autonomy.auto_related {
1672 self.autonomy.auto_related = false;
1673 }
1674 if !local.autonomy.auto_consolidate {
1675 self.autonomy.auto_consolidate = false;
1676 }
1677 if local.autonomy.silent_preload {
1678 self.autonomy.silent_preload = true;
1679 }
1680 if !local.autonomy.silent_preload && self.autonomy.silent_preload {
1681 self.autonomy.silent_preload = false;
1682 }
1683 if local.autonomy.dedup_threshold != AutonomyConfig::default().dedup_threshold {
1684 self.autonomy.dedup_threshold = local.autonomy.dedup_threshold;
1685 }
1686 if local.autonomy.consolidate_every_calls
1687 != AutonomyConfig::default().consolidate_every_calls
1688 {
1689 self.autonomy.consolidate_every_calls = local.autonomy.consolidate_every_calls;
1690 }
1691 if local.autonomy.consolidate_cooldown_secs
1692 != AutonomyConfig::default().consolidate_cooldown_secs
1693 {
1694 self.autonomy.consolidate_cooldown_secs = local.autonomy.consolidate_cooldown_secs;
1695 }
1696 if !local.autonomy.cognition_loop_enabled {
1697 self.autonomy.cognition_loop_enabled = false;
1698 }
1699 if local.autonomy.cognition_loop_interval_secs
1700 != AutonomyConfig::default().cognition_loop_interval_secs
1701 {
1702 self.autonomy.cognition_loop_interval_secs =
1703 local.autonomy.cognition_loop_interval_secs;
1704 }
1705 if local.autonomy.cognition_loop_max_steps
1706 != AutonomyConfig::default().cognition_loop_max_steps
1707 {
1708 self.autonomy.cognition_loop_max_steps = local.autonomy.cognition_loop_max_steps;
1709 }
1710 if local_toml.contains("compression_level") {
1711 self.compression_level = local.compression_level;
1712 }
1713 if local_toml.contains("compression_aggressiveness") {
1714 self.compression_aggressiveness = local.compression_aggressiveness;
1715 }
1716 if local_toml.contains("terse_agent") {
1717 self.terse_agent = local.terse_agent;
1718 }
1719 if !local.archive.enabled {
1720 self.archive.enabled = false;
1721 }
1722 if local.archive.threshold_chars != ArchiveConfig::default().threshold_chars {
1723 self.archive.threshold_chars = local.archive.threshold_chars;
1724 }
1725 if local.archive.max_age_hours != ArchiveConfig::default().max_age_hours {
1726 self.archive.max_age_hours = local.archive.max_age_hours;
1727 }
1728 if local.archive.max_disk_mb != ArchiveConfig::default().max_disk_mb {
1729 self.archive.max_disk_mb = local.archive.max_disk_mb;
1730 }
1731 if !local.archive.ephemeral {
1732 self.archive.ephemeral = false;
1733 }
1734 if local.archive.ephemeral_min_tokens != ArchiveConfig::default().ephemeral_min_tokens {
1735 self.archive.ephemeral_min_tokens = local.archive.ephemeral_min_tokens;
1736 }
1737 let mem_def = MemoryPolicy::default();
1738 if local.memory.knowledge.max_facts != mem_def.knowledge.max_facts {
1739 self.memory.knowledge.max_facts = local.memory.knowledge.max_facts;
1740 }
1741 if local.memory.knowledge.max_patterns != mem_def.knowledge.max_patterns {
1742 self.memory.knowledge.max_patterns = local.memory.knowledge.max_patterns;
1743 }
1744 if local.memory.knowledge.max_history != mem_def.knowledge.max_history {
1745 self.memory.knowledge.max_history = local.memory.knowledge.max_history;
1746 }
1747 if local.memory.knowledge.contradiction_threshold
1748 != mem_def.knowledge.contradiction_threshold
1749 {
1750 self.memory.knowledge.contradiction_threshold =
1751 local.memory.knowledge.contradiction_threshold;
1752 }
1753
1754 if local.memory.episodic.max_episodes != mem_def.episodic.max_episodes {
1755 self.memory.episodic.max_episodes = local.memory.episodic.max_episodes;
1756 }
1757 if local.memory.episodic.max_actions_per_episode != mem_def.episodic.max_actions_per_episode
1758 {
1759 self.memory.episodic.max_actions_per_episode =
1760 local.memory.episodic.max_actions_per_episode;
1761 }
1762 if local.memory.episodic.summary_max_chars != mem_def.episodic.summary_max_chars {
1763 self.memory.episodic.summary_max_chars = local.memory.episodic.summary_max_chars;
1764 }
1765
1766 if local.memory.procedural.min_repetitions != mem_def.procedural.min_repetitions {
1767 self.memory.procedural.min_repetitions = local.memory.procedural.min_repetitions;
1768 }
1769 if local.memory.procedural.min_sequence_len != mem_def.procedural.min_sequence_len {
1770 self.memory.procedural.min_sequence_len = local.memory.procedural.min_sequence_len;
1771 }
1772 if local.memory.procedural.max_procedures != mem_def.procedural.max_procedures {
1773 self.memory.procedural.max_procedures = local.memory.procedural.max_procedures;
1774 }
1775 if local.memory.procedural.max_window_size != mem_def.procedural.max_window_size {
1776 self.memory.procedural.max_window_size = local.memory.procedural.max_window_size;
1777 }
1778
1779 if local.memory.lifecycle.decay_rate != mem_def.lifecycle.decay_rate {
1780 self.memory.lifecycle.decay_rate = local.memory.lifecycle.decay_rate;
1781 }
1782 if local.memory.lifecycle.low_confidence_threshold
1783 != mem_def.lifecycle.low_confidence_threshold
1784 {
1785 self.memory.lifecycle.low_confidence_threshold =
1786 local.memory.lifecycle.low_confidence_threshold;
1787 }
1788 if local.memory.lifecycle.stale_days != mem_def.lifecycle.stale_days {
1789 self.memory.lifecycle.stale_days = local.memory.lifecycle.stale_days;
1790 }
1791 if local.memory.lifecycle.similarity_threshold != mem_def.lifecycle.similarity_threshold {
1792 self.memory.lifecycle.similarity_threshold =
1793 local.memory.lifecycle.similarity_threshold;
1794 }
1795 if local.memory.lifecycle.reclaim_headroom_pct != mem_def.lifecycle.reclaim_headroom_pct {
1796 self.memory.lifecycle.reclaim_headroom_pct =
1797 local.memory.lifecycle.reclaim_headroom_pct;
1798 }
1799 if local.memory.lifecycle.reclaim_enabled != mem_def.lifecycle.reclaim_enabled {
1800 self.memory.lifecycle.reclaim_enabled = local.memory.lifecycle.reclaim_enabled;
1801 }
1802
1803 if local.memory.embeddings.max_facts != mem_def.embeddings.max_facts {
1804 self.memory.embeddings.max_facts = local.memory.embeddings.max_facts;
1805 }
1806 if !local.allow_paths.is_empty() {
1807 self.allow_paths.extend(local.allow_paths);
1808 }
1809 if !local.extra_roots.is_empty() {
1810 self.extra_roots.extend(local.extra_roots);
1811 }
1812 // Project-local config may only ADD read-only roots (tighten the write
1813 // boundary), never remove them — merge mirrors extra_roots (#475).
1814 if !local.read_only_roots.is_empty() {
1815 self.read_only_roots.extend(local.read_only_roots);
1816 }
1817 // Symlink write-through roots (#596) follow extra_roots: a *trusted*
1818 // workspace may add roots, an untrusted one is stripped above.
1819 if !local.allow_symlink_roots.is_empty() {
1820 self.allow_symlink_roots.extend(local.allow_symlink_roots);
1821 }
1822 if local.minimal_overhead {
1823 self.minimal_overhead = true;
1824 }
1825 if local.shell_hook_disabled {
1826 self.shell_hook_disabled = true;
1827 }
1828 if local.shell_activation != ShellActivation::default() {
1829 self.shell_activation = local.shell_activation.clone();
1830 }
1831 if local.read_redirect != ReadRedirect::default() {
1832 self.read_redirect = local.read_redirect;
1833 }
1834 if local.read_dedup != ReadDedup::default() {
1835 self.read_dedup = local.read_dedup;
1836 }
1837 if local.bm25_max_cache_mb != default_bm25_max_cache_mb() {
1838 self.bm25_max_cache_mb = local.bm25_max_cache_mb;
1839 }
1840 if local.memory_profile != MemoryProfile::default() {
1841 self.memory_profile = local.memory_profile;
1842 }
1843 if local.memory_cleanup != MemoryCleanup::default() {
1844 self.memory_cleanup = local.memory_cleanup;
1845 }
1846 // Only override when the local file actually defines `shell_allowlist`.
1847 // The field carries `#[serde(default = "default_shell_allowlist")]`, so a
1848 // local `.lean-ctx.toml` that omits the key still deserializes to the full
1849 // 201-entry built-in list — an `is_empty()` guard would then silently clobber
1850 // a deliberately shorter global allowlist with the defaults. Comparing against
1851 // the default (the same pattern used for every other merged field) treats
1852 // "omitted" as "no override".
1853 if local.shell_allowlist != default_shell_allowlist() {
1854 self.shell_allowlist = local.shell_allowlist;
1855 }
1856 if !local.shell_allowlist_extra.is_empty() {
1857 self.shell_allowlist_extra
1858 .extend(local.shell_allowlist_extra);
1859 }
1860 if !local.default_tool_categories.is_empty() {
1861 self.default_tool_categories = local.default_tool_categories;
1862 }
1863 if local.tool_profile.is_some() {
1864 self.tool_profile = local.tool_profile;
1865 }
1866 if !local.tools_enabled.is_empty() {
1867 self.tools_enabled = local.tools_enabled;
1868 }
1869 if local.no_degrade {
1870 self.no_degrade = true;
1871 }
1872 if local.delta_explicit {
1873 self.delta_explicit = true;
1874 }
1875 if local.profile.is_some() {
1876 self.profile = local.profile;
1877 }
1878 if local.proxy_timeout_ms.is_some() {
1879 self.proxy_timeout_ms = local.proxy_timeout_ms;
1880 }
1881 }
1882
1883 /// Loads ONLY the global config file — never merging project-local
1884 /// `.lean-ctx.toml` overrides, and bypassing the in-memory cache. Every
1885 /// PERSIST path must use this (or [`Config::update_global`]): [`Config::load`]
1886 /// folds per-project overrides into the struct, and [`Config::save`] writes
1887 /// the whole struct back to the GLOBAL file — so a `load → mutate → save`
1888 /// round-trip silently leaks per-project values (and, historically, reset
1889 /// customized keys) into the global config (#443). Reading global-only makes
1890 /// the save leak-free by construction.
1891 pub fn load_global() -> Self {
1892 Self::path().map_or_else(Self::default, |p| Self::load_global_from(&p))
1893 }
1894
1895 /// Path-parameterized core of [`Config::load_global`] (unit-testable without
1896 /// the real config dir). Missing, empty, or unparseable files yield
1897 /// defaults; persisting callers that must not clobber a corrupt file use
1898 /// [`Config::update_global`], which refuses instead.
1899 fn load_global_from(path: &Path) -> Self {
1900 match std::fs::read_to_string(path) {
1901 Ok(raw) if !raw.trim().is_empty() => toml::from_str(&raw).unwrap_or_default(),
1902 _ => Self::default(),
1903 }
1904 }
1905
1906 /// Safely mutate and persist the GLOBAL config. Reads the global file only
1907 /// (no project-local merge), applies `f`, then writes minimally. Refuses
1908 /// (returns `Err`) when the file exists but is unparseable, so a typo can
1909 /// never clobber a customized config (#443). Returns the saved `Config`.
1910 ///
1911 /// This is the canonical persistence entry point: prefer it over
1912 /// `Config::load()` followed by `save()`, which leaks project-local
1913 /// overrides into the global file.
1914 pub fn update_global<F>(f: F) -> std::result::Result<Self, super::error::LeanCtxError>
1915 where
1916 F: FnOnce(&mut Self),
1917 {
1918 let path = Self::path().ok_or_else(|| {
1919 super::error::LeanCtxError::Config("cannot determine home directory".into())
1920 })?;
1921 Self::update_global_at(&path, f)
1922 }
1923
1924 /// Path-parameterized core of [`Config::update_global`] (unit-testable).
1925 fn update_global_at<F>(
1926 path: &Path,
1927 f: F,
1928 ) -> std::result::Result<Self, super::error::LeanCtxError>
1929 where
1930 F: FnOnce(&mut Self),
1931 {
1932 let mut cfg = match std::fs::read_to_string(path) {
1933 Ok(raw) if !raw.trim().is_empty() => toml::from_str::<Self>(&raw).map_err(|e| {
1934 super::error::LeanCtxError::Config(format!(
1935 "refusing to modify an unparseable config.toml ({e}); fix it \
1936 manually or run `lean-ctx doctor --fix`, then retry"
1937 ))
1938 })?,
1939 _ => Self::default(),
1940 };
1941 f(&mut cfg);
1942 cfg.save_to(path)?;
1943 Ok(cfg)
1944 }
1945
1946 /// Persists the current config to the global config file.
1947 ///
1948 /// Preserves user comments, formatting, and unknown keys, keeps the file
1949 /// minimal (defaults that were never set on disk stay implicit), and writes
1950 /// atomically with a `.bak` backup so customizations are always recoverable.
1951 pub fn save(&self) -> std::result::Result<(), super::error::LeanCtxError> {
1952 let path = Self::path().ok_or_else(|| {
1953 super::error::LeanCtxError::Config("cannot determine home directory".into())
1954 })?;
1955 self.save_to(&path)
1956 }
1957
1958 /// Path-parameterized core of [`Config::save`] (unit-testable).
1959 fn save_to(&self, path: &Path) -> std::result::Result<(), super::error::LeanCtxError> {
1960 if let Some(parent) = path.parent() {
1961 std::fs::create_dir_all(parent)?;
1962 }
1963 let content = toml::to_string_pretty(self)
1964 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1965 // Baseline = what loading an empty config yields. This honors serde's
1966 // field-level `#[serde(default)]` (which can diverge from the struct's
1967 // `Default` impl), so minimal mode skips exactly the keys that a fresh
1968 // load would produce — no spurious lines on save.
1969 let baseline = toml::from_str::<Self>("").unwrap_or_else(|_| Self::default());
1970 let defaults = toml::to_string_pretty(&baseline)
1971 .map_err(|e| super::error::LeanCtxError::Config(e.to_string()))?;
1972 crate::config_io::write_toml_preserving_minimal(path, &content, &defaults)
1973 .map_err(super::error::LeanCtxError::Config)?;
1974 Ok(())
1975 }
1976
1977 /// Formats the current config as a human-readable string with file paths.
1978 pub fn show(&self) -> String {
1979 let global_path = Self::path().map_or_else(
1980 || "~/.lean-ctx/config.toml".to_string(),
1981 |p| p.to_string_lossy().to_string(),
1982 );
1983 let content = toml::to_string_pretty(self).unwrap_or_default();
1984 let mut out = format!("Global config: {global_path}\n\n{content}");
1985
1986 if let Some(root) = Self::find_project_root() {
1987 let local = Self::local_path(&root);
1988 if local.exists() {
1989 out.push_str(&format!("\n\nLocal config (merged): {}\n", local.display()));
1990 } else {
1991 out.push_str(&format!(
1992 "\n\nLocal config: not found (create {} to override per-project)\n",
1993 local.display()
1994 ));
1995 }
1996 }
1997 out
1998 }
1999}