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