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