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