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