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