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