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