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