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