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        // Runtime-gated web_search tool (spec 006-1-web-search): only touch the vault
178        // when the tool is enabled, mirroring the skills.registry opt-in-only pattern.
179        if self.tools.search.enabled
180            && let Some(val) = vault
181                .get_secret(&self.tools.search.api_key_vault_key)
182                .await?
183        {
184            register_masked_secret(registry, &self.tools.search.api_key_vault_key, &val);
185            self.secrets.web_search_api_key = Some(Secret::new(val));
186        }
187        log_gonka_credential_status(
188            self.secrets.gonka_private_key.is_some(),
189            self.secrets.gonka_address.is_some(),
190        );
191        if let Some(val) = vault.get_secret("ZEPH_TELEGRAM_TOKEN").await?
192            && let Some(tg) = self.telegram.as_mut()
193        {
194            register_masked_secret(registry, "ZEPH_TELEGRAM_TOKEN", &val);
195            tg.token = Some(val);
196        }
197        if let Some(val) = vault.get_secret("ZEPH_A2A_AUTH_TOKEN").await? {
198            // #6268: an empty-string secret must be treated as "not configured", not as a
199            // valid "" bearer token that `AuthConfig`/`auth_middleware` would otherwise
200            // (pre-fix) accept from any request with no Authorization header at all.
201            if val.is_empty() {
202                tracing::warn!(
203                    "ZEPH_A2A_AUTH_TOKEN resolved to an empty string; treating as not configured"
204                );
205            } else {
206                register_masked_secret(registry, "ZEPH_A2A_AUTH_TOKEN", &val);
207                self.a2a.auth_token = Some(val);
208            }
209        }
210        for entry in &self.llm.providers {
211            if entry.provider_type == crate::config::ProviderKind::Compatible
212                && let Some(ref name) = entry.name
213            {
214                let normalized: String = name
215                    .to_uppercase()
216                    .chars()
217                    .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
218                    .collect();
219                let env_key = format!("ZEPH_COMPATIBLE_{normalized}_API_KEY");
220                if let Some(val) = vault.get_secret(&env_key).await? {
221                    register_masked_secret(registry, &env_key, &val);
222                    self.secrets
223                        .compatible_api_keys
224                        .insert(name.clone(), Secret::new(val));
225                }
226            }
227        }
228        if let Some(val) = vault.get_secret("ZEPH_HF_TOKEN").await? {
229            register_masked_secret(registry, "ZEPH_HF_TOKEN", &val);
230            self.classifiers.hf_token = Some(val.clone());
231            if let Some(candle) = self.llm.candle.as_mut() {
232                candle.hf_token = Some(val);
233            }
234        }
235        if let Some(val) = vault.get_secret("ZEPH_GATEWAY_TOKEN").await? {
236            // #6268: same rationale as ZEPH_A2A_AUTH_TOKEN above — an empty secret is
237            // "not configured", not a valid "" bearer token.
238            if val.is_empty() {
239                tracing::warn!(
240                    "ZEPH_GATEWAY_TOKEN resolved to an empty string; treating as not configured"
241                );
242            } else {
243                register_masked_secret(registry, "ZEPH_GATEWAY_TOKEN", &val);
244                self.gateway.auth_token = Some(val);
245            }
246        }
247        if let Some(val) = vault.get_secret("ZEPH_DATABASE_URL").await? {
248            register_masked_secret(registry, "ZEPH_DATABASE_URL", &val);
249            self.memory.database_url = Some(Secret::new(val));
250        }
251        if let Some(val) = vault.get_secret("ZEPH_QDRANT_API_KEY").await? {
252            register_masked_secret(registry, "ZEPH_QDRANT_API_KEY", &val);
253            self.memory.qdrant_api_key = Some(Secret::new(val));
254        }
255        if let Some(val) = vault.get_secret("ZEPH_DISCORD_TOKEN").await?
256            && let Some(dc) = self.discord.as_mut()
257        {
258            register_masked_secret(registry, "ZEPH_DISCORD_TOKEN", &val);
259            dc.token = Some(val);
260        }
261        if let Some(val) = vault.get_secret("ZEPH_DISCORD_APP_ID").await?
262            && let Some(dc) = self.discord.as_mut()
263        {
264            // M1 (#5437 critique): the Discord application ID is a public snowflake, not a
265            // secret — masking it would only suppress legitimate model reasoning about it.
266            dc.application_id = Some(val);
267        }
268        if let Some(val) = vault.get_secret("ZEPH_SLACK_BOT_TOKEN").await?
269            && let Some(sl) = self.slack.as_mut()
270        {
271            register_masked_secret(registry, "ZEPH_SLACK_BOT_TOKEN", &val);
272            sl.bot_token = Some(val);
273        }
274        if let Some(val) = vault.get_secret("ZEPH_SLACK_SIGNING_SECRET").await?
275            && let Some(sl) = self.slack.as_mut()
276        {
277            register_masked_secret(registry, "ZEPH_SLACK_SIGNING_SECRET", &val);
278            sl.signing_secret = Some(val);
279        }
280        for key in vault.list_keys() {
281            if let Some(custom_name) = key.strip_prefix("ZEPH_SECRET_")
282                && !custom_name.is_empty()
283                && let Some(val) = vault.get_secret(&key).await?
284            {
285                // Canonical form uses underscores. Both `_` and `-` in vault key names
286                // are normalized to `_` so that ZEPH_SECRET_MY-KEY and ZEPH_SECRET_MY_KEY
287                // both map to "my_key", matching SKILL.md requires-secrets parsing.
288                let normalized = custom_name.to_lowercase().replace('-', "_");
289                register_masked_secret(registry, &key, &val);
290                self.secrets.custom.insert(normalized, Secret::new(val));
291            }
292        }
293        Ok(())
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[tokio::test]
302    #[cfg(any(test, feature = "mock"))]
303    async fn resolve_secrets_with_mock_vault() {
304        use crate::vault::MockVaultProvider;
305
306        let vault = MockVaultProvider::new()
307            .with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123")
308            .with_secret("ZEPH_TELEGRAM_TOKEN", "tg-token-456");
309
310        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
311        config.resolve_secrets(&vault).await.unwrap();
312
313        assert_eq!(
314            config.secrets.claude_api_key.as_ref().unwrap().expose(),
315            "sk-test-123"
316        );
317        if let Some(tg) = config.telegram {
318            assert_eq!(tg.token.as_deref(), Some("tg-token-456"));
319        }
320    }
321
322    #[tokio::test]
323    #[cfg(any(test, feature = "mock"))]
324    async fn resolve_gonka_secrets_both_present() {
325        use crate::vault::MockVaultProvider;
326
327        let vault = MockVaultProvider::new()
328            .with_secret("ZEPH_GONKA_PRIVATE_KEY", "gonka-priv-key-abc")
329            .with_secret("ZEPH_GONKA_ADDRESS", "gonka1xyzaddress");
330
331        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
332        config.resolve_secrets(&vault).await.unwrap();
333
334        assert_eq!(
335            config.secrets.gonka_private_key.as_ref().unwrap().expose(),
336            "gonka-priv-key-abc"
337        );
338        assert_eq!(
339            config.secrets.gonka_address.as_ref().unwrap().expose(),
340            "gonka1xyzaddress"
341        );
342    }
343
344    #[tokio::test]
345    #[cfg(any(test, feature = "mock"))]
346    async fn resolve_gonka_partial_only_private_key() {
347        use crate::vault::MockVaultProvider;
348
349        let vault =
350            MockVaultProvider::new().with_secret("ZEPH_GONKA_PRIVATE_KEY", "gonka-priv-key-only");
351
352        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
353        config.resolve_secrets(&vault).await.unwrap();
354
355        assert!(config.secrets.gonka_private_key.is_some());
356        assert!(config.secrets.gonka_address.is_none());
357    }
358
359    // --- resolve_secrets_masked / PAAC secret mask registry (#5437) ---
360
361    #[tokio::test]
362    #[cfg(any(test, feature = "mock"))]
363    async fn resolve_secrets_masked_registers_values_with_registry() {
364        use crate::vault::MockVaultProvider;
365        use std::sync::Arc;
366        use zeph_sanitizer::secret_mask::SecretMaskRegistry;
367
368        let vault = MockVaultProvider::new()
369            .with_secret("ZEPH_CLAUDE_API_KEY", "sk-claude-secret-value-1234")
370            .with_secret("ZEPH_OPENAI_API_KEY", "sk-openai-secret-value-5678");
371
372        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
373        let registry = Arc::new(SecretMaskRegistry::new());
374        config
375            .resolve_secrets_masked(&vault, Some(&registry))
376            .await
377            .unwrap();
378
379        assert_eq!(
380            registry.len(),
381            2,
382            "both resolved secrets must be registered"
383        );
384        let masked = registry.mask("token: sk-claude-secret-value-1234");
385        assert!(!masked.contains("sk-claude-secret-value-1234"));
386        assert!(masked.contains("<SECRET:api_key:"));
387    }
388
389    #[tokio::test]
390    #[cfg(any(test, feature = "mock"))]
391    async fn resolve_secrets_masked_none_registry_behaves_like_resolve_secrets() {
392        use crate::vault::MockVaultProvider;
393
394        let vault = MockVaultProvider::new().with_secret("ZEPH_CLAUDE_API_KEY", "sk-test-123");
395        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
396        config.resolve_secrets_masked(&vault, None).await.unwrap();
397
398        assert_eq!(
399            config.secrets.claude_api_key.as_ref().unwrap().expose(),
400            "sk-test-123",
401            "None registry must not change resolution behavior"
402        );
403    }
404
405    #[tokio::test]
406    #[cfg(any(test, feature = "mock"))]
407    async fn resolve_secrets_plain_delegates_without_registering() {
408        use crate::vault::MockVaultProvider;
409
410        // Plain resolve_secrets() (used by every call site except bootstrap) must still work
411        // unchanged — it delegates to resolve_secrets_masked with registry = None.
412        let vault = MockVaultProvider::new().with_secret("ZEPH_OPENAI_API_KEY", "sk-openai-abc");
413        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
414        config.resolve_secrets(&vault).await.unwrap();
415
416        assert_eq!(
417            config.secrets.openai_api_key.as_ref().unwrap().expose(),
418            "sk-openai-abc"
419        );
420    }
421
422    #[tokio::test]
423    #[cfg(any(test, feature = "mock"))]
424    async fn resolve_secrets_empty_gateway_token_treated_as_not_configured() {
425        use crate::vault::MockVaultProvider;
426
427        // #6268: an empty-string ZEPH_GATEWAY_TOKEN must not become `Some("")` — that would
428        // let AuthConfig hash "" and accept any request with no Authorization header at all.
429        let vault = MockVaultProvider::new().with_secret("ZEPH_GATEWAY_TOKEN", "");
430        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
431        config.resolve_secrets(&vault).await.unwrap();
432
433        assert!(config.gateway.auth_token.is_none());
434    }
435
436    #[tokio::test]
437    #[cfg(any(test, feature = "mock"))]
438    async fn resolve_secrets_empty_a2a_token_treated_as_not_configured() {
439        use crate::vault::MockVaultProvider;
440
441        let vault = MockVaultProvider::new().with_secret("ZEPH_A2A_AUTH_TOKEN", "");
442        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
443        config.resolve_secrets(&vault).await.unwrap();
444
445        assert!(config.a2a.auth_token.is_none());
446    }
447
448    // --- tools.search vault-key opt-in gate (spec 006-1-web-search, #6445) ---
449    // Mirrors the skills.registry.auth_vault_key opt-in-only pattern this gate copies.
450
451    #[tokio::test]
452    #[cfg(any(test, feature = "mock"))]
453    async fn resolve_secrets_web_search_enabled_resolves_key() {
454        use crate::vault::MockVaultProvider;
455
456        let vault = MockVaultProvider::new().with_secret("ZEPH_WEB_SEARCH_API_KEY", "brave-key-1");
457        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
458        config.tools.search.enabled = true;
459        config.resolve_secrets(&vault).await.unwrap();
460
461        assert_eq!(
462            config.secrets.web_search_api_key.as_ref().unwrap().expose(),
463            "brave-key-1"
464        );
465    }
466
467    #[tokio::test]
468    #[cfg(any(test, feature = "mock"))]
469    async fn resolve_secrets_web_search_disabled_key_stays_unset() {
470        use crate::vault::MockVaultProvider;
471
472        // The key is present in the vault under the default vault key name, but the tool is
473        // disabled (the default) — the gate must skip resolution entirely, so the secret
474        // stays unset even though the vault could have supplied it. `MockVaultProvider` has
475        // no call-tracking, so this asserts the observable outcome (key stays `None`) rather
476        // than literally proving `get_secret` was never invoked.
477        let vault = MockVaultProvider::new().with_secret("ZEPH_WEB_SEARCH_API_KEY", "brave-key-2");
478        let mut config = Config::load(std::path::Path::new("/nonexistent/config.toml")).unwrap();
479        assert!(
480            !config.tools.search.enabled,
481            "search must be disabled by default"
482        );
483        config.resolve_secrets(&vault).await.unwrap();
484
485        assert!(config.secrets.web_search_api_key.is_none());
486    }
487}