1use 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
81fn 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
149fn 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
167fn 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
179fn 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 pub fn lookup(&self, key: &str) -> Option<&KeySchema> {
236 if !key.contains('.') {
237 return self.sections.get("root")?.keys.get(key);
238 }
239 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 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
285fn 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 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 #[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 #[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 #[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 #[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 §ion.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 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}