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