Skip to main content

lean_ctx/core/config/schema/
mod.rs

1//! Auto-generated config schema from `Config` struct metadata.
2//!
3//! Used by `lean-ctx config schema` to emit JSON and by
4//! `lean-ctx config validate` to check user config.toml files.
5
6use serde::Serialize;
7use std::collections::BTreeMap;
8mod sections_advanced;
9mod sections_core;
10mod sections_features;
11
12#[derive(Debug, Clone, Serialize)]
13pub struct ConfigSchema {
14    pub version: u32,
15    pub sections: BTreeMap<String, SectionSchema>,
16}
17
18#[derive(Debug, Clone, Serialize)]
19pub struct SectionSchema {
20    pub description: String,
21    pub keys: BTreeMap<String, KeySchema>,
22}
23
24#[derive(Debug, Clone, Serialize)]
25pub struct KeySchema {
26    #[serde(rename = "type")]
27    pub ty: String,
28    pub default: serde_json::Value,
29    pub description: String,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub values: Option<Vec<String>>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub env_override: Option<String>,
34}
35
36fn clean_f32(v: f32) -> serde_json::Value {
37    let clean: f64 = format!("{v}").parse().unwrap_or(v as f64);
38    serde_json::json!(clean)
39}
40
41fn key(ty: &str, default: serde_json::Value, desc: &str) -> KeySchema {
42    KeySchema {
43        ty: ty.to_string(),
44        default,
45        description: desc.to_string(),
46        values: None,
47        env_override: None,
48    }
49}
50
51fn key_enum(values: &[&str], default: &str, desc: &str) -> KeySchema {
52    KeySchema {
53        ty: "enum".to_string(),
54        default: serde_json::Value::String(default.to_string()),
55        description: desc.to_string(),
56        values: Some(values.iter().map(ToString::to_string).collect()),
57        env_override: None,
58    }
59}
60
61fn key_with_env(ty: &str, default: serde_json::Value, desc: &str, env: &str) -> KeySchema {
62    KeySchema {
63        ty: ty.to_string(),
64        default,
65        description: desc.to_string(),
66        values: None,
67        env_override: Some(env.to_string()),
68    }
69}
70
71fn key_enum_with_env(values: &[&str], default: &str, desc: &str, env: &str) -> KeySchema {
72    KeySchema {
73        ty: "enum".to_string(),
74        default: serde_json::Value::String(default.to_string()),
75        description: desc.to_string(),
76        values: Some(values.iter().map(ToString::to_string).collect()),
77        env_override: Some(env.to_string()),
78    }
79}
80
81/// `[governor]`: the Claude Code governor's rule modes and per-host pins.
82fn governor_section() -> SectionSchema {
83    let modes = ["on", "shadow", "off"];
84    let mut keys = BTreeMap::new();
85    for (rule, builtin, env, what) in [
86        (
87            "r1_read",
88            "on",
89            "LEAN_CTX_GOVERNOR_R1_READ",
90            "identical re-read of an unchanged file",
91        ),
92        (
93            "r1_bash",
94            "shadow",
95            "LEAN_CTX_GOVERNOR_R1_BASH",
96            "identical re-run of a viewer command",
97        ),
98        (
99            "r2",
100            "shadow",
101            "LEAN_CTX_GOVERNOR_R2",
102            "lossless repeat collapse of large Bash output",
103        ),
104        (
105            "r3",
106            "shadow",
107            "LEAN_CTX_GOVERNOR_R3",
108            "output already in context (never above shadow)",
109        ),
110        (
111            "r4",
112            "shadow",
113            "LEAN_CTX_GOVERNOR_R4",
114            "template collapse of large Bash output",
115        ),
116    ] {
117        keys.insert(
118            rule.into(),
119            key_enum_with_env(
120                &modes,
121                builtin,
122                &format!(
123                    "Governor rule {rule}: {what}. Unset = built-in default; a governor \
124                     install writes on (r3: shadow). LEAN_CTX_GOVERNOR caps every rule"
125                ),
126                env,
127            ),
128        );
129    }
130    keys.insert(
131        "pinned".into(),
132        key(
133            "table",
134            serde_json::json!({}),
135            "Setup mode chosen per host, e.g. claude = \"hybrid\". Written by `lean-ctx init \
136             --agent <host> --mode <mode>`; a pinned host is never switched to the governor \
137             by setup or an update",
138        ),
139    );
140    SectionSchema {
141        description: "Claude Code governor (`hook govern`): rule modes (env \
142                      LEAN_CTX_GOVERNOR_* overrides) and per-host setup pins. The governor \
143                      is the default Claude Code setup."
144            .into(),
145        keys,
146    }
147}
148
149/// `hook_mode` (root section); kept here so `sections_core.rs` stays under the
150/// LOC gate.
151fn hook_mode_key() -> KeySchema {
152    key_enum_with_env(
153        &["auto", "hybrid", "replace", "mcp"],
154        "auto",
155        "How lean-ctx integrates with agents. auto (default): Hybrid for every \
156         hook-capable agent — native calls are not refused, known commands are \
157         rewritten through lean-ctx; Claude Code's tool list leaves out native \
158         Grep/Glob (no refusals, searches stay compressed). hybrid: the same, \
159         with Grep/Glob back in Claude Code. replace: deny native \
160         Grep/Glob (and non-rewritable shell where the host supports it) so \
161         ctx_* MCP tools are the only path; also set by `lean-ctx harden \
162         --hard`. mcp: MCP server only, no hooks",
163        "LEAN_CTX_HOOK_MODE",
164    )
165}
166
167/// `daemon_max_rss_mb` (root section); kept here so `sections_core.rs` stays
168/// under the LOC gate.
169fn daemon_max_rss_key(cfg: &crate::core::config::Config) -> KeySchema {
170    key_with_env(
171        "u64",
172        serde_json::json!(cfg.daemon_max_rss_mb),
173        "Process RSS target (MB) of the background daemon; the guardian evicts above \
174         the lower of this and max_ram_percent (default 2048, 0 = percent only)",
175        "LEAN_CTX_DAEMON_MAX_RSS_MB",
176    )
177}
178
179/// The global-only signer-trust opt-ins (root section); kept here so
180/// `sections_core.rs` stays under the LOC gate.
181fn signer_trust_keys() -> [(&'static str, KeySchema); 2] {
182    [
183        (
184            "a2a_accept_untrusted_signers",
185            key(
186                "bool",
187                serde_json::json!(false),
188                "Accept A2A handoff bundles and context packages (/v1/a2a/handoff, /a2a/deliver) signed by a key that is not pinned in trusted-signers.toml. Off by default: such signatures prove integrity, not who sent them, so they are rejected with 403 untrusted_signer. Global-only",
189            ),
190        ),
191        (
192            "mcp_import_allow_unverified",
193            key(
194                "bool",
195                serde_json::json!(false),
196                "Let MCP tools (ctx_handoff import, ctx_pack import) import bundles and packages that are unsigned or signed by a key not pinned in trusted-signers.toml. A tool argument cannot grant this (prompt injection); CLI imports keep --allow-unsigned. Global-only",
197            ),
198        ),
199    ]
200}
201
202impl ConfigSchema {
203    pub fn generate() -> Self {
204        let mut sections = BTreeMap::new();
205        sections_core::build(&mut sections);
206        sections_features::build(&mut sections);
207        sections_advanced::build(&mut sections);
208        sections.insert("hive".into(), crate::core::config::hive::schema_section());
209        sections.insert("intelligence_runtime".into(), SectionSchema {
210            description: "Global-only optional runtime consent and independent staging pins; project overrides never apply".into(),
211            keys: [
212                ("enabled", key("bool", serde_json::json!(false), "Opt in to the verified local runtime")),
213                ("accept_proprietary", key("bool", serde_json::json!(false), "Explicit user acceptance of the separately licensed runtime")),
214                ("staging", key("bool", serde_json::json!(false), "Staging-only delivery; not production release approval")),
215                ("root", key("string", serde_json::json!(""), "Absolute private installation root")),
216                ("manifest_sha256", key("string", serde_json::json!(""), "Independently selected manifest digest")),
217                ("trust_key_hex", key("string", serde_json::json!(""), "Independently provisioned Ed25519 public key, lowercase hex")),
218                ("channel_url", key("string", serde_json::json!(""), "Explicit staging catalog URL; fetched only after consent")),
219                ("channel_signature_url", key("string", serde_json::json!(""), "Detached staging catalog signature URL")),
220                ("channel_root_key_hex", key("string", serde_json::json!(""), "Independently provisioned catalog root; never learned from a download")),
221                ("license_configuration", key("string", serde_json::json!(""), "Explicit user-global path to private license configuration; saved after installed device provisioning")),
222                ("context_policy_apply", key("bool", serde_json::json!(false), "No effect (kept so existing configs load): read-strategy policies are no longer learned by a runtime; planning always uses the local modes")),
223                ("team_artifact_projects", key("array", serde_json::json!([]), "Projects that read shared team artifacts: tables of root (absolute), organization_id, project_id and repository_id. Empty: no project uses an artifact")),
224            ].into_iter().map(|(name, value)| (name.into(), value)).collect(),
225        });
226
227        ConfigSchema {
228            version: 1,
229            sections,
230        }
231    }
232
233    /// Looks up a key schema by its dot-separated TOML path.
234    /// Returns `None` if the key is not part of the schema.
235    pub fn lookup(&self, key: &str) -> Option<&KeySchema> {
236        if !key.contains('.') {
237            return self.sections.get("root")?.keys.get(key);
238        }
239        // Try progressively longer section prefixes so that dotted sections
240        // like `memory.lifecycle` or `proxy.role_aggressiveness` resolve
241        // correctly: `memory.lifecycle.stale_days` → section=`memory.lifecycle`,
242        // field=`stale_days`.
243        for (i, _) in key.rmatch_indices('.') {
244            let section = &key[..i];
245            let field = &key[i + 1..];
246            if let Some(schema) = self.sections.get(section) {
247                if let Some(ks) = schema.keys.get(field) {
248                    return Some(ks);
249                }
250            }
251        }
252        None
253    }
254
255    /// All known TOML keys (dot-separated) for validation.
256    ///
257    /// Combines the hand-written schema (which carries descriptions, types and
258    /// help text) with the keys derived from the live `Config` struct. The
259    /// struct is the source of truth for *what is valid*, so a field added to
260    /// `Config` is recognised by `config apply` / `config validate` immediately,
261    /// without anyone remembering to mirror it into `sections_*.rs` (#456).
262    pub fn known_keys(&self) -> Vec<String> {
263        let mut keys = Vec::new();
264        for (section, schema) in &self.sections {
265            if section == "root" {
266                for key_name in schema.keys.keys() {
267                    keys.push(key_name.clone());
268                }
269            } else {
270                if schema.keys.is_empty() {
271                    keys.push(section.clone());
272                }
273                for key_name in schema.keys.keys() {
274                    keys.push(format!("{section}.{key_name}"));
275                }
276            }
277        }
278        keys.extend(config_derived_keys());
279        keys.sort();
280        keys.dedup();
281        keys
282    }
283}
284
285/// Every TOML key the `Config` struct serialises to, in dot-separated form
286/// (e.g. `proxy_require_token`, `memory`, `memory.episodic`). Derived from
287/// `Config::default()` so validation tracks the struct automatically (#456).
288///
289/// Option fields that default to `None` are omitted by serde and therefore not
290/// listed here; those keys still come from the hand-written schema. Emitting the
291/// bare section name (e.g. `memory`) lets the `starts_with("section.")` rule in
292/// the validators accept the whole section, matching how empty schema sections
293/// already behave.
294fn config_derived_keys() -> Vec<String> {
295    fn walk(table: &toml::value::Table, prefix: &str, out: &mut Vec<String>) {
296        for (k, v) in table {
297            let full = if prefix.is_empty() {
298                k.clone()
299            } else {
300                format!("{prefix}.{k}")
301            };
302            if let toml::Value::Table(sub) = v {
303                out.push(full.clone());
304                walk(sub, &full, out);
305            } else {
306                out.push(full);
307            }
308        }
309    }
310
311    let mut out = Vec::new();
312    if let Ok(toml::Value::Table(table)) =
313        toml::Value::try_from(crate::core::config::Config::default())
314    {
315        walk(&table, "", &mut out);
316    }
317    out
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    /// Mirrors the acceptance rule used by `config validate` / `config apply`:
325    /// a key is valid if it is listed verbatim or sits under a known section.
326    fn accepted(known: &[String], key: &str) -> bool {
327        known.iter().any(|k| k == key) || known.iter().any(|k| key.starts_with(&format!("{k}.")))
328    }
329
330    /// #456: every field the `Config` struct actually serialises must be
331    /// accepted by validation. Before the fix, 38 real keys/sections
332    /// (`proxy_require_token`, `memory.*`, `providers.*`, `proxy`, …) were
333    /// flagged "unknown" because the hand-written schema had drifted.
334    #[test]
335    fn known_keys_cover_every_config_struct_field() {
336        let known = ConfigSchema::generate().known_keys();
337        let missing: Vec<_> = config_derived_keys()
338            .into_iter()
339            .filter(|k| !accepted(&known, k))
340            .collect();
341        assert!(
342            missing.is_empty(),
343            "config struct fields not recognised by validation (schema drift): {missing:?}"
344        );
345    }
346
347    /// Spot-check the concrete keys from the #456 report so a future schema/struct
348    /// refactor that reintroduces the drift fails loudly.
349    #[test]
350    fn known_keys_recognise_reported_456_keys() {
351        let known = ConfigSchema::generate().known_keys();
352        for key in [
353            "proxy_require_token",
354            "allow_ide_config_dirs",
355            "memory.episodic",
356            "providers.github",
357            "proxy",
358        ] {
359            assert!(
360                accepted(&known, key),
361                "validation must recognise '{key}' (#456)"
362            );
363        }
364    }
365
366    /// `config set` resolves keys via [`ConfigSchema::lookup`] — the hand-written
367    /// schema only, NOT `known_keys()` (which also folds in `config_derived_keys`).
368    /// An `Option<_>` scalar field defaults to `None`, so serde omits it from
369    /// `Config::default()` and it never appears in `config_derived_keys`: such a
370    /// field is settable via `config set` **only** if it was hand-added to a
371    /// `sections_*.rs` schema. Forgetting that is the `Unknown config key: <x>`
372    /// regression a user hit for `path_jail` before #507 (and `persona` /
373    /// `bypass_hints` here). Guard the whole class so a new `Option` knob can't
374    /// silently become un-settable again — if you add an `Option` scalar to
375    /// `Config`, register it in `sections_*.rs` and list it here.
376    #[test]
377    fn option_scalar_keys_are_cli_settable() {
378        let schema = ConfigSchema::generate();
379        for key in [
380            "path_jail",
381            "path_jail_scope",
382            "persona",
383            "bypass_hints",
384            "shell_security",
385            "cache_policy",
386            "profile",
387            "tool_profile",
388            "rules_scope",
389            "rules_injection",
390            "permission_inheritance",
391            "proxy_enabled",
392            "proxy_port",
393            "proxy_timeout_ms",
394        ] {
395            assert!(
396                schema.lookup(key).is_some(),
397                "`lean-ctx config set {key} <v>` fails with 'Unknown config key' — \
398                 add `{key}` to a sections_*.rs schema"
399            );
400        }
401    }
402
403    #[test]
404    fn proxy_require_token_is_cli_settable() {
405        let schema = ConfigSchema::generate();
406        assert!(
407            schema.lookup("proxy_require_token").is_some(),
408            "`lean-ctx config set proxy_require_token <bool>` must be accepted"
409        );
410    }
411
412    #[test]
413    fn dotted_section_keys_are_cli_settable() {
414        let schema = ConfigSchema::generate();
415        for key in [
416            "memory.lifecycle.stale_days",
417            "memory.knowledge.max_facts",
418            "memory.episodic.max_episodes",
419            "proxy.role_aggressiveness.system",
420        ] {
421            assert!(
422                schema.lookup(key).is_some(),
423                "`lean-ctx config set {key} <v>` fails with \"Unknown config key\" — lookup must handle dotted section names"
424            );
425        }
426    }
427
428    #[test]
429    fn decision_loop_keys_are_cli_settable_with_types_and_defaults() {
430        let schema = ConfigSchema::generate();
431        for (key, ty, default) in [
432            ("decision_loop.enabled", "bool", serde_json::json!(true)),
433            ("decision_loop.max_filter_level", "u8", serde_json::json!(0)),
434        ] {
435            let entry = schema
436                .lookup(key)
437                .unwrap_or_else(|| panic!("config set {key} must be recognized"));
438            assert_eq!(entry.ty, ty, "schema type for {key}");
439            assert_eq!(entry.default, default, "schema default for {key}");
440        }
441    }
442
443    #[test]
444    fn permission_inheritance_defaults_to_on() {
445        let cfg = super::super::Config::default();
446        assert_eq!(
447            cfg.permission_inheritance.as_deref(),
448            Some("on"),
449            "permission_inheritance must default to On so IDE permission rules are honored out of the box"
450        );
451    }
452
453    /// #1605: the `[llm]` schema was hand-written with literal defaults and had
454    /// drifted from `LlmConfig` — it named an `api_key` key that no field backs
455    /// (so `config set llm.api_key <secret>` printed "Updated" and then dropped
456    /// the value in the serde round-trip), advertised `llama3.2` as the model
457    /// default, and omitted `base_url` entirely. Every `[llm]` schema key must
458    /// name a real serialised field, and its default must be the struct's.
459    #[test]
460    fn llm_schema_keys_match_the_llm_config_struct() {
461        let schema = ConfigSchema::generate();
462        let section = schema
463            .sections
464            .get("llm")
465            .expect("[llm] section must exist in the schema");
466        let defaults = serde_json::to_value(crate::core::config::LlmConfig::default())
467            .expect("LlmConfig serialises");
468        let defaults = defaults.as_object().expect("LlmConfig is a struct");
469
470        for (key, entry) in &section.keys {
471            let actual = defaults.get(key).unwrap_or_else(|| {
472                panic!(
473                    "schema key `llm.{key}` has no field on LlmConfig — \
474                     `config set llm.{key} <v>` would report a save that serde discards"
475                )
476            });
477            // `Option<String>` serialises as null; the schema shows an empty string.
478            let expected = if actual.is_null() {
479                serde_json::json!("")
480            } else {
481                actual.clone()
482            };
483            assert_eq!(
484                entry.default, expected,
485                "schema default for `llm.{key}` has drifted from LlmConfig::default()"
486            );
487        }
488
489        assert!(
490            schema.lookup("llm.api_key").is_none(),
491            "`llm.api_key` is not a config field — the credential comes from \
492             OPENROUTER_API_KEY / ANTHROPIC_API_KEY, so `config set` must reject it"
493        );
494        assert!(
495            schema.lookup("llm.base_url").is_some(),
496            "`llm.base_url` is a real field and must be settable"
497        );
498    }
499}