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