Skip to main content

harn_vm/
config.rs

1//! Canonical layered Harn runtime configuration.
2//!
3//! The VM owns the typed shape and deterministic merge engine so hosts can
4//! inspect, validate, and explain configuration without depending on the CLI's
5//! `harn.toml` package manifest model.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::fmt;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12use serde_json::{json, Map as JsonMap, Value as JsonValue};
13
14use crate::redact::current_policy;
15
16pub const CONFIG_SCHEMA_VERSION: u32 = 1;
17pub const CONFIG_SCHEMA_ID: &str = "https://harnlang.com/schemas/harn-config.schema.json";
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
20#[serde(default, deny_unknown_fields)]
21pub struct HarnConfig {
22    pub schema_version: u32,
23    pub models: ModelPolicyConfig,
24    pub permissions: PermissionConfig,
25    pub endpoints: EndpointCatalogConfig,
26    pub packages: PackageSourcesConfig,
27    pub skills: SkillSourcesConfig,
28    pub plugins: PluginSourcesConfig,
29    pub logging: LoggingConfig,
30    pub retention: RetentionConfig,
31    pub redaction: RedactionConfig,
32    pub replay: ReplayConfig,
33    pub limits: RuntimeLimitsConfig,
34    pub policy: ManagedPolicyConfig,
35}
36
37impl Default for HarnConfig {
38    fn default() -> Self {
39        Self {
40            schema_version: CONFIG_SCHEMA_VERSION,
41            models: ModelPolicyConfig::default(),
42            permissions: PermissionConfig::default(),
43            endpoints: EndpointCatalogConfig::default(),
44            packages: PackageSourcesConfig::default(),
45            skills: SkillSourcesConfig::default(),
46            plugins: PluginSourcesConfig::default(),
47            logging: LoggingConfig::default(),
48            retention: RetentionConfig::default(),
49            redaction: RedactionConfig::default(),
50            replay: ReplayConfig::default(),
51            limits: RuntimeLimitsConfig::default(),
52            policy: ManagedPolicyConfig::default(),
53        }
54    }
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
58#[serde(default, deny_unknown_fields)]
59pub struct ModelPolicyConfig {
60    pub default_provider: Option<String>,
61    pub default_model: Option<String>,
62    pub capability_refs: Vec<String>,
63    pub providers: BTreeMap<String, ProviderPolicyConfig>,
64    pub aliases: BTreeMap<String, ModelAliasConfig>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
68#[serde(default, deny_unknown_fields)]
69pub struct ProviderPolicyConfig {
70    pub base_url: Option<String>,
71    pub auth_env: Vec<String>,
72    pub capability_refs: Vec<String>,
73    pub models: Vec<String>,
74    pub metadata: BTreeMap<String, JsonValue>,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
78#[serde(default, deny_unknown_fields)]
79pub struct ModelAliasConfig {
80    pub model: String,
81    pub provider: String,
82    pub capability_refs: Vec<String>,
83}
84
85#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
86#[serde(rename_all = "kebab-case")]
87pub enum PermissionMode {
88    Allow,
89    #[default]
90    Ask,
91    Deny,
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95#[serde(default, deny_unknown_fields)]
96pub struct PermissionConfig {
97    pub default: PermissionMode,
98    pub capabilities: BTreeMap<String, PermissionMode>,
99}
100
101impl Default for PermissionConfig {
102    fn default() -> Self {
103        Self {
104            default: PermissionMode::Ask,
105            capabilities: BTreeMap::new(),
106        }
107    }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
111#[serde(default, deny_unknown_fields)]
112pub struct EndpointCatalogConfig {
113    pub mcp: BTreeMap<String, EndpointConfig>,
114    pub a2a: BTreeMap<String, EndpointConfig>,
115    pub acp: BTreeMap<String, EndpointConfig>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
119#[serde(default, deny_unknown_fields)]
120pub struct EndpointConfig {
121    pub enabled: bool,
122    pub url: Option<String>,
123    pub command: Vec<String>,
124    pub transport: Option<String>,
125    pub headers: BTreeMap<String, String>,
126}
127
128impl Default for EndpointConfig {
129    fn default() -> Self {
130        Self {
131            enabled: true,
132            url: None,
133            command: Vec::new(),
134            transport: None,
135            headers: BTreeMap::new(),
136        }
137    }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
141#[serde(default, deny_unknown_fields)]
142pub struct PackageSourcesConfig {
143    pub sources: Vec<SourceConfig>,
144    pub lockfile: Option<String>,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
148#[serde(default, deny_unknown_fields)]
149pub struct SkillSourcesConfig {
150    pub paths: Vec<String>,
151    pub sources: Vec<SourceConfig>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
155#[serde(default, deny_unknown_fields)]
156pub struct PluginSourcesConfig {
157    pub sources: Vec<SourceConfig>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
161#[serde(default, deny_unknown_fields)]
162pub struct SourceConfig {
163    pub name: String,
164    pub kind: String,
165    pub url: Option<String>,
166    pub path: Option<String>,
167    pub trust: Option<String>,
168}
169
170#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
171#[serde(rename_all = "kebab-case")]
172pub enum LogLevel {
173    Error,
174    Warn,
175    #[default]
176    Info,
177    Debug,
178    Trace,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
182#[serde(default, deny_unknown_fields)]
183pub struct LoggingConfig {
184    pub level: LogLevel,
185    pub format: String,
186    pub file: Option<String>,
187}
188
189impl Default for LoggingConfig {
190    fn default() -> Self {
191        Self {
192            level: LogLevel::Info,
193            format: "text".to_string(),
194            file: None,
195        }
196    }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200#[serde(default, deny_unknown_fields)]
201pub struct RetentionConfig {
202    pub days: Option<u64>,
203    pub max_bytes: Option<u64>,
204}
205
206impl Default for RetentionConfig {
207    fn default() -> Self {
208        Self {
209            days: Some(30),
210            max_bytes: None,
211        }
212    }
213}
214
215#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
216#[serde(rename_all = "kebab-case")]
217pub enum RedactionMode {
218    Off,
219    #[default]
220    Standard,
221    Strict,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
225#[serde(default, deny_unknown_fields)]
226pub struct RedactionConfig {
227    pub mode: RedactionMode,
228    pub extra_fields: Vec<String>,
229    pub extra_url_params: Vec<String>,
230}
231
232impl Default for RedactionConfig {
233    fn default() -> Self {
234        Self {
235            mode: RedactionMode::Standard,
236            extra_fields: Vec::new(),
237            extra_url_params: Vec::new(),
238        }
239    }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
243#[serde(default, deny_unknown_fields)]
244pub struct ReplayConfig {
245    pub enabled: bool,
246    pub directory: Option<String>,
247}
248
249impl Default for ReplayConfig {
250    fn default() -> Self {
251        Self {
252            enabled: true,
253            directory: None,
254        }
255    }
256}
257
258#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
259#[serde(rename_all = "kebab-case")]
260pub enum NetworkMode {
261    Allow,
262    #[default]
263    Ask,
264    Deny,
265    Offline,
266}
267
268#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
269#[serde(rename_all = "kebab-case")]
270pub enum FilesystemMode {
271    ReadWrite,
272    ReadOnly,
273    #[default]
274    Sandboxed,
275}
276
277#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
278#[serde(rename_all = "kebab-case")]
279pub enum SandboxMode {
280    Host,
281    #[default]
282    Process,
283    Container,
284    Worktree,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
288#[serde(default, deny_unknown_fields)]
289pub struct RuntimeLimitsConfig {
290    pub budget_usd: Option<f64>,
291    pub tokens: Option<u64>,
292    pub concurrency: Option<u64>,
293    pub network: NetworkMode,
294    pub filesystem: FilesystemMode,
295    pub sandbox: SandboxMode,
296}
297
298impl Default for RuntimeLimitsConfig {
299    fn default() -> Self {
300        Self {
301            budget_usd: None,
302            tokens: None,
303            concurrency: None,
304            network: NetworkMode::Ask,
305            filesystem: FilesystemMode::Sandboxed,
306            sandbox: SandboxMode::Process,
307        }
308    }
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
312#[serde(default, deny_unknown_fields)]
313pub struct ManagedPolicyConfig {
314    pub locked_fields: Vec<String>,
315    pub denied_fields: Vec<String>,
316}
317
318#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
319#[serde(rename_all = "snake_case")]
320pub enum ConfigLayerKind {
321    BuiltInDefaults,
322    RuntimeInstallDefaults,
323    RemoteDefaults,
324    UserConfig,
325    ProjectConfig,
326    RepoConfig,
327    ManagedPolicy,
328    EnvironmentOverrides,
329}
330
331impl ConfigLayerKind {
332    pub fn label(self) -> &'static str {
333        match self {
334            ConfigLayerKind::BuiltInDefaults => "built-in defaults",
335            ConfigLayerKind::RuntimeInstallDefaults => "runtime install defaults",
336            ConfigLayerKind::RemoteDefaults => "remote defaults",
337            ConfigLayerKind::UserConfig => "user config",
338            ConfigLayerKind::ProjectConfig => "project config",
339            ConfigLayerKind::RepoConfig => "repo config",
340            ConfigLayerKind::ManagedPolicy => "managed policy",
341            ConfigLayerKind::EnvironmentOverrides => "environment overrides",
342        }
343    }
344}
345
346#[derive(Debug, Clone)]
347pub struct ConfigLayer {
348    pub kind: ConfigLayerKind,
349    pub name: String,
350    pub source: String,
351    pub value: JsonValue,
352}
353
354impl ConfigLayer {
355    pub fn new(
356        kind: ConfigLayerKind,
357        name: impl Into<String>,
358        source: impl Into<String>,
359        value: JsonValue,
360    ) -> Self {
361        Self {
362            kind,
363            name: name.into(),
364            source: source.into(),
365            value,
366        }
367    }
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
371pub struct LayerSummary {
372    pub name: String,
373    pub kind: ConfigLayerKind,
374    pub source: String,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
378pub struct FieldCandidate {
379    pub layer: String,
380    pub kind: ConfigLayerKind,
381    pub source: String,
382    pub status: CandidateStatus,
383    pub value: JsonValue,
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub blocked_by: Option<String>,
386}
387
388#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
389#[serde(rename_all = "snake_case")]
390pub enum CandidateStatus {
391    Applied,
392    Shadowed,
393    Locked,
394    Denied,
395}
396
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
398pub struct FieldExplanation {
399    pub path: String,
400    pub value: JsonValue,
401    pub source: String,
402    pub layer: String,
403    pub kind: ConfigLayerKind,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub locked_by: Option<String>,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub denied_by: Option<String>,
408    pub candidates: Vec<FieldCandidate>,
409}
410
411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
412pub struct ResolvedConfig {
413    #[serde(skip_serializing)]
414    pub config: HarnConfig,
415    pub redacted_config: JsonValue,
416    pub layers: Vec<LayerSummary>,
417    pub explain: Vec<FieldExplanation>,
418}
419
420#[derive(Debug)]
421pub enum ConfigError {
422    ParseToml { source: String, message: String },
423    ParseJson { source: String, message: String },
424    InvalidConfig { source: String, message: String },
425    InvalidPath { path: String },
426}
427
428impl fmt::Display for ConfigError {
429    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
430        match self {
431            ConfigError::ParseToml { source, message } => {
432                write!(f, "failed to parse TOML config {source}: {message}")
433            }
434            ConfigError::ParseJson { source, message } => {
435                write!(f, "failed to parse JSON config {source}: {message}")
436            }
437            ConfigError::InvalidConfig { source, message } => {
438                write!(f, "invalid config {source}: {message}")
439            }
440            ConfigError::InvalidPath { path } => {
441                write!(f, "invalid config field path `{path}`")
442            }
443        }
444    }
445}
446
447impl std::error::Error for ConfigError {}
448
449pub fn built_in_defaults_layer() -> ConfigLayer {
450    ConfigLayer::new(
451        ConfigLayerKind::BuiltInDefaults,
452        "built-in defaults",
453        "harn-vm",
454        serde_json::to_value(HarnConfig::default()).expect("default config serializes"),
455    )
456}
457
458pub fn layer_from_providers_config(
459    kind: ConfigLayerKind,
460    name: impl Into<String>,
461    source: impl Into<String>,
462    providers: &crate::llm_config::ProvidersConfig,
463) -> ConfigLayer {
464    let mut canonical_providers = JsonMap::new();
465    for (provider_name, provider) in &providers.providers {
466        canonical_providers.insert(
467            provider_name.clone(),
468            json!({
469                "base_url": provider.base_url,
470                "auth_env": crate::llm_config::auth_env_names(&provider.auth_env),
471                "capability_refs": provider.features,
472                "models": [],
473                "metadata": {
474                    "auth_style": provider.auth_style,
475                    "chat_endpoint": provider.chat_endpoint,
476                    "completion_endpoint": provider.completion_endpoint,
477                }
478            }),
479        );
480    }
481    for (model_id, model) in &providers.models {
482        let entry = canonical_providers
483            .entry(model.provider.clone())
484            .or_insert_with(|| {
485                json!({
486                    "base_url": null,
487                    "auth_env": [],
488                    "capability_refs": [],
489                    "models": [],
490                    "metadata": {}
491                })
492            });
493        if let Some(models) = entry.get_mut("models").and_then(JsonValue::as_array_mut) {
494            models.push(JsonValue::String(model_id.clone()));
495        }
496    }
497    let aliases = providers
498        .aliases
499        .iter()
500        .map(|(alias, entry)| {
501            (
502                alias.clone(),
503                json!({
504                    "model": entry.id,
505                    "provider": entry.provider,
506                    "capability_refs": [],
507                }),
508            )
509        })
510        .collect::<JsonMap<String, JsonValue>>();
511    ConfigLayer::new(
512        kind,
513        name,
514        source,
515        json!({
516            "models": {
517                "default_provider": providers.default_provider,
518                "providers": canonical_providers,
519                "aliases": aliases,
520            }
521        }),
522    )
523}
524
525pub fn parse_config_toml(
526    content: &str,
527    source: impl Into<String>,
528) -> Result<JsonValue, ConfigError> {
529    let source = source.into();
530    let value = toml::from_str::<toml::Value>(content).map_err(|error| ConfigError::ParseToml {
531        source: source.clone(),
532        message: sanitized_error_message(error),
533    })?;
534    let json = serde_json::to_value(value).map_err(|error| ConfigError::InvalidConfig {
535        source: source.clone(),
536        message: error.to_string(),
537    })?;
538    validate_layer_value(&json, &source)?;
539    Ok(json)
540}
541
542pub fn parse_config_json(
543    content: &str,
544    source: impl Into<String>,
545) -> Result<JsonValue, ConfigError> {
546    let source = source.into();
547    let json =
548        serde_json::from_str::<JsonValue>(content).map_err(|error| ConfigError::ParseJson {
549            source: source.clone(),
550            message: sanitized_error_message(error),
551        })?;
552    validate_layer_value(&json, &source)?;
553    Ok(json)
554}
555
556pub fn parse_manifest_config_table(
557    content: &str,
558    source: impl Into<String>,
559) -> Result<Option<JsonValue>, ConfigError> {
560    let source = source.into();
561    let value = toml::from_str::<toml::Value>(content).map_err(|error| ConfigError::ParseToml {
562        source: source.clone(),
563        message: sanitized_error_message(error),
564    })?;
565    let Some(table) = value.as_table() else {
566        return Ok(None);
567    };
568    let Some(config) = table.get("config") else {
569        return Ok(None);
570    };
571    let json = serde_json::to_value(config).map_err(|error| ConfigError::InvalidConfig {
572        source: source.clone(),
573        message: error.to_string(),
574    })?;
575    validate_layer_value(&json, &source)?;
576    Ok(Some(json))
577}
578
579pub fn environment_layer<I, K, V>(vars: I) -> Result<Option<ConfigLayer>, ConfigError>
580where
581    I: IntoIterator<Item = (K, V)>,
582    K: Into<String>,
583    V: Into<String>,
584{
585    let vars: BTreeMap<String, String> = vars
586        .into_iter()
587        .map(|(key, value)| (key.into(), value.into()))
588        .collect();
589    let mut value = match vars.get("HARN_CONFIG_JSON") {
590        Some(raw) if !raw.trim().is_empty() => parse_config_json(raw, "HARN_CONFIG_JSON")?,
591        _ => JsonValue::Object(JsonMap::new()),
592    };
593
594    set_env_string(
595        &mut value,
596        &vars,
597        "HARN_DEFAULT_PROVIDER",
598        "models.default_provider",
599    )?;
600    set_env_string(
601        &mut value,
602        &vars,
603        "HARN_DEFAULT_MODEL",
604        "models.default_model",
605    )?;
606    set_env_enum(&mut value, &vars, "HARN_LOG_LEVEL", "logging.level")?;
607    set_env_enum(&mut value, &vars, "HARN_REDACTION_MODE", "redaction.mode")?;
608    set_env_enum(&mut value, &vars, "HARN_NETWORK_MODE", "limits.network")?;
609    set_env_enum(
610        &mut value,
611        &vars,
612        "HARN_FILESYSTEM_MODE",
613        "limits.filesystem",
614    )?;
615    set_env_enum(&mut value, &vars, "HARN_SANDBOX_MODE", "limits.sandbox")?;
616    set_env_u64(&mut value, &vars, "HARN_RETENTION_DAYS", "retention.days")?;
617    set_env_u64(&mut value, &vars, "HARN_TOKEN_BUDGET", "limits.tokens")?;
618    set_env_u64(
619        &mut value,
620        &vars,
621        "HARN_MAX_CONCURRENCY",
622        "limits.concurrency",
623    )?;
624    set_env_f64(&mut value, &vars, "HARN_BUDGET_USD", "limits.budget_usd")?;
625    set_env_bool(&mut value, &vars, "HARN_REPLAY_ENABLED", "replay.enabled")?;
626
627    if value.as_object().is_some_and(JsonMap::is_empty) {
628        return Ok(None);
629    }
630    validate_layer_value(&value, "environment overrides")?;
631    Ok(Some(ConfigLayer::new(
632        ConfigLayerKind::EnvironmentOverrides,
633        "environment overrides",
634        "process environment",
635        value,
636    )))
637}
638
639pub fn merge_layers(layers: Vec<ConfigLayer>) -> Result<ResolvedConfig, ConfigError> {
640    let mut merged = JsonValue::Object(JsonMap::new());
641    let mut candidate_map: BTreeMap<String, Vec<FieldCandidate>> = BTreeMap::new();
642    let mut winner_map: BTreeMap<String, (String, String, ConfigLayerKind)> = BTreeMap::new();
643    let mut locked: BTreeMap<String, String> = BTreeMap::new();
644    let mut denied: BTreeMap<String, String> = BTreeMap::new();
645    let mut summaries = Vec::new();
646
647    for layer in layers {
648        validate_layer_value(&layer.value, &layer.source)?;
649        let display_source = redact_display(&layer.source);
650        summaries.push(LayerSummary {
651            name: layer.name.clone(),
652            kind: layer.kind,
653            source: display_source.clone(),
654        });
655
656        let leaves = leaf_values(&layer.value);
657        for (path, value) in leaves {
658            if path == "policy.locked_fields" || path == "policy.denied_fields" {
659                apply_candidate(
660                    &mut merged,
661                    &mut candidate_map,
662                    &mut winner_map,
663                    &layer,
664                    &path,
665                    value,
666                )?;
667                continue;
668            }
669            if let Some((policy_path, source)) = first_policy_match(&denied, &path) {
670                push_blocked_candidate(
671                    &mut candidate_map,
672                    &layer,
673                    &path,
674                    value,
675                    CandidateStatus::Denied,
676                    format!("{source} denied {policy_path}"),
677                );
678                continue;
679            }
680            if let Some((policy_path, source)) = first_policy_match(&locked, &path) {
681                push_blocked_candidate(
682                    &mut candidate_map,
683                    &layer,
684                    &path,
685                    value,
686                    CandidateStatus::Locked,
687                    format!("{source} locked {policy_path}"),
688                );
689                continue;
690            }
691            apply_candidate(
692                &mut merged,
693                &mut candidate_map,
694                &mut winner_map,
695                &layer,
696                &path,
697                value,
698            )?;
699        }
700
701        if layer.kind == ConfigLayerKind::ManagedPolicy {
702            for path in string_list_at(&layer.value, "policy.locked_fields") {
703                validate_field_path(&path)?;
704                locked.insert(path, display_source.clone());
705            }
706            for path in string_list_at(&layer.value, "policy.denied_fields") {
707                validate_field_path(&path)?;
708                denied.insert(path.clone(), display_source.clone());
709                apply_denied_policy(
710                    &mut merged,
711                    &mut candidate_map,
712                    &mut winner_map,
713                    &path,
714                    &display_source,
715                )?;
716            }
717        }
718    }
719
720    let config: HarnConfig =
721        serde_json::from_value(merged.clone()).map_err(|error| ConfigError::InvalidConfig {
722            source: "merged config".to_string(),
723            message: error.to_string(),
724        })?;
725    let redacted_config = current_policy().redact_json(&merged);
726    let mut explain = Vec::new();
727    for (path, value) in leaf_values(&merged) {
728        let Some((source, layer, kind)) = winner_map.get(&path).cloned() else {
729            continue;
730        };
731        let locked_by = first_policy_match(&locked, &path).map(|(_, source)| source);
732        let denied_by = first_policy_match(&denied, &path).map(|(_, source)| source);
733        let mut candidates = candidate_map.remove(&path).unwrap_or_default();
734        for candidate in &mut candidates {
735            candidate.value = redact_value_at_path(&path, candidate.value.clone());
736        }
737        explain.push(FieldExplanation {
738            path: path.clone(),
739            value: redact_value_at_path(&path, value),
740            source,
741            layer,
742            kind,
743            locked_by,
744            denied_by,
745            candidates,
746        });
747    }
748    for (path, mut candidates) in candidate_map {
749        if candidates.is_empty() {
750            continue;
751        }
752        for candidate in &mut candidates {
753            candidate.value = redact_value_at_path(&path, candidate.value.clone());
754        }
755        let locked_by = first_policy_match(&locked, &path).map(|(_, source)| source);
756        let denied_by = first_policy_match(&denied, &path).map(|(_, source)| source);
757        explain.push(FieldExplanation {
758            path: path.clone(),
759            value: JsonValue::Null,
760            source: "<blocked>".to_string(),
761            layer: "<blocked>".to_string(),
762            kind: candidates
763                .last()
764                .map(|candidate| candidate.kind)
765                .unwrap_or(ConfigLayerKind::BuiltInDefaults),
766            locked_by,
767            denied_by,
768            candidates,
769        });
770    }
771    explain.sort_by(|left, right| left.path.cmp(&right.path));
772    Ok(ResolvedConfig {
773        config,
774        redacted_config,
775        layers: summaries,
776        explain,
777    })
778}
779
780pub fn validate_policy_paths(value: &JsonValue) -> Result<(), ConfigError> {
781    for path in string_list_at(value, "policy.locked_fields")
782        .into_iter()
783        .chain(string_list_at(value, "policy.denied_fields"))
784    {
785        validate_field_path(&path)?;
786    }
787    Ok(())
788}
789
790pub fn schema_json() -> JsonValue {
791    json!({
792        "$schema": "https://json-schema.org/draft/2020-12/schema",
793        "$id": CONFIG_SCHEMA_ID,
794        "title": "Harn runtime config",
795        "type": "object",
796        "additionalProperties": false,
797        "properties": {
798            "schema_version": {"type": "integer", "const": CONFIG_SCHEMA_VERSION},
799            "models": {
800                "type": "object",
801                "additionalProperties": false,
802                "properties": {
803                    "default_provider": {"type": ["string", "null"]},
804                    "default_model": {"type": ["string", "null"]},
805                    "capability_refs": {"type": "array", "items": {"type": "string"}},
806                    "providers": {"type": "object", "additionalProperties": {"$ref": "#/$defs/provider"}},
807                    "aliases": {"type": "object", "additionalProperties": {"$ref": "#/$defs/model_alias"}}
808                }
809            },
810            "permissions": {
811                "type": "object",
812                "additionalProperties": false,
813                "properties": {
814                    "default": {"$ref": "#/$defs/permission_mode"},
815                    "capabilities": {"type": "object", "additionalProperties": {"$ref": "#/$defs/permission_mode"}}
816                }
817            },
818            "endpoints": {
819                "type": "object",
820                "additionalProperties": false,
821                "properties": {
822                    "mcp": {"type": "object", "additionalProperties": {"$ref": "#/$defs/endpoint"}},
823                    "a2a": {"type": "object", "additionalProperties": {"$ref": "#/$defs/endpoint"}},
824                    "acp": {"type": "object", "additionalProperties": {"$ref": "#/$defs/endpoint"}}
825                }
826            },
827            "packages": {
828                "type": "object",
829                "additionalProperties": false,
830                "properties": {
831                    "sources": {"type": "array", "items": {"$ref": "#/$defs/source"}},
832                    "lockfile": {"type": ["string", "null"]}
833                }
834            },
835            "skills": {
836                "type": "object",
837                "additionalProperties": false,
838                "properties": {
839                    "paths": {"type": "array", "items": {"type": "string"}},
840                    "sources": {"type": "array", "items": {"$ref": "#/$defs/source"}}
841                }
842            },
843            "plugins": {
844                "type": "object",
845                "additionalProperties": false,
846                "properties": {
847                    "sources": {"type": "array", "items": {"$ref": "#/$defs/source"}}
848                }
849            },
850            "logging": {
851                "type": "object",
852                "additionalProperties": false,
853                "properties": {
854                    "level": {"enum": ["error", "warn", "info", "debug", "trace"]},
855                    "format": {"type": "string"},
856                    "file": {"type": ["string", "null"]}
857                }
858            },
859            "retention": {
860                "type": "object",
861                "additionalProperties": false,
862                "properties": {
863                    "days": {"type": ["integer", "null"], "minimum": 0},
864                    "max_bytes": {"type": ["integer", "null"], "minimum": 0}
865                }
866            },
867            "redaction": {
868                "type": "object",
869                "additionalProperties": false,
870                "properties": {
871                    "mode": {"enum": ["off", "standard", "strict"]},
872                    "extra_fields": {"type": "array", "items": {"type": "string"}},
873                    "extra_url_params": {"type": "array", "items": {"type": "string"}}
874                }
875            },
876            "replay": {
877                "type": "object",
878                "additionalProperties": false,
879                "properties": {
880                    "enabled": {"type": "boolean"},
881                    "directory": {"type": ["string", "null"]}
882                }
883            },
884            "limits": {
885                "type": "object",
886                "additionalProperties": false,
887                "properties": {
888                    "budget_usd": {"type": ["number", "null"], "minimum": 0},
889                    "tokens": {"type": ["integer", "null"], "minimum": 0},
890                    "concurrency": {"type": ["integer", "null"], "minimum": 0},
891                    "network": {"enum": ["allow", "ask", "deny", "offline"]},
892                    "filesystem": {"enum": ["read-write", "read-only", "sandboxed"]},
893                    "sandbox": {"enum": ["host", "process", "container", "worktree"]}
894                }
895            },
896            "policy": {
897                "type": "object",
898                "additionalProperties": false,
899                "properties": {
900                    "locked_fields": {"type": "array", "items": {"type": "string"}},
901                    "denied_fields": {"type": "array", "items": {"type": "string"}}
902                }
903            }
904        },
905        "$defs": {
906            "permission_mode": {"enum": ["allow", "ask", "deny"]},
907            "provider": {
908                "type": "object",
909                "additionalProperties": false,
910                "properties": {
911                    "base_url": {"type": ["string", "null"]},
912                    "auth_env": {"type": "array", "items": {"type": "string"}},
913                    "capability_refs": {"type": "array", "items": {"type": "string"}},
914                    "models": {"type": "array", "items": {"type": "string"}},
915                    "metadata": {"type": "object"}
916                }
917            },
918            "model_alias": {
919                "type": "object",
920                "additionalProperties": false,
921                "properties": {
922                    "model": {"type": "string"},
923                    "provider": {"type": "string"},
924                    "capability_refs": {"type": "array", "items": {"type": "string"}}
925                }
926            },
927            "endpoint": {
928                "type": "object",
929                "additionalProperties": false,
930                "properties": {
931                    "enabled": {"type": "boolean"},
932                    "url": {"type": ["string", "null"]},
933                    "command": {"type": "array", "items": {"type": "string"}},
934                    "transport": {"type": ["string", "null"]},
935                    "headers": {"type": "object", "additionalProperties": {"type": "string"}}
936                }
937            },
938            "source": {
939                "type": "object",
940                "additionalProperties": false,
941                "properties": {
942                    "name": {"type": "string"},
943                    "kind": {"type": "string"},
944                    "url": {"type": ["string", "null"]},
945                    "path": {"type": ["string", "null"]},
946                    "trust": {"type": ["string", "null"]}
947                }
948            }
949        }
950    })
951}
952
953pub fn install_config_path_for_os(os: &str, program_data: Option<&str>) -> PathBuf {
954    if os == "windows" {
955        PathBuf::from(program_data.unwrap_or(r"C:\ProgramData")).join(r"Harn\config.toml")
956    } else {
957        PathBuf::from("/etc/harn/config.toml")
958    }
959}
960
961pub fn user_config_path_for_os(
962    os: &str,
963    home: Option<&str>,
964    xdg_config_home: Option<&str>,
965    appdata: Option<&str>,
966) -> Option<PathBuf> {
967    if os == "windows" {
968        return appdata.map(|root| PathBuf::from(root).join(r"Harn\config.toml"));
969    }
970    if let Some(root) = xdg_config_home.filter(|value| !value.trim().is_empty()) {
971        return Some(PathBuf::from(root).join("harn").join("config.toml"));
972    }
973    home.map(|root| {
974        PathBuf::from(root)
975            .join(".config")
976            .join("harn")
977            .join("config.toml")
978    })
979}
980
981fn validate_layer_value(value: &JsonValue, source: &str) -> Result<(), ConfigError> {
982    serde_json::from_value::<HarnConfig>(value.clone()).map_err(|error| {
983        ConfigError::InvalidConfig {
984            source: source.to_string(),
985            message: error.to_string(),
986        }
987    })?;
988    Ok(())
989}
990
991fn sanitized_error_message(error: impl ToString) -> String {
992    let message = error
993        .to_string()
994        .lines()
995        .next()
996        .unwrap_or("parse error")
997        .to_string();
998    current_policy().redact_string(&message).into_owned()
999}
1000
1001fn set_env_string(
1002    value: &mut JsonValue,
1003    vars: &BTreeMap<String, String>,
1004    env_key: &str,
1005    path: &str,
1006) -> Result<(), ConfigError> {
1007    if let Some(raw) = vars
1008        .get(env_key)
1009        .map(|value| value.trim())
1010        .filter(|value| !value.is_empty())
1011    {
1012        set_path(value, path, JsonValue::String(raw.to_string()))?;
1013    }
1014    Ok(())
1015}
1016
1017fn set_env_enum(
1018    value: &mut JsonValue,
1019    vars: &BTreeMap<String, String>,
1020    env_key: &str,
1021    path: &str,
1022) -> Result<(), ConfigError> {
1023    if let Some(raw) = vars
1024        .get(env_key)
1025        .map(|value| value.trim())
1026        .filter(|value| !value.is_empty())
1027    {
1028        let normalized = raw.to_ascii_lowercase().replace('_', "-");
1029        set_path(value, path, JsonValue::String(normalized))?;
1030    }
1031    Ok(())
1032}
1033
1034fn set_env_u64(
1035    value: &mut JsonValue,
1036    vars: &BTreeMap<String, String>,
1037    env_key: &str,
1038    path: &str,
1039) -> Result<(), ConfigError> {
1040    if let Some(raw) = vars
1041        .get(env_key)
1042        .map(|value| value.trim())
1043        .filter(|value| !value.is_empty())
1044    {
1045        let parsed = raw
1046            .parse::<u64>()
1047            .map_err(|error| ConfigError::InvalidConfig {
1048                source: env_key.to_string(),
1049                message: error.to_string(),
1050            })?;
1051        set_path(value, path, json!(parsed))?;
1052    }
1053    Ok(())
1054}
1055
1056fn set_env_f64(
1057    value: &mut JsonValue,
1058    vars: &BTreeMap<String, String>,
1059    env_key: &str,
1060    path: &str,
1061) -> Result<(), ConfigError> {
1062    if let Some(raw) = vars
1063        .get(env_key)
1064        .map(|value| value.trim())
1065        .filter(|value| !value.is_empty())
1066    {
1067        let parsed = raw
1068            .parse::<f64>()
1069            .map_err(|error| ConfigError::InvalidConfig {
1070                source: env_key.to_string(),
1071                message: error.to_string(),
1072            })?;
1073        set_path(value, path, json!(parsed))?;
1074    }
1075    Ok(())
1076}
1077
1078fn set_env_bool(
1079    value: &mut JsonValue,
1080    vars: &BTreeMap<String, String>,
1081    env_key: &str,
1082    path: &str,
1083) -> Result<(), ConfigError> {
1084    if let Some(raw) = vars
1085        .get(env_key)
1086        .map(|value| value.trim())
1087        .filter(|value| !value.is_empty())
1088    {
1089        let parsed = match raw.to_ascii_lowercase().as_str() {
1090            "1" | "true" | "yes" | "on" => true,
1091            "0" | "false" | "no" | "off" => false,
1092            _ => {
1093                return Err(ConfigError::InvalidConfig {
1094                    source: env_key.to_string(),
1095                    message: "expected one of true/false, yes/no, on/off, or 1/0".to_string(),
1096                });
1097            }
1098        };
1099        set_path(value, path, json!(parsed))?;
1100    }
1101    Ok(())
1102}
1103
1104fn apply_candidate(
1105    merged: &mut JsonValue,
1106    candidate_map: &mut BTreeMap<String, Vec<FieldCandidate>>,
1107    winner_map: &mut BTreeMap<String, (String, String, ConfigLayerKind)>,
1108    layer: &ConfigLayer,
1109    path: &str,
1110    value: JsonValue,
1111) -> Result<(), ConfigError> {
1112    if let Some(candidates) = candidate_map.get_mut(path) {
1113        if let Some(previous) = candidates
1114            .iter_mut()
1115            .rev()
1116            .find(|candidate| candidate.status == CandidateStatus::Applied)
1117        {
1118            previous.status = CandidateStatus::Shadowed;
1119        }
1120    }
1121    set_path(merged, path, value.clone())?;
1122    candidate_map
1123        .entry(path.to_string())
1124        .or_default()
1125        .push(FieldCandidate {
1126            layer: layer.name.clone(),
1127            kind: layer.kind,
1128            source: redact_display(&layer.source),
1129            status: CandidateStatus::Applied,
1130            value,
1131            blocked_by: None,
1132        });
1133    winner_map.insert(
1134        path.to_string(),
1135        (
1136            redact_display(&layer.source),
1137            layer.name.clone(),
1138            layer.kind,
1139        ),
1140    );
1141    Ok(())
1142}
1143
1144fn push_blocked_candidate(
1145    candidate_map: &mut BTreeMap<String, Vec<FieldCandidate>>,
1146    layer: &ConfigLayer,
1147    path: &str,
1148    value: JsonValue,
1149    status: CandidateStatus,
1150    blocked_by: String,
1151) {
1152    candidate_map
1153        .entry(path.to_string())
1154        .or_default()
1155        .push(FieldCandidate {
1156            layer: layer.name.clone(),
1157            kind: layer.kind,
1158            source: redact_display(&layer.source),
1159            status,
1160            value,
1161            blocked_by: Some(blocked_by),
1162        });
1163}
1164
1165fn apply_denied_policy(
1166    merged: &mut JsonValue,
1167    candidate_map: &mut BTreeMap<String, Vec<FieldCandidate>>,
1168    winner_map: &mut BTreeMap<String, (String, String, ConfigLayerKind)>,
1169    policy_path: &str,
1170    policy_source: &str,
1171) -> Result<(), ConfigError> {
1172    remove_path(merged, policy_path)?;
1173    let blocked_by = format!("{policy_source} denied {policy_path}");
1174    let keys = candidate_map
1175        .keys()
1176        .filter(|candidate_path| policy_path_matches(policy_path, candidate_path))
1177        .cloned()
1178        .collect::<Vec<_>>();
1179
1180    for path in keys {
1181        let mut fallback = None;
1182        if let Some(candidates) = candidate_map.get_mut(&path) {
1183            for candidate in candidates.iter_mut() {
1184                if candidate.kind == ConfigLayerKind::BuiltInDefaults {
1185                    candidate.status = CandidateStatus::Applied;
1186                    candidate.blocked_by = None;
1187                    fallback = Some((
1188                        candidate.value.clone(),
1189                        candidate.source.clone(),
1190                        candidate.layer.clone(),
1191                        candidate.kind,
1192                    ));
1193                } else {
1194                    candidate.status = CandidateStatus::Denied;
1195                    candidate.blocked_by = Some(blocked_by.clone());
1196                }
1197            }
1198        }
1199
1200        if let Some((value, source, layer, kind)) = fallback {
1201            set_path(merged, &path, value)?;
1202            winner_map.insert(path, (source, layer, kind));
1203        } else {
1204            remove_path(merged, &path)?;
1205            winner_map.remove(&path);
1206        }
1207    }
1208    Ok(())
1209}
1210
1211fn leaf_values(value: &JsonValue) -> Vec<(String, JsonValue)> {
1212    let mut leaves = Vec::new();
1213    collect_leaf_values(value, "", &mut leaves);
1214    leaves
1215}
1216
1217fn collect_leaf_values(value: &JsonValue, prefix: &str, leaves: &mut Vec<(String, JsonValue)>) {
1218    match value {
1219        JsonValue::Object(map) if !map.is_empty() => {
1220            for (key, child) in map {
1221                let next = if prefix.is_empty() {
1222                    key.clone()
1223                } else {
1224                    format!("{prefix}.{key}")
1225                };
1226                collect_leaf_values(child, &next, leaves);
1227            }
1228        }
1229        JsonValue::Object(_) if prefix.is_empty() => {}
1230        _ if !prefix.is_empty() => leaves.push((prefix.to_string(), value.clone())),
1231        _ => {}
1232    }
1233}
1234
1235fn set_path(root: &mut JsonValue, path: &str, value: JsonValue) -> Result<(), ConfigError> {
1236    validate_field_path(path)?;
1237    let parts: Vec<&str> = path.split('.').collect();
1238    if !root.is_object() {
1239        *root = JsonValue::Object(JsonMap::new());
1240    }
1241    let mut cursor = root;
1242    for part in &parts[..parts.len() - 1] {
1243        let object = cursor
1244            .as_object_mut()
1245            .ok_or_else(|| ConfigError::InvalidPath {
1246                path: path.to_string(),
1247            })?;
1248        cursor = object
1249            .entry((*part).to_string())
1250            .or_insert_with(|| JsonValue::Object(JsonMap::new()));
1251    }
1252    let object = cursor
1253        .as_object_mut()
1254        .ok_or_else(|| ConfigError::InvalidPath {
1255            path: path.to_string(),
1256        })?;
1257    object.insert(parts[parts.len() - 1].to_string(), value);
1258    Ok(())
1259}
1260
1261fn remove_path(root: &mut JsonValue, path: &str) -> Result<(), ConfigError> {
1262    validate_field_path(path)?;
1263    let parts = path.split('.').collect::<Vec<_>>();
1264    remove_path_parts(root, &parts);
1265    Ok(())
1266}
1267
1268fn remove_path_parts(value: &mut JsonValue, parts: &[&str]) -> bool {
1269    let Some((part, rest)) = parts.split_first() else {
1270        return false;
1271    };
1272    let Some(object) = value.as_object_mut() else {
1273        return false;
1274    };
1275    if rest.is_empty() {
1276        object.remove(*part);
1277    } else if let Some(child) = object.get_mut(*part) {
1278        if remove_path_parts(child, rest) {
1279            object.remove(*part);
1280        }
1281    }
1282    object.is_empty()
1283}
1284
1285fn validate_field_path(path: &str) -> Result<(), ConfigError> {
1286    let valid = !path.trim().is_empty()
1287        && path
1288            .split('.')
1289            .all(|part| !part.is_empty() && part.chars().all(valid_path_char));
1290    if valid {
1291        Ok(())
1292    } else {
1293        Err(ConfigError::InvalidPath {
1294            path: path.to_string(),
1295        })
1296    }
1297}
1298
1299fn valid_path_char(ch: char) -> bool {
1300    ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-')
1301}
1302
1303fn first_policy_match(policies: &BTreeMap<String, String>, path: &str) -> Option<(String, String)> {
1304    policies
1305        .iter()
1306        .find(|(policy_path, _)| policy_path_matches(policy_path, path))
1307        .map(|(policy_path, source)| (policy_path.clone(), source.clone()))
1308}
1309
1310fn policy_path_matches(policy_path: &str, candidate_path: &str) -> bool {
1311    candidate_path == policy_path
1312        || candidate_path
1313            .strip_prefix(policy_path)
1314            .is_some_and(|suffix| suffix.starts_with('.'))
1315        || policy_path
1316            .strip_prefix(candidate_path)
1317            .is_some_and(|suffix| suffix.starts_with('.'))
1318}
1319
1320fn string_list_at(value: &JsonValue, path: &str) -> Vec<String> {
1321    let mut cursor = value;
1322    for part in path.split('.') {
1323        let Some(next) = cursor.get(part) else {
1324            return Vec::new();
1325        };
1326        cursor = next;
1327    }
1328    cursor
1329        .as_array()
1330        .into_iter()
1331        .flatten()
1332        .filter_map(|item| item.as_str().map(str::to_string))
1333        .collect::<BTreeSet<_>>()
1334        .into_iter()
1335        .collect()
1336}
1337
1338fn redact_value_at_path(path: &str, value: JsonValue) -> JsonValue {
1339    let key = path.rsplit('.').next().unwrap_or(path);
1340    let mut object = JsonMap::new();
1341    object.insert(key.to_string(), value);
1342    let redacted = current_policy().redact_json(&JsonValue::Object(object));
1343    redacted
1344        .get(key)
1345        .cloned()
1346        .unwrap_or(JsonValue::String("[redacted]".to_string()))
1347}
1348
1349fn redact_display(value: &str) -> String {
1350    let policy = current_policy();
1351    if value.starts_with("http://") || value.starts_with("https://") {
1352        if url::Url::parse(value).is_ok() {
1353            return policy.redact_url(value);
1354        }
1355        return "[redacted]".to_string();
1356    }
1357    policy.redact_string(value).into_owned()
1358}
1359
1360#[cfg(test)]
1361mod tests {
1362    use super::*;
1363
1364    fn layer(kind: ConfigLayerKind, name: &str, value: JsonValue) -> ConfigLayer {
1365        ConfigLayer::new(kind, name, name, value)
1366    }
1367
1368    #[test]
1369    fn precedence_tracks_winner_and_shadowed_candidates() {
1370        let resolved = merge_layers(vec![
1371            built_in_defaults_layer(),
1372            layer(
1373                ConfigLayerKind::UserConfig,
1374                "user",
1375                json!({"logging": {"level": "warn"}}),
1376            ),
1377            layer(
1378                ConfigLayerKind::ProjectConfig,
1379                "project",
1380                json!({"logging": {"level": "debug"}}),
1381            ),
1382        ])
1383        .unwrap();
1384
1385        assert_eq!(resolved.config.logging.level, LogLevel::Debug);
1386        let level = resolved
1387            .explain
1388            .iter()
1389            .find(|field| field.path == "logging.level")
1390            .expect("logging.level explanation");
1391        assert_eq!(level.source, "project");
1392        assert!(level
1393            .candidates
1394            .iter()
1395            .any(|candidate| candidate.source == "user"
1396                && candidate.status == CandidateStatus::Shadowed));
1397    }
1398
1399    #[test]
1400    fn managed_lock_blocks_later_environment_override() {
1401        let resolved = merge_layers(vec![
1402            built_in_defaults_layer(),
1403            layer(
1404                ConfigLayerKind::ManagedPolicy,
1405                "managed",
1406                json!({
1407                    "limits": {"network": "offline"},
1408                    "policy": {"locked_fields": ["limits.network"]}
1409                }),
1410            ),
1411            layer(
1412                ConfigLayerKind::EnvironmentOverrides,
1413                "env",
1414                json!({"limits": {"network": "allow"}}),
1415            ),
1416        ])
1417        .unwrap();
1418
1419        assert_eq!(resolved.config.limits.network, NetworkMode::Offline);
1420        let network = resolved
1421            .explain
1422            .iter()
1423            .find(|field| field.path == "limits.network")
1424            .expect("network explanation");
1425        assert_eq!(network.locked_by.as_deref(), Some("managed"));
1426        assert!(network
1427            .candidates
1428            .iter()
1429            .any(|candidate| candidate.source == "env"
1430                && candidate.status == CandidateStatus::Locked));
1431    }
1432
1433    #[test]
1434    fn managed_deny_blocks_later_field() {
1435        let resolved = merge_layers(vec![
1436            built_in_defaults_layer(),
1437            layer(
1438                ConfigLayerKind::ManagedPolicy,
1439                "managed",
1440                json!({"policy": {"denied_fields": ["endpoints.mcp.untrusted"]}}),
1441            ),
1442            layer(
1443                ConfigLayerKind::ProjectConfig,
1444                "project",
1445                json!({"endpoints": {"mcp": {"untrusted": {"url": "https://example.com"}}}}),
1446            ),
1447        ])
1448        .unwrap();
1449
1450        assert!(!resolved.config.endpoints.mcp.contains_key("untrusted"));
1451        let candidates = resolved
1452            .explain
1453            .iter()
1454            .flat_map(|field| field.candidates.iter())
1455            .collect::<Vec<_>>();
1456        assert!(candidates
1457            .iter()
1458            .any(|candidate| candidate.status == CandidateStatus::Denied));
1459    }
1460
1461    #[test]
1462    fn managed_deny_masks_lower_precedence_dynamic_fields() {
1463        let resolved = merge_layers(vec![
1464            built_in_defaults_layer(),
1465            layer(
1466                ConfigLayerKind::ProjectConfig,
1467                "project",
1468                json!({"endpoints": {"mcp": {"untrusted": {"url": "https://example.com"}}}}),
1469            ),
1470            layer(
1471                ConfigLayerKind::ManagedPolicy,
1472                "managed",
1473                json!({"policy": {"denied_fields": ["endpoints.mcp.untrusted"]}}),
1474            ),
1475        ])
1476        .unwrap();
1477
1478        assert!(!resolved.config.endpoints.mcp.contains_key("untrusted"));
1479        let untrusted = resolved
1480            .explain
1481            .iter()
1482            .find(|field| field.path == "endpoints.mcp.untrusted.url")
1483            .expect("blocked endpoint explanation");
1484        assert_eq!(untrusted.denied_by.as_deref(), Some("managed"));
1485        assert!(untrusted
1486            .candidates
1487            .iter()
1488            .any(|candidate| candidate.source == "project"
1489                && candidate.status == CandidateStatus::Denied));
1490    }
1491
1492    #[test]
1493    fn secrets_are_redacted_in_config_and_explain() {
1494        let resolved = merge_layers(vec![
1495            built_in_defaults_layer(),
1496            layer(
1497                ConfigLayerKind::UserConfig,
1498                "user",
1499                json!({
1500                    "endpoints": {
1501                        "mcp": {
1502                            "secret": {
1503                                "headers": {"authorization": "Bearer sk_live_1234567890abcdef"}
1504                            }
1505                        }
1506                    }
1507                }),
1508            ),
1509        ])
1510        .unwrap();
1511
1512        let rendered = serde_json::to_string(&resolved).unwrap();
1513        assert!(!rendered.contains("sk_live_1234567890abcdef"));
1514        assert!(rendered.contains("[redacted]"));
1515    }
1516
1517    #[test]
1518    fn sources_are_redacted_in_explain_output() {
1519        let resolved = merge_layers(vec![
1520            built_in_defaults_layer(),
1521            ConfigLayer::new(
1522                ConfigLayerKind::RemoteDefaults,
1523                "remote",
1524                "https://example.com/.well-known/harn?api_key=sk_live_1234567890abcdef",
1525                json!({"logging": {"level": "debug"}}),
1526            ),
1527        ])
1528        .unwrap();
1529
1530        let rendered = serde_json::to_string(&resolved).unwrap();
1531        assert!(!rendered.contains("sk_live_1234567890abcdef"));
1532        assert!(rendered.contains("api_key=%5Bredacted%5D"));
1533    }
1534
1535    #[test]
1536    fn parses_config_table_from_manifest() {
1537        let value = parse_manifest_config_table(
1538            r#"
1539[package]
1540name = "demo"
1541
1542[config.logging]
1543level = "trace"
1544"#,
1545            "harn.toml",
1546        )
1547        .unwrap()
1548        .expect("config table");
1549        assert_eq!(value["logging"]["level"], "trace");
1550    }
1551
1552    #[test]
1553    fn environment_overrides_are_typed() {
1554        let env = environment_layer([
1555            ("HARN_LOG_LEVEL", "debug"),
1556            ("HARN_TOKEN_BUDGET", "1200"),
1557            ("HARN_REPLAY_ENABLED", "false"),
1558        ])
1559        .unwrap()
1560        .expect("env layer");
1561        let config: HarnConfig = serde_json::from_value(env.value).unwrap();
1562        assert_eq!(config.logging.level, LogLevel::Debug);
1563        assert_eq!(config.limits.tokens, Some(1200));
1564        assert!(!config.replay.enabled);
1565    }
1566
1567    #[test]
1568    fn environment_bool_overrides_reject_unknown_values() {
1569        let error = environment_layer([("HARN_REPLAY_ENABLED", "sometimes")]).unwrap_err();
1570        assert!(error.to_string().contains("expected one of"));
1571    }
1572
1573    #[test]
1574    fn parse_errors_do_not_echo_source_lines() {
1575        let error = parse_config_toml(
1576            "secret = \"sk_live_1234567890abcdef\"\n[",
1577            "bad-config.toml",
1578        )
1579        .unwrap_err();
1580        let rendered = error.to_string();
1581        assert!(!rendered.contains("sk_live_1234567890abcdef"));
1582    }
1583
1584    #[test]
1585    fn schema_is_valid_json_schema_document() {
1586        let schema = schema_json();
1587        assert_eq!(schema["$id"], CONFIG_SCHEMA_ID);
1588        assert_eq!(
1589            schema["properties"]["limits"]["properties"]["network"]["enum"][3],
1590            "offline"
1591        );
1592    }
1593
1594    #[test]
1595    fn config_locations_are_cross_platform() {
1596        assert_eq!(
1597            install_config_path_for_os("linux", None),
1598            PathBuf::from("/etc/harn/config.toml")
1599        );
1600        assert_eq!(
1601            user_config_path_for_os("linux", Some("/home/me"), None, None),
1602            Some(PathBuf::from("/home/me/.config/harn/config.toml"))
1603        );
1604        assert_eq!(
1605            user_config_path_for_os("linux", Some("/home/me"), Some("/xdg"), None),
1606            Some(PathBuf::from("/xdg/harn/config.toml"))
1607        );
1608        assert_eq!(
1609            install_config_path_for_os("windows", Some(r"D:\ProgramData")),
1610            PathBuf::from(r"D:\ProgramData").join(r"Harn\config.toml")
1611        );
1612        assert_eq!(
1613            user_config_path_for_os("windows", None, None, Some(r"C:\Users\me\AppData\Roaming")),
1614            Some(PathBuf::from(r"C:\Users\me\AppData\Roaming").join(r"Harn\config.toml"))
1615        );
1616    }
1617}