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 /// Global caps for MCP image passthrough (spec-072). Applies to every server with
1330 /// `media_passthrough = true`.
1331 #[serde(default)]
1332 pub media: McpMediaConfig,
1333}
1334
1335impl Default for McpConfig {
1336 fn default() -> Self {
1337 Self {
1338 servers: Vec::new(),
1339 allowed_commands: Vec::new(),
1340 max_dynamic_servers: default_max_dynamic_servers(),
1341 pruning: ToolPruningConfig::default(),
1342 trust_calibration: TrustCalibrationConfig::default(),
1343 tool_discovery: ToolDiscoveryConfig::default(),
1344 max_description_bytes: default_max_description_bytes(),
1345 max_instructions_bytes: default_max_instructions_bytes(),
1346 elicitation_enabled: false,
1347 elicitation_timeout: default_elicitation_timeout(),
1348 elicitation_queue_capacity: default_elicitation_queue_capacity(),
1349 elicitation_warn_sensitive_fields: true,
1350 lock_tool_list: false,
1351 default_env_isolation: false,
1352 forward_output_schema: false,
1353 output_schema_hint_bytes: default_output_schema_hint_bytes(),
1354 max_connect_attempts: default_max_connect_attempts(),
1355 startup_retry_backoff_ms: default_startup_retry_backoff_ms(),
1356 tool_timeout_secs: None,
1357 media: McpMediaConfig::default(),
1358 }
1359 }
1360}
1361
1362/// Global caps enforced by `MediaSanitizer` (`zeph-sanitizer`) on every MCP-sourced image,
1363/// for servers with `media_passthrough = true` (spec-072 §3.4).
1364///
1365/// Defaults are conservative starting points, tunable per deployment; a follow-up
1366/// benchmarking pass may adjust them (spec-072 §10, OQ-1).
1367#[derive(Debug, Clone, Deserialize, Serialize)]
1368#[serde(default)]
1369pub struct McpMediaConfig {
1370 /// Maximum encoded byte size of a single image, checked before any decode attempt.
1371 /// Default: 5 MiB — below the existing 20 MiB user-upload `MAX_IMAGE_BYTES`.
1372 pub max_image_bytes: usize,
1373 /// Maximum width or height in pixels, enforced on the decoded image.
1374 /// Default: 8192.
1375 pub max_dimension_px: u32,
1376 /// Maximum total pixel count (width * height), enforced on the decoded image —
1377 /// decompression-bomb defense that a byte cap alone cannot provide. Default: 64,000,000 (~64 MP).
1378 pub max_pixels: u64,
1379 /// Maximum number of images validated/attached per single tool result.
1380 /// Default: 4.
1381 pub max_images_per_result: usize,
1382 /// Maximum number of images attached per turn, aggregated across all tool calls
1383 /// in the batch. Default: 8.
1384 pub max_images_per_turn: usize,
1385 /// Allowed image formats (short names, e.g. `"png"`, `"jpeg"`, `"gif"`, `"webp"`).
1386 /// Default: all four.
1387 pub allowed_formats: Vec<String>,
1388}
1389
1390impl Default for McpMediaConfig {
1391 fn default() -> Self {
1392 Self {
1393 max_image_bytes: 5 * 1024 * 1024,
1394 max_dimension_px: 8192,
1395 max_pixels: 64_000_000,
1396 max_images_per_result: 4,
1397 max_images_per_turn: 8,
1398 allowed_formats: vec![
1399 "jpeg".to_owned(),
1400 "png".to_owned(),
1401 "gif".to_owned(),
1402 "webp".to_owned(),
1403 ],
1404 }
1405 }
1406}
1407
1408#[derive(Clone, Deserialize, Serialize)]
1409pub struct McpServerConfig {
1410 pub id: String,
1411 /// Stdio transport: command to spawn.
1412 pub command: Option<String>,
1413 #[serde(default)]
1414 pub args: Vec<String>,
1415 /// Environment variables for the spawned Stdio process. Values may hold vault
1416 /// references (`${VAULT_KEY}`) or, in a hand-written config, raw secrets.
1417 ///
1418 /// # Security
1419 ///
1420 /// Intentionally **not** redacted in `Serialize`: `--init` persists this map to
1421 /// `config.toml`, so a redacting `Serialize` would corrupt the round-trip. The
1422 /// redacting `Debug` impl on this struct is the approved representation for any
1423 /// log/dump/status output — never emit this field's values via `Serialize` or any other
1424 /// non-`Debug` representation.
1425 #[serde(default)]
1426 pub env: HashMap<String, String>,
1427 /// HTTP transport: remote MCP server URL.
1428 pub url: Option<String>,
1429 #[serde(default = "default_mcp_timeout")]
1430 pub timeout: u64,
1431 /// Optional declarative policy for this server (allowlist, denylist, rate limit).
1432 #[serde(default)]
1433 pub policy: McpPolicy,
1434 /// Static HTTP headers for the transport (e.g. `Authorization: Bearer <token>`).
1435 /// Values support vault references: `${VAULT_KEY}`.
1436 ///
1437 /// # Security
1438 ///
1439 /// Intentionally **not** redacted in `Serialize` — same rationale as
1440 /// [`env`](Self::env): `--init` persists this map to `config.toml`, and the redacting
1441 /// `Debug` impl is the approved representation for log/dump/status output — never emit
1442 /// this field's values via `Serialize` or any other non-`Debug` representation.
1443 #[serde(default)]
1444 pub headers: HashMap<String, String>,
1445 /// OAuth 2.1 configuration for this server.
1446 #[serde(default)]
1447 pub oauth: Option<McpOAuthConfig>,
1448 /// Trust level for this server. Default: Untrusted.
1449 #[serde(default)]
1450 pub trust_level: McpTrustLevel,
1451 /// Tool allowlist. `None` means no override (inherit defaults).
1452 /// `Some(vec![])` is an explicit empty list (deny all for Untrusted/Sandboxed).
1453 /// `Some(vec!["a", "b"])` allows only listed tools.
1454 #[serde(default)]
1455 pub tool_allowlist: Option<Vec<String>>,
1456 /// Expected tool names for attestation. Supplements `tool_allowlist`.
1457 ///
1458 /// When non-empty: tools not in this list are filtered out (Untrusted/Sandboxed)
1459 /// or warned about (Trusted). Schema drift is logged when fingerprints change
1460 /// between connections.
1461 #[serde(default)]
1462 pub expected_tools: Vec<String>,
1463 /// Filesystem roots exposed to this MCP server via `roots/list`.
1464 /// Each entry is a `{uri, name?}` pair. URI must use `file://` scheme.
1465 /// When empty, the server receives an empty roots list.
1466 #[serde(default)]
1467 pub roots: Vec<McpRootEntry>,
1468 /// Per-tool security metadata overrides. Keys are tool names.
1469 /// When absent for a tool, metadata is inferred from the tool name via heuristics.
1470 #[serde(default)]
1471 pub tool_metadata: HashMap<String, ToolSecurityMeta>,
1472 /// Per-server elicitation override. `None` = inherit global `elicitation_enabled`.
1473 /// `Some(true)` = allow this server to elicit regardless of global setting.
1474 /// `Some(false)` = always decline for this server.
1475 #[serde(default)]
1476 pub elicitation_enabled: Option<bool>,
1477 /// Isolate the environment for this Stdio server.
1478 ///
1479 /// When `true` (or when `[mcp].default_env_isolation = true`), the spawned process
1480 /// only sees a minimal base env (`PATH`, `HOME`, etc.) plus this server's `env` map.
1481 /// Overrides `[mcp].default_env_isolation` when set explicitly.
1482 /// Default: `false` (backward compatible).
1483 #[serde(default)]
1484 pub env_isolation: Option<bool>,
1485 /// Opt-in: decode and attach images this server returns as native `MessagePart::Image`
1486 /// siblings for vision-capable providers (spec-072). Default: `false`.
1487 ///
1488 /// Independent of [`trust_level`](Self::trust_level) but always hard-blocked when
1489 /// `trust_level == McpTrustLevel::Sandboxed`, regardless of this flag.
1490 #[serde(default)]
1491 pub media_passthrough: bool,
1492}
1493
1494/// A filesystem root exposed to an MCP server via `roots/list`.
1495#[derive(Debug, Clone, Deserialize, Serialize)]
1496pub struct McpRootEntry {
1497 /// URI of the root directory. Must use `file://` scheme.
1498 pub uri: String,
1499 /// Optional human-readable name for this root.
1500 #[serde(default)]
1501 pub name: Option<String>,
1502}
1503
1504/// OAuth 2.1 configuration for an MCP server.
1505#[derive(Debug, Clone, Deserialize, Serialize)]
1506pub struct McpOAuthConfig {
1507 /// Enable OAuth 2.1 for this server.
1508 #[serde(default)]
1509 pub enabled: bool,
1510 /// Token storage backend.
1511 #[serde(default)]
1512 pub token_storage: OAuthTokenStorage,
1513 /// OAuth scopes to request. Empty = server default.
1514 #[serde(default)]
1515 pub scopes: Vec<String>,
1516 /// Port for the local callback server. `0` = auto-assign, `18766` = default fixed port.
1517 #[serde(default = "default_oauth_callback_port")]
1518 pub callback_port: u16,
1519 /// Client name sent during dynamic registration.
1520 #[serde(default = "default_oauth_client_name")]
1521 pub client_name: String,
1522}
1523
1524impl Default for McpOAuthConfig {
1525 fn default() -> Self {
1526 Self {
1527 enabled: false,
1528 token_storage: OAuthTokenStorage::default(),
1529 scopes: Vec::new(),
1530 callback_port: default_oauth_callback_port(),
1531 client_name: default_oauth_client_name(),
1532 }
1533 }
1534}
1535
1536/// Where OAuth tokens are stored.
1537#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1538#[serde(rename_all = "lowercase")]
1539#[non_exhaustive]
1540pub enum OAuthTokenStorage {
1541 /// Persisted in the age vault (default).
1542 #[default]
1543 Vault,
1544 /// In-memory only — tokens lost on restart.
1545 Memory,
1546}
1547
1548impl std::fmt::Debug for McpServerConfig {
1549 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1550 let redacted_env: HashMap<&str, &str> = self
1551 .env
1552 .keys()
1553 .map(|k| (k.as_str(), "[REDACTED]"))
1554 .collect();
1555 // Redact header values to avoid leaking tokens in logs.
1556 let redacted_headers: HashMap<&str, &str> = self
1557 .headers
1558 .keys()
1559 .map(|k| (k.as_str(), "[REDACTED]"))
1560 .collect();
1561 f.debug_struct("McpServerConfig")
1562 .field("id", &self.id)
1563 .field("command", &self.command)
1564 .field("args", &self.args)
1565 .field("env", &redacted_env)
1566 .field("url", &self.url)
1567 .field("timeout", &self.timeout)
1568 .field("policy", &self.policy)
1569 .field("headers", &redacted_headers)
1570 .field("oauth", &self.oauth)
1571 .field("trust_level", &self.trust_level)
1572 .field("tool_allowlist", &self.tool_allowlist)
1573 .field("expected_tools", &self.expected_tools)
1574 .field("roots", &self.roots)
1575 .field(
1576 "tool_metadata_keys",
1577 &self.tool_metadata.keys().collect::<Vec<_>>(),
1578 )
1579 .field("elicitation_enabled", &self.elicitation_enabled)
1580 .field("env_isolation", &self.env_isolation)
1581 .field("media_passthrough", &self.media_passthrough)
1582 .finish()
1583 }
1584}