use serde::Serialize;
use std::collections::BTreeMap;
mod sections_advanced;
mod sections_core;
mod sections_features;
#[derive(Debug, Clone, Serialize)]
pub struct ConfigSchema {
pub version: u32,
pub sections: BTreeMap<String, SectionSchema>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SectionSchema {
pub description: String,
pub keys: BTreeMap<String, KeySchema>,
}
#[derive(Debug, Clone, Serialize)]
pub struct KeySchema {
#[serde(rename = "type")]
pub ty: String,
pub default: serde_json::Value,
pub description: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub values: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_override: Option<String>,
}
fn clean_f32(v: f32) -> serde_json::Value {
let clean: f64 = format!("{v}").parse().unwrap_or(v as f64);
serde_json::json!(clean)
}
fn key(ty: &str, default: serde_json::Value, desc: &str) -> KeySchema {
KeySchema {
ty: ty.to_string(),
default,
description: desc.to_string(),
values: None,
env_override: None,
}
}
fn key_enum(values: &[&str], default: &str, desc: &str) -> KeySchema {
KeySchema {
ty: "enum".to_string(),
default: serde_json::Value::String(default.to_string()),
description: desc.to_string(),
values: Some(values.iter().map(ToString::to_string).collect()),
env_override: None,
}
}
fn key_with_env(ty: &str, default: serde_json::Value, desc: &str, env: &str) -> KeySchema {
KeySchema {
ty: ty.to_string(),
default,
description: desc.to_string(),
values: None,
env_override: Some(env.to_string()),
}
}
fn key_enum_with_env(values: &[&str], default: &str, desc: &str, env: &str) -> KeySchema {
KeySchema {
ty: "enum".to_string(),
default: serde_json::Value::String(default.to_string()),
description: desc.to_string(),
values: Some(values.iter().map(ToString::to_string).collect()),
env_override: Some(env.to_string()),
}
}
fn governor_section() -> SectionSchema {
let modes = ["on", "shadow", "off"];
let mut keys = BTreeMap::new();
for (rule, builtin, env, what) in [
(
"r1_read",
"on",
"LEAN_CTX_GOVERNOR_R1_READ",
"identical re-read of an unchanged file",
),
(
"r1_bash",
"shadow",
"LEAN_CTX_GOVERNOR_R1_BASH",
"identical re-run of a viewer command",
),
(
"r2",
"shadow",
"LEAN_CTX_GOVERNOR_R2",
"lossless repeat collapse of large Bash output",
),
(
"r3",
"shadow",
"LEAN_CTX_GOVERNOR_R3",
"output already in context (never above shadow)",
),
(
"r4",
"shadow",
"LEAN_CTX_GOVERNOR_R4",
"template collapse of large Bash output",
),
] {
keys.insert(
rule.into(),
key_enum_with_env(
&modes,
builtin,
&format!(
"Governor rule {rule}: {what}. Unset = built-in default; a governor \
install writes on (r3: shadow). LEAN_CTX_GOVERNOR caps every rule"
),
env,
),
);
}
keys.insert(
"pinned".into(),
key(
"table",
serde_json::json!({}),
"Setup mode chosen per host, e.g. claude = \"hybrid\". Written by `lean-ctx init \
--agent <host> --mode <mode>`; a pinned host is never switched to the governor \
by setup or an update",
),
);
SectionSchema {
description: "Claude Code governor (`hook govern`): rule modes (env \
LEAN_CTX_GOVERNOR_* overrides) and per-host setup pins. The governor \
is the default Claude Code setup."
.into(),
keys,
}
}
fn hook_mode_key() -> KeySchema {
key_enum_with_env(
&["auto", "hybrid", "replace", "mcp"],
"auto",
"How lean-ctx integrates with agents. auto (default): Hybrid for every \
hook-capable agent — native calls are not refused, known commands are \
rewritten through lean-ctx; Claude Code's tool list leaves out native \
Grep/Glob (no refusals, searches stay compressed). hybrid: the same, \
with Grep/Glob back in Claude Code. replace: deny native \
Grep/Glob (and non-rewritable shell where the host supports it) so \
ctx_* MCP tools are the only path; also set by `lean-ctx harden \
--hard`. mcp: MCP server only, no hooks",
"LEAN_CTX_HOOK_MODE",
)
}
fn daemon_max_rss_key(cfg: &crate::core::config::Config) -> KeySchema {
key_with_env(
"u64",
serde_json::json!(cfg.daemon_max_rss_mb),
"Process RSS target (MB) of the background daemon; the guardian evicts above \
the lower of this and max_ram_percent (default 2048, 0 = percent only)",
"LEAN_CTX_DAEMON_MAX_RSS_MB",
)
}
fn signer_trust_keys() -> [(&'static str, KeySchema); 2] {
[
(
"a2a_accept_untrusted_signers",
key(
"bool",
serde_json::json!(false),
"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",
),
),
(
"mcp_import_allow_unverified",
key(
"bool",
serde_json::json!(false),
"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",
),
),
]
}
impl ConfigSchema {
pub fn generate() -> Self {
let mut sections = BTreeMap::new();
sections_core::build(&mut sections);
sections_features::build(&mut sections);
sections_advanced::build(&mut sections);
sections.insert("hive".into(), crate::core::config::hive::schema_section());
sections.insert("intelligence_runtime".into(), SectionSchema {
description: "Global-only optional runtime consent and independent staging pins; project overrides never apply".into(),
keys: [
("enabled", key("bool", serde_json::json!(false), "Opt in to the verified local runtime")),
("accept_proprietary", key("bool", serde_json::json!(false), "Explicit user acceptance of the separately licensed runtime")),
("staging", key("bool", serde_json::json!(false), "Staging-only delivery; not production release approval")),
("root", key("string", serde_json::json!(""), "Absolute private installation root")),
("manifest_sha256", key("string", serde_json::json!(""), "Independently selected manifest digest")),
("trust_key_hex", key("string", serde_json::json!(""), "Independently provisioned Ed25519 public key, lowercase hex")),
("channel_url", key("string", serde_json::json!(""), "Explicit staging catalog URL; fetched only after consent")),
("channel_signature_url", key("string", serde_json::json!(""), "Detached staging catalog signature URL")),
("channel_root_key_hex", key("string", serde_json::json!(""), "Independently provisioned catalog root; never learned from a download")),
("license_configuration", key("string", serde_json::json!(""), "Explicit user-global path to private license configuration; saved after installed device provisioning")),
("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")),
("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")),
].into_iter().map(|(name, value)| (name.into(), value)).collect(),
});
ConfigSchema {
version: 1,
sections,
}
}
pub fn lookup(&self, key: &str) -> Option<&KeySchema> {
if !key.contains('.') {
return self.sections.get("root")?.keys.get(key);
}
for (i, _) in key.rmatch_indices('.') {
let section = &key[..i];
let field = &key[i + 1..];
if let Some(schema) = self.sections.get(section) {
if let Some(ks) = schema.keys.get(field) {
return Some(ks);
}
}
}
None
}
pub fn known_keys(&self) -> Vec<String> {
let mut keys = Vec::new();
for (section, schema) in &self.sections {
if section == "root" {
for key_name in schema.keys.keys() {
keys.push(key_name.clone());
}
} else {
if schema.keys.is_empty() {
keys.push(section.clone());
}
for key_name in schema.keys.keys() {
keys.push(format!("{section}.{key_name}"));
}
}
}
keys.extend(config_derived_keys());
keys.sort();
keys.dedup();
keys
}
}
fn config_derived_keys() -> Vec<String> {
fn walk(table: &toml::value::Table, prefix: &str, out: &mut Vec<String>) {
for (k, v) in table {
let full = if prefix.is_empty() {
k.clone()
} else {
format!("{prefix}.{k}")
};
if let toml::Value::Table(sub) = v {
out.push(full.clone());
walk(sub, &full, out);
} else {
out.push(full);
}
}
}
let mut out = Vec::new();
if let Ok(toml::Value::Table(table)) =
toml::Value::try_from(crate::core::config::Config::default())
{
walk(&table, "", &mut out);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn accepted(known: &[String], key: &str) -> bool {
known.iter().any(|k| k == key) || known.iter().any(|k| key.starts_with(&format!("{k}.")))
}
#[test]
fn known_keys_cover_every_config_struct_field() {
let known = ConfigSchema::generate().known_keys();
let missing: Vec<_> = config_derived_keys()
.into_iter()
.filter(|k| !accepted(&known, k))
.collect();
assert!(
missing.is_empty(),
"config struct fields not recognised by validation (schema drift): {missing:?}"
);
}
#[test]
fn known_keys_recognise_reported_456_keys() {
let known = ConfigSchema::generate().known_keys();
for key in [
"proxy_require_token",
"allow_ide_config_dirs",
"memory.episodic",
"providers.github",
"proxy",
] {
assert!(
accepted(&known, key),
"validation must recognise '{key}' (#456)"
);
}
}
#[test]
fn option_scalar_keys_are_cli_settable() {
let schema = ConfigSchema::generate();
for key in [
"path_jail",
"path_jail_scope",
"persona",
"bypass_hints",
"shell_security",
"cache_policy",
"profile",
"tool_profile",
"rules_scope",
"rules_injection",
"permission_inheritance",
"proxy_enabled",
"proxy_port",
"proxy_timeout_ms",
] {
assert!(
schema.lookup(key).is_some(),
"`lean-ctx config set {key} <v>` fails with 'Unknown config key' — \
add `{key}` to a sections_*.rs schema"
);
}
}
#[test]
fn proxy_require_token_is_cli_settable() {
let schema = ConfigSchema::generate();
assert!(
schema.lookup("proxy_require_token").is_some(),
"`lean-ctx config set proxy_require_token <bool>` must be accepted"
);
}
#[test]
fn dotted_section_keys_are_cli_settable() {
let schema = ConfigSchema::generate();
for key in [
"memory.lifecycle.stale_days",
"memory.knowledge.max_facts",
"memory.episodic.max_episodes",
"proxy.role_aggressiveness.system",
] {
assert!(
schema.lookup(key).is_some(),
"`lean-ctx config set {key} <v>` fails with \"Unknown config key\" — lookup must handle dotted section names"
);
}
}
#[test]
fn decision_loop_keys_are_cli_settable_with_types_and_defaults() {
let schema = ConfigSchema::generate();
for (key, ty, default) in [
("decision_loop.enabled", "bool", serde_json::json!(true)),
("decision_loop.max_filter_level", "u8", serde_json::json!(0)),
] {
let entry = schema
.lookup(key)
.unwrap_or_else(|| panic!("config set {key} must be recognized"));
assert_eq!(entry.ty, ty, "schema type for {key}");
assert_eq!(entry.default, default, "schema default for {key}");
}
}
#[test]
fn permission_inheritance_defaults_to_on() {
let cfg = super::super::Config::default();
assert_eq!(
cfg.permission_inheritance.as_deref(),
Some("on"),
"permission_inheritance must default to On so IDE permission rules are honored out of the box"
);
}
#[test]
fn llm_schema_keys_match_the_llm_config_struct() {
let schema = ConfigSchema::generate();
let section = schema
.sections
.get("llm")
.expect("[llm] section must exist in the schema");
let defaults = serde_json::to_value(crate::core::config::LlmConfig::default())
.expect("LlmConfig serialises");
let defaults = defaults.as_object().expect("LlmConfig is a struct");
for (key, entry) in §ion.keys {
let actual = defaults.get(key).unwrap_or_else(|| {
panic!(
"schema key `llm.{key}` has no field on LlmConfig — \
`config set llm.{key} <v>` would report a save that serde discards"
)
});
let expected = if actual.is_null() {
serde_json::json!("")
} else {
actual.clone()
};
assert_eq!(
entry.default, expected,
"schema default for `llm.{key}` has drifted from LlmConfig::default()"
);
}
assert!(
schema.lookup("llm.api_key").is_none(),
"`llm.api_key` is not a config field — the credential comes from \
OPENROUTER_API_KEY / ANTHROPIC_API_KEY, so `config set` must reject it"
);
assert!(
schema.lookup("llm.base_url").is_some(),
"`llm.base_url` is a real field and must be settable"
);
}
}