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    RegistryBackendKind, RegistryConfig, ResolvedSecrets, RetrievalConfig, RouterConfig,
22    RouterStrategyConfig, ScheduledTaskConfig, ScheduledTaskKind, SchedulerConfig,
23    SchedulerDaemonConfig, SchedulerSecurityConfig, SecurityConfig, SemanticConfig, SessionsConfig,
24    SidequestConfig, SkillFilter, SkillPromptMode, SkillsConfig, SlackConfig, StoreRoutingConfig,
25    StoreRoutingStrategy, SttConfig, SubAgentConfig, SubAgentLifecycleHooks, SubagentHooks,
26    TaskSupervisorConfig, TelegramConfig, TimeoutConfig, ToolDiscoveryConfig,
27    ToolDiscoveryStrategyConfig, ToolFilterConfig, ToolPolicy, TraceConfig, TrustConfig, TuiConfig,
28    VaultConfig, VectorBackend,
29};
30
31pub use zeph_config::{
32    AutoDreamConfig, CategoryConfig, ContextStrategy, DigestConfig, MagicDocsConfig,
33    MicrocompactConfig, PersonaConfig, TrajectoryConfig, TreeConfig,
34};
35pub use zeph_config::{DiagnosticSeverity, DiagnosticsConfig, HoverConfig, LspConfig};
36pub use zeph_config::{DurableBackend, DurableConfig, RetentionPolicy};
37pub use zeph_config::{QualityConfig, TriggerPolicy};
38pub use zeph_config::{TelemetryBackend, TelemetryConfig};
39
40pub use zeph_config::{
41    ContentIsolationConfig, CustomPiiPattern, ExfiltrationGuardConfig, MemoryWriteValidationConfig,
42    PiiFilterConfig, QuarantineConfig,
43};
44pub use zeph_config::{GuardrailAction, GuardrailConfig, GuardrailFailStrategy};
45
46pub use zeph_config::A2aClientConfig;
47pub use zeph_config::A2aServerConfig;
48pub use zeph_config::ChannelSkillsConfig;
49pub use zeph_config::{CardTrustPolicy, TrustedAgentKey};
50pub use zeph_config::{FileChangedConfig, HooksConfig};
51
52pub use zeph_config::{
53    DEFAULT_DEBUG_DIR, DEFAULT_LOG_FILE, DEFAULT_SKILLS_DIR, DEFAULT_SQLITE_PATH,
54    default_debug_dir, default_log_file_path, default_skills_dir, default_sqlite_path,
55    is_legacy_default_debug_dir, is_legacy_default_log_file, is_legacy_default_skills_path,
56    is_legacy_default_sqlite_path,
57};
58
59pub use zeph_config::providers::{default_stt_language, validate_pool};
60
61pub mod migrate {
62    pub use zeph_config::migrate::*;
63}
64
65use crate::vault::{Secret, VaultProvider};
66
67/// Extension trait for resolving vault secrets into a [`Config`].
68///
69/// Implemented for [`Config`] in `zeph-core` because `VaultProvider` lives here.
70/// Call with `use zeph_core::config::SecretResolver` in scope.
71pub trait SecretResolver {
72    /// Populate `secrets` fields from the vault.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if the vault backend fails.
77    fn resolve_secrets(
78        &mut self,
79        vault: &dyn VaultProvider,
80    ) -> impl std::future::Future<Output = Result<(), ConfigError>> + Send;
81
82    /// Same as [`resolve_secrets`](Self::resolve_secrets), but additionally registers every
83    /// successfully resolved secret value with `registry` for outbound LLM masking (#5437).
84    ///
85    /// When `registry` is `None` (secret masking disabled), behaves identically to
86    /// `resolve_secrets`.
87    ///
88    /// # Errors
89    ///
90    /// Returns an error if the vault backend fails.
91    fn resolve_secrets_masked(
92        &mut self,
93        vault: &dyn VaultProvider,
94        registry: Option<&std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
95    ) -> impl std::future::Future<Output = Result<(), ConfigError>> + Send;
96}
97
98/// Registers a resolved secret with the PAAC mask registry, when one is attached.
99///
100/// No-op when `registry` is `None` (secret masking disabled or not yet bootstrapped).
101fn register_masked_secret(
102    registry: Option<&std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
103    key_name: &str,
104    value: &str,
105) {
106    if let Some(registry) = registry {
107        registry.register(
108            key_name,
109            value,
110            zeph_sanitizer::secret_mask::SecretCategory::from_key_name(key_name),
111        );
112    }
113}
114
115fn log_gonka_credential_status(has_key: bool, has_address: bool) {
116    match (has_key, has_address) {
117        (true, true) => tracing::info!("gonka wallet credentials resolved from vault"),
118        (true, false) => tracing::warn!(
119            "ZEPH_GONKA_PRIVATE_KEY is set but ZEPH_GONKA_ADDRESS is missing from vault"
120        ),
121        (false, true) => tracing::warn!(
122            "ZEPH_GONKA_ADDRESS is set but ZEPH_GONKA_PRIVATE_KEY is missing from vault"
123        ),
124        (false, false) => {}
125    }
126}
127
128// TODO(critic): vault values are inserted verbatim into typed config fields.
129// Consider centralizing whitespace trimming and empty-string normalization
130// here so every consumer (qdrant client, claude, openai, etc.) sees a clean value.
131impl SecretResolver for Config {
132    async fn resolve_secrets(&mut self, vault: &dyn VaultProvider) -> Result<(), ConfigError> {
133        self.resolve_secrets_masked(vault, None).await
134    }
135
136    #[allow(clippy::too_many_lines)] // one branch per vault key, each 2-4 lines — flat by design
137    async fn resolve_secrets_masked(
138        &mut self,
139        vault: &dyn VaultProvider,
140        registry: Option<&std::sync::Arc<zeph_sanitizer::secret_mask::SecretMaskRegistry>>,
141    ) -> Result<(), ConfigError> {
142        if let Some(val) = vault.get_secret("ZEPH_CLAUDE_API_KEY").await? {
143            register_masked_secret(registry, "ZEPH_CLAUDE_API_KEY", &val);
144            self.secrets.claude_api_key = Some(Secret::new(val));
145        }
146        if let Some(val) = vault.get_secret("ZEPH_OPENAI_API_KEY").await? {
147            register_masked_secret(registry, "ZEPH_OPENAI_API_KEY", &val);
148            self.secrets.openai_api_key = Some(Secret::new(val));
149        }
150        if let Some(val) = vault.get_secret("ZEPH_GEMINI_API_KEY").await? {
151            register_masked_secret(registry, "ZEPH_GEMINI_API_KEY", &val);
152            self.secrets.gemini_api_key = Some(Secret::new(val));
153        }
154        if let Some(val) = vault.get_secret("ZEPH_GONKA_PRIVATE_KEY").await? {
155            register_masked_secret(registry, "ZEPH_GONKA_PRIVATE_KEY", &val);
156            self.secrets.gonka_private_key = Some(Secret::new(val));
157        }
158        if let Some(val) = vault.get_secret("ZEPH_GONKA_ADDRESS").await? {
159            // M1 (#5437 critique): a wallet address is a public identifier, not a secret —
160            // masking it would only suppress legitimate model reasoning about it, with no
161            // confidentiality benefit.
162            self.secrets.gonka_address = Some(Secret::new(val));
163        }
164        if let Some(val) = vault.get_secret("ZEPH_COCOON_ACCESS_HASH").await? {
165            register_masked_secret(registry, "ZEPH_COCOON_ACCESS_HASH", &val);
166            self.secrets.cocoon_access_hash = Some(Secret::new(val));
167        }
168        // Registry lookups are strictly opt-in (NFR-001): only touch the vault when the
169        // registry is enabled AND a key name is configured, never unconditionally.
170        if self.skills.registry.enabled
171            && let Some(key_name) = self.skills.registry.auth_vault_key.clone()
172            && let Some(val) = vault.get_secret(&key_name).await?
173        {
174            register_masked_secret(registry, &key_name, &val);
175            self.secrets.skill_registry_token = Some(Secret::new(val));
176        }
177        log_gonka_credential_status(
178            self.secrets.gonka_private_key.is_some(),
179            self.secrets.gonka_address.is_some(),
180        );
181        if let Some(val) = vault.get_secret("ZEPH_TELEGRAM_TOKEN").await?
182            && let Some(tg) = self.telegram.as_mut()
183        {
184            register_masked_secret(registry, "ZEPH_TELEGRAM_TOKEN", &val);
185            tg.token = Some(val);
186        }
187        if let Some(val) = vault.get_secret("ZEPH_A2A_AUTH_TOKEN").await? {
188            // #6268: an empty-string secret must be treated as "not configured", not as a
189            // valid "" bearer token that `AuthConfig`/`auth_middleware` would otherwise
190            // (pre-fix) accept from any request with no Authorization header at all.
191            if val.is_empty() {
192                tracing::warn!(
193                    "ZEPH_A2A_AUTH_TOKEN resolved to an empty string; treating as not configured"
194                );
195            } else {
196                register_masked_secret(registry, "ZEPH_A2A_AUTH_TOKEN", &val);
197                self.a2a.auth_token = Some(val);
198            }
199        }
200        for entry in &self.llm.providers {
201            if entry.provider_type == crate::config::ProviderKind::Compatible
202                && let Some(ref name) = entry.name
203            {
204                let normalized: String = name
205                    .to_uppercase()
206                    .chars()
207                    .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
208                    .collect();
209                let env_key = format!("ZEPH_COMPATIBLE_{normalized}_API_KEY");
210                if let Some(val) = vault.get_secret(&env_key).await? {
211                    register_masked_secret(registry, &env_key, &val);
212                    self.secrets
213                        .compatible_api_keys
214                        .insert(name.clone(), Secret::new(val));
215                }
216            }
217        }
218        if let Some(val) = vault.get_secret("ZEPH_HF_TOKEN").await? {
219            register_masked_secret(registry, "ZEPH_HF_TOKEN", &val);
220            self.classifiers.hf_token = Some(val.clone());
221            if let Some(candle) = self.llm.candle.as_mut() {
222                candle.hf_token = Some(val);
223            }
224        }
225        if let Some(val) = vault.get_secret("ZEPH_GATEWAY_TOKEN").await? {
226            // #6268: same rationale as ZEPH_A2A_AUTH_TOKEN above — an empty secret is
227            // "not configured", not a valid "" bearer token.
228            if val.is_empty() {
229                tracing::warn!(
230                    "ZEPH_GATEWAY_TOKEN resolved to an empty string; treating as not configured"
231                );
232            } else {
233                register_masked_secret(registry, "ZEPH_GATEWAY_TOKEN", &val);
234                self.gateway.auth_token = Some(val);
235            }
236        }
237        if let Some(val) = vault.get_secret("ZEPH_DATABASE_URL").await? {
238            register_masked_secret(registry, "ZEPH_DATABASE_URL", &val);
239            self.memory.database_url = Some(Secret::new(val));
240        }
241        if let Some(val) = vault.get_secret("ZEPH_QDRANT_API_KEY").await? {
242            register_masked_secret(registry, "ZEPH_QDRANT_API_KEY", &val);
243            self.memory.qdrant_api_key = Some(Secret::new(val));
244        }
245        if let Some(val) = vault.get_secret("ZEPH_DISCORD_TOKEN").await?
246            && let Some(dc) = self.discord.as_mut()
247        {
248            register_masked_secret(registry, "ZEPH_DISCORD_TOKEN", &val);
249            dc.token = Some(val);
250        }
251        if let Some(val) = vault.get_secret("ZEPH_DISCORD_APP_ID").await?
252            && let Some(dc) = self.discord.as_mut()
253        {
254            // M1 (#5437 critique): the Discord application ID is a public snowflake, not a
255            // secret — masking it would only suppress legitimate model reasoning about it.
256            dc.application_id = Some(val);
257        }
258        if let Some(val) = vault.get_secret("ZEPH_SLACK_BOT_TOKEN").await?
259            && let Some(sl) = self.slack.as_mut()
260        {
261            register_masked_secret(registry, "ZEPH_SLACK_BOT_TOKEN", &val);
262            sl.bot_token = Some(val);
263        }
264        if let Some(val) = vault.get_secret("ZEPH_SLACK_SIGNING_SECRET").await?
265            && let Some(sl) = self.slack.as_mut()
266        {
267            register_masked_secret(registry, "ZEPH_SLACK_SIGNING_SECRET", &val);
268            sl.signing_secret = Some(val);
269        }
270        for key in vault.list_keys() {
271            if let Some(custom_name) = key.strip_prefix("ZEPH_SECRET_")
272                && !custom_name.is_empty()
273                && let Some(val) = vault.get_secret(&key).await?
274            {
275                // Canonical form uses underscores. Both `_` and `-` in vault key names
276                // are normalized to `_` so that ZEPH_SECRET_MY-KEY and ZEPH_SECRET_MY_KEY
277                // both map to "my_key", matching SKILL.md requires-secrets parsing.
278                let normalized = custom_name.to_lowercase().replace('-', "_");
279                register_masked_secret(registry, &key, &val);
280                self.secrets.custom.insert(normalized, Secret::new(val));
281            }
282        }
283        Ok(())
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[tokio::test]
292    #[cfg(any(test, feature = "mock"))]
293    async fn resolve_secrets_with_mock_vault() {
294        use crate::vault::MockVaultProvider;
295
296        let vault = MockVaultProvider::new()
297            .with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123")
298            .with_secret("ZEPH_TELEGRAM_TOKEN", "tg-token-456");
299
300        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
301        config.resolve_secrets(&vault).await.unwrap();
302
303        assert_eq!(
304            config.secrets.claude_api_key.as_ref().unwrap().expose(),
305            "sk-test-123"
306        );
307        if let Some(tg) = config.telegram {
308            assert_eq!(tg.token.as_deref(), Some("tg-token-456"));
309        }
310    }
311
312    #[tokio::test]
313    #[cfg(any(test, feature = "mock"))]
314    async fn resolve_gonka_secrets_both_present() {
315        use crate::vault::MockVaultProvider;
316
317        let vault = MockVaultProvider::new()
318            .with_secret("ZEPH_GONKA_PRIVATE_KEY", "gonka-priv-key-abc")
319            .with_secret("ZEPH_GONKA_ADDRESS", "gonka1xyzaddress");
320
321        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
322        config.resolve_secrets(&vault).await.unwrap();
323
324        assert_eq!(
325            config.secrets.gonka_private_key.as_ref().unwrap().expose(),
326            "gonka-priv-key-abc"
327        );
328        assert_eq!(
329            config.secrets.gonka_address.as_ref().unwrap().expose(),
330            "gonka1xyzaddress"
331        );
332    }
333
334    #[tokio::test]
335    #[cfg(any(test, feature = "mock"))]
336    async fn resolve_gonka_partial_only_private_key() {
337        use crate::vault::MockVaultProvider;
338
339        let vault =
340            MockVaultProvider::new().with_secret("ZEPH_GONKA_PRIVATE_KEY", "gonka-priv-key-only");
341
342        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
343        config.resolve_secrets(&vault).await.unwrap();
344
345        assert!(config.secrets.gonka_private_key.is_some());
346        assert!(config.secrets.gonka_address.is_none());
347    }
348
349    // --- resolve_secrets_masked / PAAC secret mask registry (#5437) ---
350
351    #[tokio::test]
352    #[cfg(any(test, feature = "mock"))]
353    async fn resolve_secrets_masked_registers_values_with_registry() {
354        use crate::vault::MockVaultProvider;
355        use std::sync::Arc;
356        use zeph_sanitizer::secret_mask::SecretMaskRegistry;
357
358        let vault = MockVaultProvider::new()
359            .with_secret("ZEPH_CLAUDE_API_KEY", "sk-claude-secret-value-1234")
360            .with_secret("ZEPH_OPENAI_API_KEY", "sk-openai-secret-value-5678");
361
362        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
363        let registry = Arc::new(SecretMaskRegistry::new());
364        config
365            .resolve_secrets_masked(&vault, Some(&registry))
366            .await
367            .unwrap();
368
369        assert_eq!(
370            registry.len(),
371            2,
372            "both resolved secrets must be registered"
373        );
374        let masked = registry.mask("token: sk-claude-secret-value-1234");
375        assert!(!masked.contains("sk-claude-secret-value-1234"));
376        assert!(masked.contains("<SECRET:api_key:"));
377    }
378
379    #[tokio::test]
380    #[cfg(any(test, feature = "mock"))]
381    async fn resolve_secrets_masked_none_registry_behaves_like_resolve_secrets() {
382        use crate::vault::MockVaultProvider;
383
384        let vault = MockVaultProvider::new().with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123");
385        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
386        config.resolve_secrets_masked(&vault, None).await.unwrap();
387
388        assert_eq!(
389            config.secrets.claude_api_key.as_ref().unwrap().expose(),
390            "sk-test-123",
391            "None registry must not change resolution behavior"
392        );
393    }
394
395    #[tokio::test]
396    #[cfg(any(test, feature = "mock"))]
397    async fn resolve_secrets_plain_delegates_without_registering() {
398        use crate::vault::MockVaultProvider;
399
400        // Plain resolve_secrets() (used by every call site except bootstrap) must still work
401        // unchanged — it delegates to resolve_secrets_masked with registry = None.
402        let vault = MockVaultProvider::new().with_secret("ZEPH_OPENAI_API_KEY", "sk-openai-abc");
403        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
404        config.resolve_secrets(&vault).await.unwrap();
405
406        assert_eq!(
407            config.secrets.openai_api_key.as_ref().unwrap().expose(),
408            "sk-openai-abc"
409        );
410    }
411
412    #[tokio::test]
413    #[cfg(any(test, feature = "mock"))]
414    async fn resolve_secrets_empty_gateway_token_treated_as_not_configured() {
415        use crate::vault::MockVaultProvider;
416
417        // #6268: an empty-string ZEPH_GATEWAY_TOKEN must not become `Some("")` — that would
418        // let AuthConfig hash "" and accept any request with no Authorization header at all.
419        let vault = MockVaultProvider::new().with_secret("ZEPH_GATEWAY_TOKEN", "");
420        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
421        config.resolve_secrets(&vault).await.unwrap();
422
423        assert!(config.gateway.auth_token.is_none());
424    }
425
426    #[tokio::test]
427    #[cfg(any(test, feature = "mock"))]
428    async fn resolve_secrets_empty_a2a_token_treated_as_not_configured() {
429        use crate::vault::MockVaultProvider;
430
431        let vault = MockVaultProvider::new().with_secret("ZEPH_A2A_AUTH_TOKEN", "");
432        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
433        config.resolve_secrets(&vault).await.unwrap();
434
435        assert!(config.a2a.auth_token.is_none());
436    }
437}