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