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