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