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