Skip to main content

zeph_config/
channels.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::HashMap;
5
6use serde::{Deserialize, Serialize};
7
8use crate::defaults::default_true;
9use crate::providers::ProviderName;
10
11pub use crate::mcp_security::ToolSecurityMeta;
12
13// ── MCP trust and policy types (moved from zeph-mcp) ─────────────────────────
14
15/// Trust level for an MCP server connection.
16///
17/// Controls SSRF validation, tool filtering, and data-flow policy enforcement.
18#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20#[non_exhaustive]
21pub enum McpTrustLevel {
22    /// Full trust — all tools exposed, SSRF check skipped. Use for operator-controlled servers.
23    Trusted,
24    /// Default. SSRF enforced. Tools exposed with a warning when allowlist is empty.
25    #[default]
26    Untrusted,
27    /// Strict sandboxing — SSRF enforced. Only allowlisted tools exposed; empty allowlist = no tools.
28    Sandboxed,
29}
30
31impl McpTrustLevel {
32    /// Returns a numeric restriction level where higher means more restricted.
33    ///
34    /// Used for "only demote, never promote automatically" comparisons.
35    #[must_use]
36    pub fn restriction_level(self) -> u8 {
37        match self {
38            Self::Trusted => 0,
39            Self::Untrusted => 1,
40            Self::Sandboxed => 2,
41        }
42    }
43}
44
45/// Rate limit configuration for a single MCP server.
46#[derive(Debug, Clone, Deserialize, Serialize)]
47pub struct RateLimit {
48    /// Maximum number of tool calls allowed per minute across all tools on this server.
49    pub max_calls_per_minute: u32,
50}
51
52/// Per-server MCP policy.
53///
54/// No policy present = allow all (backward compatible default).
55#[derive(Debug, Clone, Default, Deserialize, Serialize)]
56#[serde(default)]
57pub struct McpPolicy {
58    /// Allowlist of tool names. `None` means all tools are allowed (subject to `denied_tools`).
59    pub allowed_tools: Option<Vec<String>>,
60    /// Denylist of tool names. Takes precedence over `allowed_tools`.
61    pub denied_tools: Vec<String>,
62    /// Optional rate limit for this server.
63    pub rate_limit: Option<RateLimit>,
64}
65
66fn default_skill_allowlist() -> Vec<String> {
67    vec!["*".into()]
68}
69
70/// Per-channel skill allowlist configuration.
71///
72/// Declares which skills are permitted on a given channel. The config is parsed and
73/// `is_skill_allowed()` is available for callers to check membership. Runtime enforcement
74/// (filtering skills before prompt assembly) is tracked in issue #2507 and not yet wired.
75#[derive(Debug, Clone, Deserialize, Serialize)]
76pub struct ChannelSkillsConfig {
77    /// Skill allowlist. `["*"]` = all skills allowed. `[]` = deny all.
78    /// Supports exact names and `*` wildcard (e.g. `"web-*"` matches `"web-search"`).
79    #[serde(default = "default_skill_allowlist")]
80    pub allowed: Vec<String>,
81}
82
83impl Default for ChannelSkillsConfig {
84    fn default() -> Self {
85        Self {
86            allowed: default_skill_allowlist(),
87        }
88    }
89}
90
91/// Returns `true` if the skill `name` matches any pattern in the allowlist.
92///
93/// Pattern rules: `"*"` matches any name; `"prefix-*"` matches names starting with `"prefix-"`;
94/// exact strings match only themselves. Matching is case-sensitive.
95#[must_use]
96pub fn is_skill_allowed(name: &str, config: &ChannelSkillsConfig) -> bool {
97    config.allowed.iter().any(|p| glob_match(p, name))
98}
99
100fn glob_match(pattern: &str, name: &str) -> bool {
101    if let Some(prefix) = pattern.strip_suffix('*') {
102        if prefix.is_empty() {
103            return true;
104        }
105        name.starts_with(prefix)
106    } else {
107        pattern == name
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    fn allow(patterns: &[&str]) -> ChannelSkillsConfig {
116        ChannelSkillsConfig {
117            allowed: patterns.iter().map(ToString::to_string).collect(),
118        }
119    }
120
121    #[test]
122    fn telegram_config_defaults() {
123        // When all new fields are absent, defaults must be applied.
124        let src = r#"token = "test_token""#;
125        let cfg: TelegramConfig = toml::from_str(src).unwrap();
126        assert!(!cfg.guest_mode);
127        assert!(!cfg.bot_to_bot);
128        assert!(cfg.allowed_bots.is_empty());
129        assert_eq!(cfg.max_bot_chain_depth, 1);
130    }
131
132    #[test]
133    fn telegram_config_explicit_values() {
134        let src = r#"
135token = "test_token"
136guest_mode = true
137bot_to_bot = true
138allowed_bots = ["@bot_a", "@bot_b"]
139max_bot_chain_depth = 5
140"#;
141        let cfg: TelegramConfig = toml::from_str(src).unwrap();
142        assert!(cfg.guest_mode);
143        assert!(cfg.bot_to_bot);
144        assert_eq!(cfg.allowed_bots, vec!["@bot_a", "@bot_b"]);
145        assert_eq!(cfg.max_bot_chain_depth, 5);
146    }
147
148    #[test]
149    fn test_default_output_schema_hint_bytes_is_1024() {
150        assert_eq!(default_output_schema_hint_bytes(), 1024);
151    }
152
153    #[test]
154    fn test_mcp_config_default_output_schema_hint_bytes_is_1024() {
155        let cfg = McpConfig::default();
156        assert_eq!(cfg.output_schema_hint_bytes, 1024);
157    }
158
159    #[test]
160    fn max_connect_attempts_default_is_3() {
161        let cfg = McpConfig::default();
162        assert_eq!(cfg.max_connect_attempts, 3);
163    }
164
165    #[test]
166    fn max_connect_attempts_accepts_valid_range() {
167        for v in [1u8, 3, 10] {
168            let src = format!("max_connect_attempts = {v}\n");
169            let cfg: McpConfig = toml::from_str(&src)
170                .unwrap_or_else(|e| panic!("max_connect_attempts = {v} should be valid, got: {e}"));
171            assert_eq!(cfg.max_connect_attempts, v);
172        }
173    }
174
175    #[test]
176    fn max_connect_attempts_rejects_zero() {
177        let src = "max_connect_attempts = 0\n";
178        let result = toml::from_str::<McpConfig>(src);
179        assert!(
180            result.is_err(),
181            "max_connect_attempts = 0 should be rejected"
182        );
183        let msg = result.unwrap_err().to_string();
184        assert!(
185            msg.contains("max_connect_attempts"),
186            "error message should mention the field name, got: {msg}"
187        );
188    }
189
190    #[test]
191    fn max_connect_attempts_rejects_eleven() {
192        let src = "max_connect_attempts = 11\n";
193        let result = toml::from_str::<McpConfig>(src);
194        assert!(
195            result.is_err(),
196            "max_connect_attempts = 11 should be rejected"
197        );
198    }
199
200    #[test]
201    fn startup_retry_backoff_ms_default_is_1000() {
202        let cfg = McpConfig::default();
203        assert_eq!(cfg.startup_retry_backoff_ms, 1000);
204    }
205
206    #[test]
207    fn startup_retry_backoff_ms_deserializes_from_toml() {
208        let src = "startup_retry_backoff_ms = 500\n";
209        let cfg: McpConfig = toml::from_str(src).expect("valid toml");
210        assert_eq!(cfg.startup_retry_backoff_ms, 500);
211    }
212
213    #[test]
214    fn tool_timeout_secs_default_is_none() {
215        let cfg = McpConfig::default();
216        assert!(cfg.tool_timeout_secs.is_none());
217    }
218
219    #[test]
220    fn tool_timeout_secs_deserializes_from_toml() {
221        let src = "tool_timeout_secs = 120\n";
222        let cfg: McpConfig = toml::from_str(src).expect("valid toml");
223        assert_eq!(cfg.tool_timeout_secs, Some(120));
224    }
225
226    #[test]
227    fn tool_timeout_secs_rejects_above_3600() {
228        let src = "tool_timeout_secs = 3601\n";
229        assert!(toml::from_str::<McpConfig>(src).is_err());
230    }
231
232    #[test]
233    fn tool_timeout_secs_accepts_3600() {
234        let src = "tool_timeout_secs = 3600\n";
235        let cfg: McpConfig = toml::from_str(src).expect("valid toml");
236        assert_eq!(cfg.tool_timeout_secs, Some(3600));
237    }
238
239    #[test]
240    fn wildcard_star_allows_any_skill() {
241        let cfg = allow(&["*"]);
242        assert!(is_skill_allowed("anything", &cfg));
243        assert!(is_skill_allowed("web-search", &cfg));
244    }
245
246    #[test]
247    fn empty_allowlist_denies_all() {
248        let cfg = allow(&[]);
249        assert!(!is_skill_allowed("web-search", &cfg));
250        assert!(!is_skill_allowed("shell", &cfg));
251    }
252
253    #[test]
254    fn exact_match_allows_only_that_skill() {
255        let cfg = allow(&["web-search"]);
256        assert!(is_skill_allowed("web-search", &cfg));
257        assert!(!is_skill_allowed("shell", &cfg));
258        assert!(!is_skill_allowed("web-search-extra", &cfg));
259    }
260
261    #[test]
262    fn prefix_wildcard_allows_matching_skills() {
263        let cfg = allow(&["web-*"]);
264        assert!(is_skill_allowed("web-search", &cfg));
265        assert!(is_skill_allowed("web-fetch", &cfg));
266        assert!(!is_skill_allowed("shell", &cfg));
267        assert!(!is_skill_allowed("awesome-web-thing", &cfg));
268    }
269
270    #[test]
271    fn multiple_patterns_or_logic() {
272        let cfg = allow(&["shell", "web-*"]);
273        assert!(is_skill_allowed("shell", &cfg));
274        assert!(is_skill_allowed("web-search", &cfg));
275        assert!(!is_skill_allowed("memory", &cfg));
276    }
277
278    #[test]
279    fn default_config_allows_all() {
280        let cfg = ChannelSkillsConfig::default();
281        assert!(is_skill_allowed("any-skill", &cfg));
282    }
283
284    #[test]
285    fn prefix_wildcard_does_not_match_empty_suffix() {
286        let cfg = allow(&["web-*"]);
287        // "web-" itself — prefix is "web-", remainder after stripping is "", which is the name
288        // glob_match("web-*", "web-") → prefix="web-", name.starts_with("web-") is true, len > prefix
289        // but name == "web-" means remainder is "", so starts_with returns true, let's verify:
290        assert!(is_skill_allowed("web-", &cfg));
291    }
292
293    #[test]
294    fn matching_is_case_sensitive() {
295        let cfg = allow(&["Web-Search"]);
296        assert!(!is_skill_allowed("web-search", &cfg));
297        assert!(is_skill_allowed("Web-Search", &cfg));
298    }
299
300    #[test]
301    fn a2a_client_config_defaults_are_hardened() {
302        let cfg = A2aClientConfig::default();
303        assert!(cfg.require_tls);
304        assert!(cfg.ssrf_protection);
305    }
306
307    #[test]
308    fn a2a_client_config_missing_toml_section_uses_defaults() {
309        // Absent `[a2a_client]` in an existing/fresh config.toml must deserialize to the
310        // hardened defaults, not fail — this is what makes the fix transparent for old configs.
311        let cfg: A2aClientConfig = toml::from_str("").unwrap();
312        assert_eq!(cfg, A2aClientConfig::default());
313    }
314
315    #[test]
316    fn a2a_client_config_partial_toml_fills_missing_field_from_default() {
317        let cfg: A2aClientConfig = toml::from_str("require_tls = false\n").unwrap();
318        assert!(!cfg.require_tls);
319        assert!(cfg.ssrf_protection);
320    }
321
322    #[test]
323    fn a2a_client_config_card_trust_policy_defaults_to_ignore() {
324        let cfg = A2aClientConfig::default();
325        assert_eq!(cfg.card_trust_policy, CardTrustPolicy::Ignore);
326        assert!(cfg.trusted_agent_keys.is_empty());
327    }
328
329    #[test]
330    fn card_trust_policy_serde_lowercase() {
331        assert_eq!(
332            serde_json::to_string(&CardTrustPolicy::Ignore).unwrap(),
333            r#""ignore""#
334        );
335        assert_eq!(
336            serde_json::to_string(&CardTrustPolicy::Prefer).unwrap(),
337            r#""prefer""#
338        );
339        assert_eq!(
340            serde_json::to_string(&CardTrustPolicy::Require).unwrap(),
341            r#""require""#
342        );
343    }
344
345    #[test]
346    fn a2a_client_config_trusted_agent_keys_round_trip() {
347        let toml_src = r#"
348            card_trust_policy = "require"
349
350            [[trusted_agent_keys]]
351            kid = "key-1"
352            alg = "ES256"
353            jwk_or_pem = "-----BEGIN PUBLIC KEY-----\nMFk...\n-----END PUBLIC KEY-----"
354        "#;
355        let cfg: A2aClientConfig = toml::from_str(toml_src).unwrap();
356        assert_eq!(cfg.card_trust_policy, CardTrustPolicy::Require);
357        assert_eq!(cfg.trusted_agent_keys.len(), 1);
358        assert_eq!(cfg.trusted_agent_keys[0].kid, "key-1");
359        assert_eq!(cfg.trusted_agent_keys[0].alg, "ES256");
360    }
361
362    #[test]
363    fn ibct_key_config_debug_redacts_key_hex() {
364        let key = IbctKeyConfig {
365            key_id: "primary".into(),
366            key_hex: "deadbeefdeadbeefdeadbeefdeadbeef".into(),
367        };
368        let debug = format!("{key:?}");
369        assert!(!debug.contains("deadbeefdeadbeefdeadbeefdeadbeef"));
370        assert!(debug.contains("primary"));
371        assert!(debug.contains("REDACTED"));
372    }
373
374    #[test]
375    fn ibct_key_config_serialize_redacts_key_hex() {
376        let key = IbctKeyConfig {
377            key_id: "primary".into(),
378            key_hex: "deadbeefdeadbeefdeadbeefdeadbeef".into(),
379        };
380        let json = serde_json::to_string(&key).unwrap();
381        assert!(!json.contains("deadbeefdeadbeefdeadbeefdeadbeef"));
382        assert!(json.contains("primary"));
383        assert!(json.contains("REDACTED"));
384    }
385
386    #[test]
387    fn telegram_config_serialize_omits_token() {
388        let cfg = TelegramConfig {
389            token: Some("real-secret-value".into()),
390            allowed_users: Vec::new(),
391            skills: ChannelSkillsConfig::default(),
392            allowed_tools: None,
393            stream_interval_ms: default_stream_interval_ms(),
394            guest_mode: false,
395            bot_to_bot: false,
396            allowed_bots: Vec::new(),
397            max_bot_chain_depth: default_max_bot_chain_depth(),
398        };
399        let json = serde_json::to_string(&cfg).unwrap();
400        assert!(!json.contains("real-secret-value"));
401        assert!(!json.contains("\"token\""));
402    }
403
404    #[test]
405    fn discord_config_serialize_omits_token_but_keeps_application_id() {
406        let cfg = DiscordConfig {
407            token: Some("real-secret-value".into()),
408            application_id: Some("123456789".into()),
409            allowed_user_ids: Vec::new(),
410            allowed_role_ids: Vec::new(),
411            allowed_channel_ids: Vec::new(),
412            skills: ChannelSkillsConfig::default(),
413            allowed_tools: None,
414        };
415        let json = serde_json::to_string(&cfg).unwrap();
416        assert!(!json.contains("real-secret-value"));
417        assert!(!json.contains("\"token\""));
418        // application_id is a public snowflake, not a secret — it must still serialize.
419        assert!(json.contains("123456789"));
420    }
421
422    #[test]
423    fn slack_config_serialize_omits_bot_token_and_signing_secret() {
424        let cfg = SlackConfig {
425            bot_token: Some("real-secret-value".into()),
426            signing_secret: Some("another-real-secret".into()),
427            webhook_host: default_slack_webhook_host(),
428            port: default_slack_port(),
429            allowed_user_ids: Vec::new(),
430            allowed_channel_ids: Vec::new(),
431            skills: ChannelSkillsConfig::default(),
432            allowed_tools: None,
433        };
434        let json = serde_json::to_string(&cfg).unwrap();
435        assert!(!json.contains("real-secret-value"));
436        assert!(!json.contains("another-real-secret"));
437        assert!(!json.contains("\"bot_token\""));
438        assert!(!json.contains("\"signing_secret\""));
439    }
440
441    #[test]
442    fn a2a_server_config_toml_round_trip_keeps_auth_token_plaintext() {
443        // Guards against a future regression that redacts this Group-C field: `--init`
444        // persists the raw auth_token to config.toml today, so redacting it here would
445        // corrupt the config on reload.
446        let cfg = A2aServerConfig {
447            auth_token: Some("real-auth-token-value".into()),
448            ..A2aServerConfig::default()
449        };
450        let toml_str = toml::to_string(&cfg).unwrap();
451        assert!(toml_str.contains("real-auth-token-value"));
452    }
453
454    #[test]
455    fn group_a_configs_deserialize_missing_secret_field_as_none() {
456        // `#[serde(skip_serializing)]` only affects the output side; serde's built-in
457        // Option-defaulting already tolerates the key being absent on the input side. This
458        // pins that `skip_serializing` cannot break loading a config that never had the key
459        // (e.g. one written before this fix, or hand-edited without it).
460        let telegram: TelegramConfig = toml::from_str("").unwrap();
461        assert!(telegram.token.is_none());
462
463        let discord: DiscordConfig = toml::from_str("").unwrap();
464        assert!(discord.token.is_none());
465
466        let slack: SlackConfig = toml::from_str("").unwrap();
467        assert!(slack.bot_token.is_none());
468        assert!(slack.signing_secret.is_none());
469    }
470}
471
472fn default_slack_port() -> u16 {
473    3000
474}
475
476fn default_slack_webhook_host() -> String {
477    "127.0.0.1".into()
478}
479
480fn default_a2a_host() -> String {
481    "0.0.0.0".into()
482}
483
484fn default_a2a_port() -> u16 {
485    8080
486}
487
488fn default_a2a_rate_limit() -> u32 {
489    60
490}
491
492fn default_a2a_max_body() -> usize {
493    1_048_576
494}
495
496fn default_drain_timeout_ms() -> u64 {
497    30_000
498}
499
500fn default_max_dynamic_servers() -> usize {
501    10
502}
503
504fn default_mcp_timeout() -> u64 {
505    30
506}
507
508fn default_startup_retry_backoff_ms() -> u64 {
509    1000
510}
511
512fn default_tool_timeout_secs() -> Option<u64> {
513    None
514}
515
516fn default_oauth_callback_port() -> u16 {
517    18766
518}
519
520fn default_oauth_client_name() -> String {
521    "Zeph".into()
522}
523
524fn default_stream_interval_ms() -> u64 {
525    3000
526}
527
528fn default_max_bot_chain_depth() -> u32 {
529    1
530}
531
532/// Telegram channel configuration, nested under `[telegram]` in TOML.
533///
534/// When present, Zeph connects to Telegram as a bot using the provided token.
535/// The token must be resolved from the vault at runtime via `ZEPH_TELEGRAM_TOKEN`.
536///
537/// # Example (TOML)
538///
539/// ```toml
540/// [telegram]
541/// allowed_users = ["myusername"]
542/// stream_interval_ms = 3000
543/// guest_mode = true
544/// bot_to_bot = true
545/// allowed_bots = ["@my_bot"]
546/// max_bot_chain_depth = 1
547/// ```
548#[derive(Clone, Deserialize, Serialize)]
549pub struct TelegramConfig {
550    /// Bot token. Set to `None` and resolve from vault via `ZEPH_TELEGRAM_TOKEN`.
551    ///
552    /// # Security
553    ///
554    /// Never serialized: `--init` always persists this field as `None` (the real token
555    /// goes to the vault), but runtime config resolution hydrates the real value into this
556    /// field in memory. `#[serde(skip_serializing)]` keeps any future diagnostic `Serialize`
557    /// of a live `Config` from leaking it; `Deserialize` is untouched so inline tokens in a
558    /// hand-edited `config.toml` still load.
559    #[serde(skip_serializing)]
560    pub token: Option<String>,
561    /// Telegram usernames allowed to interact with the bot (empty = allow all).
562    #[serde(default)]
563    pub allowed_users: Vec<String>,
564    /// Skill allowlist for this channel.
565    #[serde(default)]
566    pub skills: ChannelSkillsConfig,
567    /// Tool allowlist for this channel. `None` means all tools are permitted.
568    /// `Some(vec![])` denies all tools. `Some(vec!["shell"])` allows only listed tools.
569    #[serde(default)]
570    pub allowed_tools: Option<Vec<String>>,
571    /// Minimum interval in milliseconds between streaming message edits.
572    ///
573    /// Defaults to 3000 ms (3 seconds) to stay within Telegram's rate limits.
574    /// Values below 500 ms are clamped to 500 ms with a warning; the Telegram
575    /// Bot API enforces a hard limit of ~30 edits/second per chat.
576    #[serde(default = "default_stream_interval_ms")]
577    pub stream_interval_ms: u64,
578    /// Enable responding to @mentions in any chat (Bot API 10.0 Guest Mode).
579    ///
580    /// When `false` (default), `guest_message` updates are ignored.
581    #[serde(default)]
582    pub guest_mode: bool,
583    /// Enable receiving messages from other bots (Bot API 10.0).
584    ///
585    /// When `false` (default), messages where `from.is_bot = true` are silently dropped.
586    #[serde(default)]
587    pub bot_to_bot: bool,
588    /// Bot usernames allowed to interact when `bot_to_bot = true`.
589    ///
590    /// Empty list (default) allows all bots. Include the `@` prefix (e.g. `"@my_bot"`).
591    #[serde(default)]
592    pub allowed_bots: Vec<String>,
593    /// Maximum reply chain depth before Zeph stops responding to bot messages.
594    ///
595    /// Prevents infinite loops between bots. Checked against both the structural
596    /// `reply_to_message` depth (spec FR-007) and the consecutive-reply counter
597    /// for the same chat. Default: 1.
598    ///
599    /// Note: Telegram API payloads only expose one level of `reply_to_message`
600    /// nesting, so values greater than 1 have no additional effect on structural
601    /// depth alone. The consecutive-reply counter provides secondary loop
602    /// prevention across multiple top-level exchanges.
603    #[serde(default = "default_max_bot_chain_depth")]
604    pub max_bot_chain_depth: u32,
605}
606
607impl std::fmt::Debug for TelegramConfig {
608    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
609        f.debug_struct("TelegramConfig")
610            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
611            .field("allowed_users", &self.allowed_users)
612            .field("skills", &self.skills)
613            .field("allowed_tools", &self.allowed_tools)
614            .field("stream_interval_ms", &self.stream_interval_ms)
615            .field("guest_mode", &self.guest_mode)
616            .field("bot_to_bot", &self.bot_to_bot)
617            .field("allowed_bots_count", &self.allowed_bots.len())
618            .field("max_bot_chain_depth", &self.max_bot_chain_depth)
619            .finish()
620    }
621}
622
623#[derive(Clone, Deserialize, Serialize)]
624pub struct DiscordConfig {
625    /// Bot token. Set to `None` and resolve from vault via `ZEPH_DISCORD_TOKEN`.
626    ///
627    /// # Security
628    ///
629    /// Never serialized: `--init` always persists this field as `None` (the real token
630    /// goes to the vault), but runtime config resolution hydrates the real value into this
631    /// field in memory. `#[serde(skip_serializing)]` keeps any future diagnostic `Serialize`
632    /// of a live `Config` from leaking it; `Deserialize` is untouched so inline tokens in a
633    /// hand-edited `config.toml` still load.
634    #[serde(skip_serializing)]
635    pub token: Option<String>,
636    /// Public Discord application snowflake — not a secret, safe to serialize.
637    pub application_id: Option<String>,
638    #[serde(default)]
639    pub allowed_user_ids: Vec<String>,
640    #[serde(default)]
641    pub allowed_role_ids: Vec<String>,
642    #[serde(default)]
643    pub allowed_channel_ids: Vec<String>,
644    #[serde(default)]
645    pub skills: ChannelSkillsConfig,
646    /// Tool allowlist for this channel. `None` means all tools are permitted.
647    #[serde(default)]
648    pub allowed_tools: Option<Vec<String>>,
649}
650
651impl std::fmt::Debug for DiscordConfig {
652    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
653        f.debug_struct("DiscordConfig")
654            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
655            .field("application_id", &self.application_id)
656            .field("allowed_user_ids", &self.allowed_user_ids)
657            .field("allowed_role_ids", &self.allowed_role_ids)
658            .field("allowed_channel_ids", &self.allowed_channel_ids)
659            .field("skills", &self.skills)
660            .field("allowed_tools", &self.allowed_tools)
661            .finish()
662    }
663}
664
665#[derive(Clone, Deserialize, Serialize)]
666pub struct SlackConfig {
667    /// Bot token. Set to `None` and resolve from vault via `ZEPH_SLACK_BOT_TOKEN`.
668    ///
669    /// # Security
670    ///
671    /// Never serialized: `--init` always persists this field as `None` (the real token
672    /// goes to the vault), but runtime config resolution hydrates the real value into this
673    /// field in memory. `#[serde(skip_serializing)]` keeps any future diagnostic `Serialize`
674    /// of a live `Config` from leaking it; `Deserialize` is untouched so inline tokens in a
675    /// hand-edited `config.toml` still load.
676    #[serde(skip_serializing)]
677    pub bot_token: Option<String>,
678    /// Request signing secret. Set to `None` and resolve from vault via
679    /// `ZEPH_SLACK_SIGNING_SECRET`.
680    ///
681    /// # Security
682    ///
683    /// Never serialized — same rationale as [`bot_token`](Self::bot_token).
684    #[serde(skip_serializing)]
685    pub signing_secret: Option<String>,
686    #[serde(default = "default_slack_webhook_host")]
687    pub webhook_host: String,
688    #[serde(default = "default_slack_port")]
689    pub port: u16,
690    #[serde(default)]
691    pub allowed_user_ids: Vec<String>,
692    #[serde(default)]
693    pub allowed_channel_ids: Vec<String>,
694    #[serde(default)]
695    pub skills: ChannelSkillsConfig,
696    /// Tool allowlist for this channel. `None` means all tools are permitted.
697    #[serde(default)]
698    pub allowed_tools: Option<Vec<String>>,
699}
700
701impl std::fmt::Debug for SlackConfig {
702    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
703        f.debug_struct("SlackConfig")
704            .field("bot_token", &self.bot_token.as_ref().map(|_| "[REDACTED]"))
705            .field(
706                "signing_secret",
707                &self.signing_secret.as_ref().map(|_| "[REDACTED]"), // lgtm[rust/cleartext-logging]
708            )
709            .field("webhook_host", &self.webhook_host)
710            .field("port", &self.port)
711            .field("allowed_user_ids", &self.allowed_user_ids)
712            .field("allowed_channel_ids", &self.allowed_channel_ids)
713            .field("skills", &self.skills)
714            .field("allowed_tools", &self.allowed_tools)
715            .finish()
716    }
717}
718
719/// An IBCT signing key entry in the A2A server configuration.
720///
721/// Multiple entries allow key rotation: keep old keys until all tokens signed with them expire.
722///
723/// `Serialize` is hand-written and redacts `key_hex` to `"[REDACTED]"` (mirroring the
724/// `Debug` impl below); `Deserialize` is derived and reads the real hex key untouched, since
725/// config loading and the `--init` wizard both need the real value on the way in.
726///
727/// # Tradeoff
728///
729/// A future "load config → mutate → save TOML" flow that persists an inline
730/// `[a2a] ibct_keys[].key_hex` would round-trip through this redacting `Serialize` and write
731/// back `key_hex = "[REDACTED]"`, corrupting the key. This is acceptable today: no such flow
732/// exists, `--migrate-config` operates on the TOML text directly (never through
733/// `Config`/`Serialize`), and the documented direction is vault-resolved keys via
734/// [`A2aServerConfig::ibct_signing_key_vault_ref`](crate::channels::A2aServerConfig::ibct_signing_key_vault_ref)
735/// (which takes precedence over `ibct_keys[0]`), making inline `key_hex` a legacy path. If a
736/// struct-based config save flow is ever added, this type should graduate to a split
737/// config/diagnostic-shape design instead of redacting in place.
738#[derive(Clone, Deserialize)]
739pub struct IbctKeyConfig {
740    /// Unique key identifier. Must match the `key_id` field in issued IBCT tokens.
741    pub key_id: String,
742    /// Hex-encoded HMAC-SHA256 signing key.
743    pub key_hex: String,
744}
745
746impl std::fmt::Debug for IbctKeyConfig {
747    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
748        f.debug_struct("IbctKeyConfig")
749            .field("key_id", &self.key_id)
750            .field("key_hex", &"[REDACTED]")
751            .finish()
752    }
753}
754
755impl Serialize for IbctKeyConfig {
756    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
757        use serde::ser::SerializeStruct;
758        let mut s = serializer.serialize_struct("IbctKeyConfig", 2)?;
759        s.serialize_field("key_id", &self.key_id)?;
760        s.serialize_field("key_hex", "[REDACTED]")?;
761        s.end()
762    }
763}
764
765fn default_ibct_ttl() -> u64 {
766    300
767}
768
769fn default_a2a_request_timeout_ms() -> u64 {
770    300_000
771}
772
773fn default_task_ttl_secs() -> u64 {
774    3600
775}
776
777/// A2A server configuration, nested under `[a2a]` in TOML.
778///
779/// Controls the Agent-to-Agent HTTP server that exposes the agent via the A2A protocol.
780/// The `AgentCard` served at `/.well-known/agent.json` is built from these settings combined
781/// with runtime-detected capabilities (`images`, `audio`) and the opt-in `advertise_files` flag.
782#[derive(Deserialize, Serialize)]
783#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic here
784pub struct A2aServerConfig {
785    #[serde(default)]
786    pub enabled: bool,
787    #[serde(default = "default_a2a_host")]
788    pub host: String,
789    #[serde(default = "default_a2a_port")]
790    pub port: u16,
791    #[serde(default)]
792    pub public_url: String,
793    /// Bearer token required on inbound A2A requests. `None` disables auth.
794    ///
795    /// # Security
796    ///
797    /// Intentionally **not** redacted in `Serialize`: unlike the channel tokens above, the
798    /// `--init` wizard writes the raw value straight into `config.toml` (there is no vault
799    /// indirection for this field yet), so a redacting `Serialize` would corrupt the
800    /// persisted config on the next `--init`/save round-trip. The redacting `Debug` impl on
801    /// this struct is the approved representation for any log/dump/status output — never emit
802    /// this field's value via `Serialize` or any other non-`Debug` representation.
803    #[serde(default)]
804    pub auth_token: Option<String>,
805    #[serde(default = "default_a2a_rate_limit")]
806    pub rate_limit: u32,
807    #[serde(default = "default_a2a_max_body")]
808    pub max_body_size: usize,
809    #[serde(default = "default_drain_timeout_ms")]
810    pub drain_timeout_ms: u64,
811    /// When `true`, all requests are rejected with 401 if no `auth_token` is configured.
812    /// Default `false` for backward compatibility — existing deployments without a token
813    /// continue to operate. Set to `true` in production when authentication is mandatory.
814    #[serde(default)]
815    pub require_auth: bool,
816    /// IBCT signing keys for per-task delegation scoping.
817    ///
818    /// When non-empty, all requests to `/a2a` and `/a2a/stream` must include a valid
819    /// `X-Zeph-IBCT` header signed with one of these keys, scoped to this server's own
820    /// advertised endpoint (`AgentCard::url`, i.e. `public_url` above) and to the request's
821    /// `task_id` (`params.id` for `tasks/get`/`tasks/cancel`, `params.message.taskId` for
822    /// `message/send`/`message/stream` — the empty-string sentinel for a brand-new task with
823    /// no server-assigned ID yet). A missing/undecodable header is rejected with `401`; a
824    /// present-but-invalid one (bad signature, expired, unknown key, or scope mismatch) with
825    /// `403`. Multiple keys allow key rotation without downtime — see [`IbctKeyConfig`].
826    /// Enforced by `zeph_a2a::server::router::ibct_middleware`, wired via
827    /// `A2aServer::with_ibct_keys`.
828    ///
829    /// **Before enabling in production**: as of #6260, no caller in this repository attaches
830    /// `X-Zeph-IBCT` yet (the `--connect` remote-TUI client does not opt in, and no A2A
831    /// delegation client exists). Setting this to a non-empty list will `401` `--connect` and
832    /// any standard A2A peer, without protecting a delegated-subagent flow that doesn't yet
833    /// exist — see `specs/010-security/spec.md`'s IBCT "Deployment status" note.
834    #[serde(default)]
835    pub ibct_keys: Vec<IbctKeyConfig>,
836    /// Vault key name to resolve the primary IBCT signing key at startup (MF-3 fix).
837    ///
838    /// When set, the vault key is resolved at startup and used to construct an
839    /// `IbctKey` with `key_id = "primary"`. Takes precedence over `ibct_keys[0]` if both
840    /// are set.  Example: `"ZEPH_A2A_IBCT_KEY"`.
841    #[serde(default)]
842    pub ibct_signing_key_vault_ref: Option<String>,
843    /// TTL (seconds) for issued IBCT tokens. Default: 300 (5 minutes).
844    #[serde(default = "default_ibct_ttl")]
845    pub ibct_ttl_secs: u64,
846    /// Advertise non-media file attachment capability on the `AgentCard`.
847    ///
848    /// When `true`, the served `/.well-known/agent.json` sets `capabilities.files = true`,
849    /// signalling to peer agents that this agent can receive `Part::File` entries that are
850    /// not image or audio (e.g., documents, archives).
851    ///
852    /// Default `false` because generic file attachments have no built-in ingestion path in
853    /// the current agent loop. Set to `true` only when the deployed agent has skills or MCP
854    /// tools that can consume file parts; otherwise the card would advertise a capability
855    /// the agent silently drops.
856    ///
857    /// Note: `images` and `audio` capability flags are auto-detected from the active LLM
858    /// provider and STT configuration — no manual override is needed for those.
859    #[serde(default)]
860    pub advertise_files: bool,
861    /// Request processing timeout in milliseconds.
862    ///
863    /// Applies to both `message/send` and `tasks/stream` handlers.
864    /// On timeout the task is set to `Failed` and the HTTP connection is closed.
865    /// Defaults to 300 000 ms (5 minutes).
866    #[serde(default = "default_a2a_request_timeout_ms")]
867    pub request_timeout_ms: u64,
868    /// TTL (seconds) for completed, failed, canceled, or rejected tasks in the in-memory store.
869    ///
870    /// Tasks that have reached a terminal state and whose age exceeds this value are evicted
871    /// from memory by a background loop running every 60 seconds. Non-terminal tasks (submitted,
872    /// working) are never evicted. Default: 3600 (1 hour).
873    ///
874    /// Set to `0` to disable eviction entirely. In that case the task store grows without bound
875    /// and the operator is responsible for managing memory (e.g., via process restart).
876    #[serde(default = "default_task_ttl_secs")]
877    pub task_ttl_secs: u64,
878}
879
880impl std::fmt::Debug for A2aServerConfig {
881    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
882        f.debug_struct("A2aServerConfig")
883            .field("enabled", &self.enabled)
884            .field("host", &self.host)
885            .field("port", &self.port)
886            .field("public_url", &self.public_url)
887            .field(
888                "auth_token",
889                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
890            )
891            .field("rate_limit", &self.rate_limit)
892            .field("max_body_size", &self.max_body_size)
893            .field("drain_timeout_ms", &self.drain_timeout_ms)
894            .field("require_auth", &self.require_auth)
895            .field("ibct_keys_count", &self.ibct_keys.len())
896            .field(
897                "ibct_signing_key_vault_ref",
898                &self.ibct_signing_key_vault_ref,
899            )
900            .field("ibct_ttl_secs", &self.ibct_ttl_secs)
901            .field("advertise_files", &self.advertise_files)
902            .field("request_timeout_ms", &self.request_timeout_ms)
903            .field("task_ttl_secs", &self.task_ttl_secs)
904            .finish()
905    }
906}
907
908impl Default for A2aServerConfig {
909    fn default() -> Self {
910        Self {
911            enabled: false,
912            host: default_a2a_host(),
913            port: default_a2a_port(),
914            public_url: String::new(),
915            auth_token: None,
916            rate_limit: default_a2a_rate_limit(),
917            max_body_size: default_a2a_max_body(),
918            drain_timeout_ms: default_drain_timeout_ms(),
919            require_auth: false,
920            ibct_keys: Vec::new(),
921            ibct_signing_key_vault_ref: None,
922            ibct_ttl_secs: default_ibct_ttl(),
923            advertise_files: false,
924            request_timeout_ms: default_a2a_request_timeout_ms(),
925            task_ttl_secs: default_task_ttl_secs(),
926        }
927    }
928}
929
930/// Client-side security policy for outbound A2A connections made by `zeph --connect <URL>`
931/// (the remote-TUI-over-A2A-SSE attach feature), nested under `[a2a_client]` in TOML.
932///
933/// Deliberately separate from [`A2aServerConfig`]'s `[a2a]` section: the two configure
934/// different roles (this process attaching to a *remote* daemon vs. this process's *own*
935/// A2A server accepting inbound connections) and must not share one config subtree — a
936/// default/fresh config previously made every `--connect http://...` attempt fail with
937/// "TLS required", even against `127.0.0.1` loopback, because `[a2a]`'s server-oriented
938/// `require_tls = true` default was being reused for the client path (#5878).
939///
940/// Loopback targets (`127.0.0.1`, `::1`, `localhost` — see
941/// [`is_loopback_host`](zeph_common::net::is_loopback_host)) are always permitted over
942/// plain HTTP with SSRF protection skipped, regardless of these settings: connecting to
943/// your own local daemon is definitionally not an SSRF risk, and the CLI's documented
944/// `--connect http://127.0.0.1:8080/a2a/stream` usage example must work out of the box.
945/// Non-loopback targets are governed by `require_tls`/`ssrf_protection` below, which
946/// default to the same hardened posture as the server config.
947#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
948#[serde(default)]
949pub struct A2aClientConfig {
950    /// Reject non-loopback endpoints that do not start with `https://`. Default: `true`.
951    pub require_tls: bool,
952    /// Resolve non-loopback endpoint hostnames via DNS and reject private/link-local
953    /// ranges. Default: `true`.
954    pub ssrf_protection: bool,
955    /// Trust policy applied to peer [`AgentCard`](https://docs.rs/zeph-a2a) signatures and
956    /// URL-origin consistency during discovery (A2A 1.0.0 §8.4, #5928). Default: `ignore`
957    /// — byte-identical to pre-#5928 discovery behavior. See
958    /// [`CardTrustPolicy`] doc comments for the `prefer`/`require` semantics, and
959    /// [`Config::validate`](crate::root::Config::validate) for the `require`-without-the-
960    /// `card-signing`-feature fail-fast check.
961    pub card_trust_policy: CardTrustPolicy,
962    /// Public keys trusted to sign peer `AgentCard`s, keyed by `kid`. Empty by default —
963    /// `prefer`/`require` with no entries treats every peer as unverifiable (see
964    /// `SignatureVerification::Unverifiable` in `zeph-a2a`).
965    ///
966    /// These are public verification keys, not secrets, so (unlike
967    /// [`A2aServerConfig::ibct_signing_key_vault_ref`]) they are stored inline rather than
968    /// via a vault reference.
969    pub trusted_agent_keys: Vec<TrustedAgentKey>,
970}
971
972impl Default for A2aClientConfig {
973    fn default() -> Self {
974        Self {
975            require_tls: true,
976            ssrf_protection: true,
977            card_trust_policy: CardTrustPolicy::default(),
978            trusted_agent_keys: Vec::new(),
979        }
980    }
981}
982
983/// Trust policy for peer `AgentCard` signature + URL-origin verification during A2A
984/// discovery (A2A 1.0.0 §8.4, #5928).
985///
986/// Mirrors `zeph_a2a::discovery::CardTrustPolicy` (protocol-crate-facing) as an
987/// independent type — `zeph-config` must not depend on protocol crates, the same reason
988/// [`McpTrustLevel`] has no `zeph-mcp` counterpart dependency. Conversion happens in the
989/// top-level `zeph` binary crate (`src/tui_remote.rs::convert_card_trust_policy`), which
990/// constructs the `AgentRegistry` used before `zeph --connect <URL>` establishes an A2A
991/// session (#6200).
992#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
993#[serde(rename_all = "lowercase")]
994#[non_exhaustive]
995pub enum CardTrustPolicy {
996    /// Discover peer cards without checking signatures or URL origin. Default —
997    /// byte-identical to pre-#5928 behavior.
998    #[default]
999    Ignore,
1000    /// Log a warning on an untrusted/unverifiable card or URL-origin mismatch, but still
1001    /// accept it; reject only an actively tampered signature. Recommended production
1002    /// setting once real-peer interop is proven (see `zeph-a2a::card_signing` module docs).
1003    Prefer,
1004    /// Reject any card with an unverifiable signature or a URL-origin mismatch.
1005    ///
1006    /// Requires the `card-signing` feature to be compiled in — [`Config::validate`]
1007    /// rejects this setting at config-load time otherwise, rather than allowing it to
1008    /// silently degrade or brick discovery at runtime.
1009    ///
1010    /// [`Config::validate`]: crate::root::Config::validate
1011    Require,
1012}
1013
1014/// A single trusted public key for verifying peer `AgentCard` signatures (#5928).
1015///
1016/// Public verification key material — not secret, so stored inline in config rather than
1017/// resolved via a vault reference (contrast IBCT's `ibct_signing_key_vault_ref`, which
1018/// protects a symmetric HMAC secret).
1019#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
1020pub struct TrustedAgentKey {
1021    /// Key identifier, matched against the `kid` in a signature's protected header.
1022    pub kid: String,
1023    /// Signature algorithm this key is trusted to verify (e.g. `"ES256"`).
1024    pub alg: String,
1025    /// JWK JSON object or PEM-encoded `SubjectPublicKeyInfo` public key material.
1026    pub jwk_or_pem: String,
1027}
1028
1029/// Dynamic MCP tool context pruning configuration (#2204).
1030///
1031/// When enabled, an LLM call evaluates which MCP tools are relevant to the current task
1032/// before sending tool schemas to the main LLM, reducing context usage and improving
1033/// tool selection accuracy for servers with many tools.
1034#[derive(Debug, Clone, Deserialize, Serialize)]
1035#[serde(default)]
1036pub struct ToolPruningConfig {
1037    /// Enable dynamic tool pruning. Default: `false` (opt-in).
1038    pub enabled: bool,
1039    /// Maximum number of MCP tools to include after pruning.
1040    pub max_tools: usize,
1041    /// Provider name from `[[llm.providers]]` for the pruning LLM call.
1042    /// Should be a fast/cheap model. Empty string = use the default provider.
1043    pub pruning_provider: ProviderName,
1044    /// Minimum number of MCP tools below which pruning is skipped.
1045    pub min_tools_to_prune: usize,
1046    /// Tool names that are never pruned (always included in the result).
1047    pub always_include: Vec<String>,
1048}
1049
1050impl Default for ToolPruningConfig {
1051    fn default() -> Self {
1052        Self {
1053            enabled: false,
1054            max_tools: 15,
1055            pruning_provider: ProviderName::default(),
1056            min_tools_to_prune: 10,
1057            always_include: Vec::new(),
1058        }
1059    }
1060}
1061
1062/// MCP tool discovery strategy (config-side representation).
1063///
1064/// Converted to `zeph_mcp::ToolDiscoveryStrategy` in `zeph-core` to avoid a
1065/// circular crate dependency (`zeph-config` → `zeph-mcp`).
1066#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
1067#[serde(rename_all = "lowercase")]
1068#[non_exhaustive]
1069pub enum ToolDiscoveryStrategyConfig {
1070    /// Embedding-based cosine similarity retrieval.  Fast, no LLM call per turn.
1071    Embedding,
1072    /// LLM-based pruning via `prune_tools_cached`.  Existing behavior.
1073    Llm,
1074    /// No filtering — all tools are passed through.  This is the default.
1075    #[default]
1076    None,
1077}
1078
1079/// MCP tool discovery configuration (#2321).
1080///
1081/// Nested under `[mcp.tool_discovery]`.  When `strategy = "embedding"`, the
1082/// `mcp.pruning` section is ignored for this session — the embedding path
1083/// supersedes LLM pruning entirely.
1084#[derive(Debug, Clone, Deserialize, Serialize)]
1085#[serde(default)]
1086pub struct ToolDiscoveryConfig {
1087    /// Discovery strategy.  Default: `none` (all tools, safe default).
1088    pub strategy: ToolDiscoveryStrategyConfig,
1089    /// Number of top-scoring tools to include per turn (embedding strategy only).
1090    pub top_k: usize,
1091    /// Minimum cosine similarity for a tool to be included (embedding strategy only).
1092    pub min_similarity: f32,
1093    /// Provider name from `[[llm.providers]]` for embedding computation.
1094    /// Should reference a fast/cheap embedding model.  Empty = use the agent's
1095    /// default embedding provider.
1096    pub embedding_provider: ProviderName,
1097    /// Tool names always included regardless of similarity score.
1098    pub always_include: Vec<String>,
1099    /// Minimum tool count below which discovery is skipped (all tools passed through).
1100    pub min_tools_to_filter: usize,
1101    /// When `true`, treat any embedding failure as a hard error instead of silently
1102    /// falling back to all tools.  Default: `false` (soft fallback).
1103    pub strict: bool,
1104}
1105
1106impl Default for ToolDiscoveryConfig {
1107    fn default() -> Self {
1108        Self {
1109            strategy: ToolDiscoveryStrategyConfig::None,
1110            top_k: 10,
1111            min_similarity: 0.2,
1112            embedding_provider: ProviderName::default(),
1113            always_include: Vec::new(),
1114            min_tools_to_filter: 10,
1115            strict: false,
1116        }
1117    }
1118}
1119
1120/// Trust calibration configuration, nested under `[mcp.trust_calibration]`.
1121#[derive(Debug, Clone, Deserialize, Serialize)]
1122#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
1123pub struct TrustCalibrationConfig {
1124    /// Enable trust calibration (default: false — opt-in).
1125    #[serde(default)]
1126    pub enabled: bool,
1127    /// Run pre-invocation probe on connect (Phase 1).
1128    #[serde(default = "default_true")]
1129    pub probe_on_connect: bool,
1130    /// Monitor invocations for trust score updates (Phase 2).
1131    #[serde(default = "default_true")]
1132    pub monitor_invocations: bool,
1133    /// Persist trust scores to `SQLite` (Phase 3).
1134    #[serde(default = "default_true")]
1135    pub persist_scores: bool,
1136    /// Per-day decay rate applied to trust scores above 0.5.
1137    #[serde(default = "default_decay_rate")]
1138    pub decay_rate_per_day: f64,
1139    /// Score penalty applied when injection is detected.
1140    #[serde(default = "default_injection_penalty")]
1141    pub injection_penalty: f64,
1142    /// Optional LLM provider for trust verification. Empty = disabled.
1143    #[serde(default)]
1144    pub verifier_provider: ProviderName,
1145}
1146
1147fn default_decay_rate() -> f64 {
1148    0.01
1149}
1150
1151fn default_injection_penalty() -> f64 {
1152    0.25
1153}
1154
1155impl Default for TrustCalibrationConfig {
1156    fn default() -> Self {
1157        Self {
1158            enabled: false,
1159            probe_on_connect: true,
1160            monitor_invocations: true,
1161            persist_scores: true,
1162            decay_rate_per_day: default_decay_rate(),
1163            injection_penalty: default_injection_penalty(),
1164            verifier_provider: ProviderName::default(),
1165        }
1166    }
1167}
1168
1169fn default_max_description_bytes() -> usize {
1170    2048
1171}
1172
1173fn default_max_instructions_bytes() -> usize {
1174    2048
1175}
1176
1177fn default_elicitation_timeout() -> u64 {
1178    120
1179}
1180
1181fn default_elicitation_queue_capacity() -> usize {
1182    16
1183}
1184
1185fn default_output_schema_hint_bytes() -> usize {
1186    1024
1187}
1188
1189fn default_max_connect_attempts() -> u8 {
1190    3
1191}
1192
1193fn validate_max_connect_attempts<'de, D>(d: D) -> Result<u8, D::Error>
1194where
1195    D: serde::Deserializer<'de>,
1196{
1197    let v = u8::deserialize(d)?;
1198    if !(1..=10).contains(&v) {
1199        return Err(serde::de::Error::custom(format!(
1200            "mcp.max_connect_attempts must be in 1..=10 (got {v})"
1201        )));
1202    }
1203    Ok(v)
1204}
1205
1206fn validate_tool_timeout_secs<'de, D>(d: D) -> Result<Option<u64>, D::Error>
1207where
1208    D: serde::Deserializer<'de>,
1209{
1210    let v = Option::<u64>::deserialize(d)?;
1211    if let Some(n) = v
1212        && n > 3600
1213    {
1214        return Err(serde::de::Error::custom(format!(
1215            "mcp.tool_timeout_secs must be \u{2264} 3600 (got {n})"
1216        )));
1217    }
1218    Ok(v)
1219}
1220
1221#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic for TOML-deserialized configuration
1222#[derive(Debug, Clone, Deserialize, Serialize)]
1223pub struct McpConfig {
1224    #[serde(default)]
1225    pub servers: Vec<McpServerConfig>,
1226    #[serde(default)]
1227    pub allowed_commands: Vec<String>,
1228    #[serde(default = "default_max_dynamic_servers")]
1229    pub max_dynamic_servers: usize,
1230    /// Dynamic tool pruning for context optimization.
1231    #[serde(default)]
1232    pub pruning: ToolPruningConfig,
1233    /// Trust calibration settings (opt-in, disabled by default).
1234    #[serde(default)]
1235    pub trust_calibration: TrustCalibrationConfig,
1236    /// Embedding-based tool discovery (#2321).
1237    #[serde(default)]
1238    pub tool_discovery: ToolDiscoveryConfig,
1239    /// Maximum byte length for MCP tool descriptions. Truncated with "..." if exceeded. Default: 2048.
1240    #[serde(default = "default_max_description_bytes")]
1241    pub max_description_bytes: usize,
1242    /// Maximum byte length for MCP server instructions. Truncated with "..." if exceeded. Default: 2048.
1243    #[serde(default = "default_max_instructions_bytes")]
1244    pub max_instructions_bytes: usize,
1245    /// Enable MCP elicitation (servers can request user input mid-task).
1246    /// Default: false — all elicitation requests are auto-declined.
1247    /// Opt-in because it interrupts agent flow and could be abused by malicious servers.
1248    #[serde(default)]
1249    pub elicitation_enabled: bool,
1250    /// Timeout for user to respond to an elicitation request (seconds). Default: 120.
1251    #[serde(default = "default_elicitation_timeout")]
1252    pub elicitation_timeout: u64,
1253    /// Bounded channel capacity for elicitation events. Requests beyond this limit are
1254    /// auto-declined with a warning to prevent memory exhaustion from misbehaving servers.
1255    /// Default: 16.
1256    #[serde(default = "default_elicitation_queue_capacity")]
1257    pub elicitation_queue_capacity: usize,
1258    /// When true, warn the user before prompting for fields whose names match sensitive
1259    /// patterns (password, token, secret, key, credential, etc.). Default: true.
1260    #[serde(default = "default_true")]
1261    pub elicitation_warn_sensitive_fields: bool,
1262    /// Maximum number of connection attempts for each MCP server at startup.
1263    ///
1264    /// Value `1` means one attempt with no retry. Value `3` (default) means up to three
1265    /// attempts with exponential backoff: 500 ms then 1 s between attempts.
1266    ///
1267    /// For `max_connect_attempts = N`, the inter-attempt delay sequence is
1268    /// `min(500 * 2^(k-1), 8_000) ms` for k = 1..N-1, giving at most ~47 s total backoff
1269    /// at the cap of `10`. Must be in `1..=10`.
1270    ///
1271    /// Note: dynamic `add_server` calls retain single-attempt behaviour regardless of this
1272    /// setting; a follow-up issue tracks extending retry there.
1273    #[serde(
1274        default = "default_max_connect_attempts",
1275        deserialize_with = "validate_max_connect_attempts"
1276    )]
1277    pub max_connect_attempts: u8,
1278    /// Lock tool lists after initial connection for all servers.
1279    ///
1280    /// When `true`, `tools/list_changed` refresh events are rejected for servers that have
1281    /// completed their initial connection, preventing mid-session tool injection.
1282    /// Default: `false` (opt-in, backward compatible).
1283    #[serde(default)]
1284    pub lock_tool_list: bool,
1285    /// Default env isolation for all Stdio servers. Per-server `env_isolation` overrides this.
1286    ///
1287    /// When `true`, spawned processes only receive a minimal base env + their declared `env` map.
1288    /// Default: `false` (backward compatible).
1289    #[serde(default)]
1290    pub default_env_isolation: bool,
1291    /// When `true`, forward MCP tool output schemas as a hint appended to the tool description.
1292    ///
1293    /// Disabled by default to preserve Anthropic prompt-cache hit rates. Enabling this mutates
1294    /// tool descriptions, which changes the cached hash and causes a one-off cache miss after
1295    /// every MCP reconnect or server redeploy.
1296    ///
1297    /// See `output_schema_hint_bytes` for the budget controlling hint size.
1298    #[serde(default)]
1299    pub forward_output_schema: bool,
1300    /// Maximum bytes of the compact JSON appended to the tool description as the output schema
1301    /// hint when `forward_output_schema = true`. Default: 1024.
1302    ///
1303    /// If the serialized schema exceeds this budget, a stub message is used instead and a WARN
1304    /// is emitted once per session per tool.
1305    #[serde(default = "default_output_schema_hint_bytes")]
1306    pub output_schema_hint_bytes: usize,
1307    /// Base delay in milliseconds before each retry attempt at startup.
1308    ///
1309    /// The actual backoff is computed as `min(startup_retry_backoff_ms * 2^(k-1), 8_000) ms`
1310    /// where `k` is the 1-based attempt index. Default: 1000 ms.
1311    ///
1312    /// Set to a lower value for faster failover in test/development environments.
1313    #[serde(default = "default_startup_retry_backoff_ms")]
1314    pub startup_retry_backoff_ms: u64,
1315    /// Per-call timeout in seconds applied to each MCP tool invocation.
1316    ///
1317    /// This is separate from `[[mcp.servers]].timeout`, which controls the handshake and
1318    /// `tools/list` timeout. `tool_timeout_secs` applies after the connection is established,
1319    /// for each `tools/call` request.
1320    ///
1321    /// When absent (the default), the per-server `timeout` governs `tools/call` as well.
1322    /// Set to a lower value to cap runaway tools without changing the handshake timeout.
1323    /// Maximum accepted value is 3600 s; values above that are rejected at parse time.
1324    #[serde(
1325        default = "default_tool_timeout_secs",
1326        deserialize_with = "validate_tool_timeout_secs"
1327    )]
1328    pub tool_timeout_secs: Option<u64>,
1329}
1330
1331impl Default for McpConfig {
1332    fn default() -> Self {
1333        Self {
1334            servers: Vec::new(),
1335            allowed_commands: Vec::new(),
1336            max_dynamic_servers: default_max_dynamic_servers(),
1337            pruning: ToolPruningConfig::default(),
1338            trust_calibration: TrustCalibrationConfig::default(),
1339            tool_discovery: ToolDiscoveryConfig::default(),
1340            max_description_bytes: default_max_description_bytes(),
1341            max_instructions_bytes: default_max_instructions_bytes(),
1342            elicitation_enabled: false,
1343            elicitation_timeout: default_elicitation_timeout(),
1344            elicitation_queue_capacity: default_elicitation_queue_capacity(),
1345            elicitation_warn_sensitive_fields: true,
1346            lock_tool_list: false,
1347            default_env_isolation: false,
1348            forward_output_schema: false,
1349            output_schema_hint_bytes: default_output_schema_hint_bytes(),
1350            max_connect_attempts: default_max_connect_attempts(),
1351            startup_retry_backoff_ms: default_startup_retry_backoff_ms(),
1352            tool_timeout_secs: None,
1353        }
1354    }
1355}
1356
1357#[derive(Clone, Deserialize, Serialize)]
1358pub struct McpServerConfig {
1359    pub id: String,
1360    /// Stdio transport: command to spawn.
1361    pub command: Option<String>,
1362    #[serde(default)]
1363    pub args: Vec<String>,
1364    /// Environment variables for the spawned Stdio process. Values may hold vault
1365    /// references (`${VAULT_KEY}`) or, in a hand-written config, raw secrets.
1366    ///
1367    /// # Security
1368    ///
1369    /// Intentionally **not** redacted in `Serialize`: `--init` persists this map to
1370    /// `config.toml`, so a redacting `Serialize` would corrupt the round-trip. The
1371    /// redacting `Debug` impl on this struct is the approved representation for any
1372    /// log/dump/status output — never emit this field's values via `Serialize` or any other
1373    /// non-`Debug` representation.
1374    #[serde(default)]
1375    pub env: HashMap<String, String>,
1376    /// HTTP transport: remote MCP server URL.
1377    pub url: Option<String>,
1378    #[serde(default = "default_mcp_timeout")]
1379    pub timeout: u64,
1380    /// Optional declarative policy for this server (allowlist, denylist, rate limit).
1381    #[serde(default)]
1382    pub policy: McpPolicy,
1383    /// Static HTTP headers for the transport (e.g. `Authorization: Bearer <token>`).
1384    /// Values support vault references: `${VAULT_KEY}`.
1385    ///
1386    /// # Security
1387    ///
1388    /// Intentionally **not** redacted in `Serialize` — same rationale as
1389    /// [`env`](Self::env): `--init` persists this map to `config.toml`, and the redacting
1390    /// `Debug` impl is the approved representation for log/dump/status output — never emit
1391    /// this field's values via `Serialize` or any other non-`Debug` representation.
1392    #[serde(default)]
1393    pub headers: HashMap<String, String>,
1394    /// OAuth 2.1 configuration for this server.
1395    #[serde(default)]
1396    pub oauth: Option<McpOAuthConfig>,
1397    /// Trust level for this server. Default: Untrusted.
1398    #[serde(default)]
1399    pub trust_level: McpTrustLevel,
1400    /// Tool allowlist. `None` means no override (inherit defaults).
1401    /// `Some(vec![])` is an explicit empty list (deny all for Untrusted/Sandboxed).
1402    /// `Some(vec!["a", "b"])` allows only listed tools.
1403    #[serde(default)]
1404    pub tool_allowlist: Option<Vec<String>>,
1405    /// Expected tool names for attestation. Supplements `tool_allowlist`.
1406    ///
1407    /// When non-empty: tools not in this list are filtered out (Untrusted/Sandboxed)
1408    /// or warned about (Trusted). Schema drift is logged when fingerprints change
1409    /// between connections.
1410    #[serde(default)]
1411    pub expected_tools: Vec<String>,
1412    /// Filesystem roots exposed to this MCP server via `roots/list`.
1413    /// Each entry is a `{uri, name?}` pair. URI must use `file://` scheme.
1414    /// When empty, the server receives an empty roots list.
1415    #[serde(default)]
1416    pub roots: Vec<McpRootEntry>,
1417    /// Per-tool security metadata overrides. Keys are tool names.
1418    /// When absent for a tool, metadata is inferred from the tool name via heuristics.
1419    #[serde(default)]
1420    pub tool_metadata: HashMap<String, ToolSecurityMeta>,
1421    /// Per-server elicitation override. `None` = inherit global `elicitation_enabled`.
1422    /// `Some(true)` = allow this server to elicit regardless of global setting.
1423    /// `Some(false)` = always decline for this server.
1424    #[serde(default)]
1425    pub elicitation_enabled: Option<bool>,
1426    /// Isolate the environment for this Stdio server.
1427    ///
1428    /// When `true` (or when `[mcp].default_env_isolation = true`), the spawned process
1429    /// only sees a minimal base env (`PATH`, `HOME`, etc.) plus this server's `env` map.
1430    /// Overrides `[mcp].default_env_isolation` when set explicitly.
1431    /// Default: `false` (backward compatible).
1432    #[serde(default)]
1433    pub env_isolation: Option<bool>,
1434}
1435
1436/// A filesystem root exposed to an MCP server via `roots/list`.
1437#[derive(Debug, Clone, Deserialize, Serialize)]
1438pub struct McpRootEntry {
1439    /// URI of the root directory. Must use `file://` scheme.
1440    pub uri: String,
1441    /// Optional human-readable name for this root.
1442    #[serde(default)]
1443    pub name: Option<String>,
1444}
1445
1446/// OAuth 2.1 configuration for an MCP server.
1447#[derive(Debug, Clone, Deserialize, Serialize)]
1448pub struct McpOAuthConfig {
1449    /// Enable OAuth 2.1 for this server.
1450    #[serde(default)]
1451    pub enabled: bool,
1452    /// Token storage backend.
1453    #[serde(default)]
1454    pub token_storage: OAuthTokenStorage,
1455    /// OAuth scopes to request. Empty = server default.
1456    #[serde(default)]
1457    pub scopes: Vec<String>,
1458    /// Port for the local callback server. `0` = auto-assign, `18766` = default fixed port.
1459    #[serde(default = "default_oauth_callback_port")]
1460    pub callback_port: u16,
1461    /// Client name sent during dynamic registration.
1462    #[serde(default = "default_oauth_client_name")]
1463    pub client_name: String,
1464}
1465
1466impl Default for McpOAuthConfig {
1467    fn default() -> Self {
1468        Self {
1469            enabled: false,
1470            token_storage: OAuthTokenStorage::default(),
1471            scopes: Vec::new(),
1472            callback_port: default_oauth_callback_port(),
1473            client_name: default_oauth_client_name(),
1474        }
1475    }
1476}
1477
1478/// Where OAuth tokens are stored.
1479#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1480#[serde(rename_all = "lowercase")]
1481#[non_exhaustive]
1482pub enum OAuthTokenStorage {
1483    /// Persisted in the age vault (default).
1484    #[default]
1485    Vault,
1486    /// In-memory only — tokens lost on restart.
1487    Memory,
1488}
1489
1490impl std::fmt::Debug for McpServerConfig {
1491    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1492        let redacted_env: HashMap<&str, &str> = self
1493            .env
1494            .keys()
1495            .map(|k| (k.as_str(), "[REDACTED]"))
1496            .collect();
1497        // Redact header values to avoid leaking tokens in logs.
1498        let redacted_headers: HashMap<&str, &str> = self
1499            .headers
1500            .keys()
1501            .map(|k| (k.as_str(), "[REDACTED]"))
1502            .collect();
1503        f.debug_struct("McpServerConfig")
1504            .field("id", &self.id)
1505            .field("command", &self.command)
1506            .field("args", &self.args)
1507            .field("env", &redacted_env)
1508            .field("url", &self.url)
1509            .field("timeout", &self.timeout)
1510            .field("policy", &self.policy)
1511            .field("headers", &redacted_headers)
1512            .field("oauth", &self.oauth)
1513            .field("trust_level", &self.trust_level)
1514            .field("tool_allowlist", &self.tool_allowlist)
1515            .field("expected_tools", &self.expected_tools)
1516            .field("roots", &self.roots)
1517            .field(
1518                "tool_metadata_keys",
1519                &self.tool_metadata.keys().collect::<Vec<_>>(),
1520            )
1521            .field("elicitation_enabled", &self.elicitation_enabled)
1522            .field("env_isolation", &self.env_isolation)
1523            .finish()
1524    }
1525}