Skip to main content

zeph_core/
config.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Extension trait for resolving vault secrets into a Config.
5//!
6//! This trait is defined in zeph-core (not in zeph-config) due to Rust's orphan rule:
7//! implementing a foreign trait on a foreign type requires the trait to be defined locally.
8
9// Re-export Config types from zeph-config for internal use.
10pub use zeph_config::{
11    AcpAuthMethod, AcpConfig, AcpLspConfig, AcpTransport, AdditionalDir, AdditionalDirError,
12    AgentConfig, CandleConfig, CandleDevice, CandleInlineConfig, CandleSource,
13    CascadeClassifierMode, CascadeConfig, ClassifiersConfig, CompressionConfig,
14    CompressionStrategy, Config, ConfigError, ContextFormat, CostConfig, DaemonConfig, DebugConfig,
15    DetectorMode, DiscordConfig, DocumentConfig, DumpFormat, ExperimentConfig, ExperimentSchedule,
16    FocusConfig, GatewayConfig, GenerationParams, GonkaNode, GraphConfig, HookAction, HookDef,
17    HookMatcher, IndexConfig, LearningConfig, LlmConfig, LlmRoutingStrategy, LogRotation,
18    LoggingConfig, MAX_TOKENS_CAP, McpConfig, McpOAuthConfig, McpServerConfig, McpTrustLevel,
19    MemoryConfig, MemoryScope, NoteLinkingConfig, OAuthTokenStorage, OrchestrationConfig,
20    PermissionMode, ProviderEntry, ProviderKind, ProviderName, PruningStrategy, RateLimitConfig,
21    ResolvedSecrets, RetrievalConfig, RouterConfig, RouterStrategyConfig, ScheduledTaskConfig,
22    ScheduledTaskKind, SchedulerConfig, SchedulerDaemonConfig, SchedulerSecurityConfig,
23    SecurityConfig, SemanticConfig, SessionsConfig, SidequestConfig, SkillFilter, SkillPromptMode,
24    SkillsConfig, SlackConfig, StoreRoutingConfig, StoreRoutingStrategy, SttConfig, SubAgentConfig,
25    SubAgentLifecycleHooks, SubagentHooks, TaskSupervisorConfig, TelegramConfig, TimeoutConfig,
26    ToolDiscoveryConfig, ToolDiscoveryStrategyConfig, ToolFilterConfig, ToolPolicy, TraceConfig,
27    TrustConfig, TuiConfig, VaultConfig, VectorBackend,
28};
29
30pub use zeph_config::{
31    AutoDreamConfig, CategoryConfig, ContextStrategy, DigestConfig, MagicDocsConfig,
32    MicrocompactConfig, PersonaConfig, TrajectoryConfig, TreeConfig,
33};
34pub use zeph_config::{DiagnosticSeverity, DiagnosticsConfig, HoverConfig, LspConfig};
35pub use zeph_config::{DurableBackend, DurableConfig, RetentionPolicy};
36pub use zeph_config::{QualityConfig, TriggerPolicy};
37pub use zeph_config::{TelemetryBackend, TelemetryConfig};
38
39pub use zeph_config::{
40    ContentIsolationConfig, CustomPiiPattern, ExfiltrationGuardConfig, MemoryWriteValidationConfig,
41    PiiFilterConfig, QuarantineConfig,
42};
43pub use zeph_config::{GuardrailAction, GuardrailConfig, GuardrailFailStrategy};
44
45pub use zeph_config::A2aServerConfig;
46pub use zeph_config::ChannelSkillsConfig;
47pub use zeph_config::{FileChangedConfig, HooksConfig};
48
49pub use zeph_config::{
50    DEFAULT_DEBUG_DIR, DEFAULT_LOG_FILE, DEFAULT_SKILLS_DIR, DEFAULT_SQLITE_PATH,
51    default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
52    is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
53    is_legacy_default_sqlite_path,
54};
55
56pub use zeph_config::providers::{default_stt_language, validate_pool};
57
58pub mod migrate {
59    pub use zeph_config::migrate::*;
60}
61
62use crate::vault::{Secret, VaultProvider};
63
64/// Extension trait for resolving vault secrets into a [`Config`].
65///
66/// Implemented for [`Config`] in `zeph-core` because `VaultProvider` lives here.
67/// Call with `use zeph_core::config::SecretResolver` in scope.
68pub trait SecretResolver {
69    /// Populate `secrets` fields from the vault.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if the vault backend fails.
74    fn resolve_secrets(
75        &mut self,
76        vault: &dyn VaultProvider,
77    ) -> impl std::future::Future<Output = Result<(), ConfigError>> + Send;
78
79    /// Same as [`resolve_secrets`](Self::resolve_secrets), but additionally registers every
80    /// successfully resolved secret value with `registry` for outbound LLM masking (#5437).
81    ///
82    /// When `registry` is `None` (secret masking disabled), behaves identically to
83    /// `resolve_secrets`.
84    ///
85    /// # Errors
86    ///
87    /// Returns an error if the vault backend fails.
88    fn resolve_secrets_masked(
89        &mut self,
90        vault: &dyn VaultProvider,
91        registry: Option<&std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
92    ) -> impl std::future::Future<Output = Result<(), ConfigError>> + Send;
93}
94
95/// Registers a resolved secret with the PAAC mask registry, when one is attached.
96///
97/// No-op when `registry` is `None` (secret masking disabled or not yet bootstrapped).
98fn register_masked_secret(
99    registry: Option<&std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
100    key_name: &str,
101    value: &str,
102) {
103    if let Some(registry) = registry {
104        registry.register(
105            key_name,
106            value,
107            zeph_sanitizer::secret_mask::SecretCategory::from_key_name(key_name),
108        );
109    }
110}
111
112fn log_gonka_credential_status(has_key: bool, has_address: bool) {
113    match (has_key, has_address) {
114        (true, true) => tracing::info!("gonka wallet credentials resolved from vault"),
115        (true, false) => tracing::warn!(
116            "ZEPH_GONKA_PRIVATE_KEY is set but ZEPH_GONKA_ADDRESS is missing from vault"
117        ),
118        (false, true) => tracing::warn!(
119            "ZEPH_GONKA_ADDRESS is set but ZEPH_GONKA_PRIVATE_KEY is missing from vault"
120        ),
121        (false, false) => {}
122    }
123}
124
125// TODO(critic): vault values are inserted verbatim into typed config fields.
126// Consider centralizing whitespace trimming and empty-string normalization
127// here so every consumer (qdrant client, claude, openai, etc.) sees a clean value.
128impl SecretResolver for Config {
129    async fn resolve_secrets(&mut self, vault: &dyn VaultProvider) -> Result<(), ConfigError> {
130        self.resolve_secrets_masked(vault, None).await
131    }
132
133    #[allow(clippy::too_many_lines)] // one branch per vault key, each 2-4 lines — flat by design
134    async fn resolve_secrets_masked(
135        &mut self,
136        vault: &dyn VaultProvider,
137        registry: Option<&std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
138    ) -> Result<(), ConfigError> {
139        if let Some(val) = vault.get_secret("ZEPH_CLAUDE_API_KEY").await? {
140            register_masked_secret(registry, "ZEPH_CLAUDE_API_KEY", &val);
141            self.secrets.claude_api_key = Some(Secret::new(val));
142        }
143        if let Some(val) = vault.get_secret("ZEPH_OPENAI_API_KEY").await? {
144            register_masked_secret(registry, "ZEPH_OPENAI_API_KEY", &val);
145            self.secrets.openai_api_key = Some(Secret::new(val));
146        }
147        if let Some(val) = vault.get_secret("ZEPH_GEMINI_API_KEY").await? {
148            register_masked_secret(registry, "ZEPH_GEMINI_API_KEY", &val);
149            self.secrets.gemini_api_key = Some(Secret::new(val));
150        }
151        if let Some(val) = vault.get_secret("ZEPH_GONKA_PRIVATE_KEY").await? {
152            register_masked_secret(registry, "ZEPH_GONKA_PRIVATE_KEY", &val);
153            self.secrets.gonka_private_key = Some(Secret::new(val));
154        }
155        if let Some(val) = vault.get_secret("ZEPH_GONKA_ADDRESS").await? {
156            // M1 (#5437 critique): a wallet address is a public identifier, not a secret —
157            // masking it would only suppress legitimate model reasoning about it, with no
158            // confidentiality benefit.
159            self.secrets.gonka_address = Some(Secret::new(val));
160        }
161        if let Some(val) = vault.get_secret("ZEPH_COCOON_ACCESS_HASH").await? {
162            register_masked_secret(registry, "ZEPH_COCOON_ACCESS_HASH", &val);
163            self.secrets.cocoon_access_hash = Some(Secret::new(val));
164        }
165        log_gonka_credential_status(
166            self.secrets.gonka_private_key.is_some(),
167            self.secrets.gonka_address.is_some(),
168        );
169        if let Some(val) = vault.get_secret("ZEPH_TELEGRAM_TOKEN").await?
170            && let Some(tg) = self.telegram.as_mut()
171        {
172            register_masked_secret(registry, "ZEPH_TELEGRAM_TOKEN", &val);
173            tg.token = Some(val);
174        }
175        if let Some(val) = vault.get_secret("ZEPH_A2A_AUTH_TOKEN").await? {
176            register_masked_secret(registry, "ZEPH_A2A_AUTH_TOKEN", &val);
177            self.a2a.auth_token = Some(val);
178        }
179        for entry in &self.llm.providers {
180            if entry.provider_type == crate::config::ProviderKind::Compatible
181                && let Some(ref name) = entry.name
182            {
183                let normalized: String = name
184                    .to_uppercase()
185                    .chars()
186                    .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
187                    .collect();
188                let env_key = format!("ZEPH_COMPATIBLE_{normalized}_API_KEY");
189                if let Some(val) = vault.get_secret(&env_key).await? {
190                    register_masked_secret(registry, &env_key, &val);
191                    self.secrets
192                        .compatible_api_keys
193                        .insert(name.clone(), Secret::new(val));
194                }
195            }
196        }
197        if let Some(val) = vault.get_secret("ZEPH_HF_TOKEN").await? {
198            register_masked_secret(registry, "ZEPH_HF_TOKEN", &val);
199            self.classifiers.hf_token = Some(val.clone());
200            if let Some(candle) = self.llm.candle.as_mut() {
201                candle.hf_token = Some(val);
202            }
203        }
204        if let Some(val) = vault.get_secret("ZEPH_GATEWAY_TOKEN").await? {
205            register_masked_secret(registry, "ZEPH_GATEWAY_TOKEN", &val);
206            self.gateway.auth_token = Some(val);
207        }
208        if let Some(val) = vault.get_secret("ZEPH_DATABASE_URL").await? {
209            register_masked_secret(registry, "ZEPH_DATABASE_URL", &val);
210            self.memory.database_url = Some(val);
211        }
212        if let Some(val) = vault.get_secret("ZEPH_QDRANT_API_KEY").await? {
213            register_masked_secret(registry, "ZEPH_QDRANT_API_KEY", &val);
214            self.memory.qdrant_api_key = Some(Secret::new(val));
215        }
216        if let Some(val) = vault.get_secret("ZEPH_DISCORD_TOKEN").await?
217            && let Some(dc) = self.discord.as_mut()
218        {
219            register_masked_secret(registry, "ZEPH_DISCORD_TOKEN", &val);
220            dc.token = Some(val);
221        }
222        if let Some(val) = vault.get_secret("ZEPH_DISCORD_APP_ID").await?
223            && let Some(dc) = self.discord.as_mut()
224        {
225            // M1 (#5437 critique): the Discord application ID is a public snowflake, not a
226            // secret — masking it would only suppress legitimate model reasoning about it.
227            dc.application_id = Some(val);
228        }
229        if let Some(val) = vault.get_secret("ZEPH_SLACK_BOT_TOKEN").await?
230            && let Some(sl) = self.slack.as_mut()
231        {
232            register_masked_secret(registry, "ZEPH_SLACK_BOT_TOKEN", &val);
233            sl.bot_token = Some(val);
234        }
235        if let Some(val) = vault.get_secret("ZEPH_SLACK_SIGNING_SECRET").await?
236            && let Some(sl) = self.slack.as_mut()
237        {
238            register_masked_secret(registry, "ZEPH_SLACK_SIGNING_SECRET", &val);
239            sl.signing_secret = Some(val);
240        }
241        for key in vault.list_keys() {
242            if let Some(custom_name) = key.strip_prefix("ZEPH_SECRET_")
243                && !custom_name.is_empty()
244                && let Some(val) = vault.get_secret(&key).await?
245            {
246                // Canonical form uses underscores. Both `_` and `-` in vault key names
247                // are normalized to `_` so that ZEPH_SECRET_MY-KEY and ZEPH_SECRET_MY_KEY
248                // both map to "my_key", matching SKILL.md requires-secrets parsing.
249                let normalized = custom_name.to_lowercase().replace('-', "_");
250                register_masked_secret(registry, &key, &val);
251                self.secrets.custom.insert(normalized, Secret::new(val));
252            }
253        }
254        Ok(())
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[tokio::test]
263    #[cfg(any(test, feature = "mock"))]
264    async fn resolve_secrets_with_mock_vault() {
265        use crate::vault::MockVaultProvider;
266
267        let vault = MockVaultProvider::new()
268            .with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123")
269            .with_secret("ZEPH_TELEGRAM_TOKEN", "tg-token-456");
270
271        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
272        config.resolve_secrets(&vault).await.unwrap();
273
274        assert_eq!(
275            config.secrets.claude_api_key.as_ref().unwrap().expose(),
276            "sk-test-123"
277        );
278        if let Some(tg) = config.telegram {
279            assert_eq!(tg.token.as_deref(), Some("tg-token-456"));
280        }
281    }
282
283    #[tokio::test]
284    #[cfg(any(test, feature = "mock"))]
285    async fn resolve_gonka_secrets_both_present() {
286        use crate::vault::MockVaultProvider;
287
288        let vault = MockVaultProvider::new()
289            .with_secret("ZEPH_GONKA_PRIVATE_KEY", "gonka-priv-key-abc")
290            .with_secret("ZEPH_GONKA_ADDRESS", "gonka1xyzaddress");
291
292        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
293        config.resolve_secrets(&vault).await.unwrap();
294
295        assert_eq!(
296            config.secrets.gonka_private_key.as_ref().unwrap().expose(),
297            "gonka-priv-key-abc"
298        );
299        assert_eq!(
300            config.secrets.gonka_address.as_ref().unwrap().expose(),
301            "gonka1xyzaddress"
302        );
303    }
304
305    #[tokio::test]
306    #[cfg(any(test, feature = "mock"))]
307    async fn resolve_gonka_partial_only_private_key() {
308        use crate::vault::MockVaultProvider;
309
310        let vault =
311            MockVaultProvider::new().with_secret("ZEPH_GONKA_PRIVATE_KEY", "gonka-priv-key-only");
312
313        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
314        config.resolve_secrets(&vault).await.unwrap();
315
316        assert!(config.secrets.gonka_private_key.is_some());
317        assert!(config.secrets.gonka_address.is_none());
318    }
319
320    // --- resolve_secrets_masked / PAAC secret mask registry (#5437) ---
321
322    #[tokio::test]
323    #[cfg(any(test, feature = "mock"))]
324    async fn resolve_secrets_masked_registers_values_with_registry() {
325        use crate::vault::MockVaultProvider;
326        use std::sync::Arc;
327        use zeph_sanitizer::secret_mask::SecretMaskRegistry;
328
329        let vault = MockVaultProvider::new()
330            .with_secret("ZEPH_CLAUDE_API_KEY", "sk-claude-secret-value-1234")
331            .with_secret("ZEPH_OPENAI_API_KEY", "sk-openai-secret-value-5678");
332
333        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
334        let registry = Arc::new(SecretMaskRegistry::new());
335        config
336            .resolve_secrets_masked(&vault, Some(&registry))
337            .await
338            .unwrap();
339
340        assert_eq!(
341            registry.len(),
342            2,
343            "both resolved secrets must be registered"
344        );
345        let masked = registry.mask("token: sk-claude-secret-value-1234");
346        assert!(!masked.contains("sk-claude-secret-value-1234"));
347        assert!(masked.contains("<SECRET:api_key:"));
348    }
349
350    #[tokio::test]
351    #[cfg(any(test, feature = "mock"))]
352    async fn resolve_secrets_masked_none_registry_behaves_like_resolve_secrets() {
353        use crate::vault::MockVaultProvider;
354
355        let vault = MockVaultProvider::new().with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123");
356        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
357        config.resolve_secrets_masked(&vault, None).await.unwrap();
358
359        assert_eq!(
360            config.secrets.claude_api_key.as_ref().unwrap().expose(),
361            "sk-test-123",
362            "None registry must not change resolution behavior"
363        );
364    }
365
366    #[tokio::test]
367    #[cfg(any(test, feature = "mock"))]
368    async fn resolve_secrets_plain_delegates_without_registering() {
369        use crate::vault::MockVaultProvider;
370
371        // Plain resolve_secrets() (used by every call site except bootstrap) must still work
372        // unchanged — it delegates to resolve_secrets_masked with registry = None.
373        let vault = MockVaultProvider::new().with_secret("ZEPH_OPENAI_API_KEY", "sk-openai-abc");
374        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
375        config.resolve_secrets(&vault).await.unwrap();
376
377        assert_eq!(
378            config.secrets.openai_api_key.as_ref().unwrap().expose(),
379            "sk-openai-abc"
380        );
381    }
382}