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