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;
9
10pub use zeph_mcp::{McpTrustLevel, tool::ToolSecurityMeta};
11
12fn default_slack_port() -> u16 {
13    3000
14}
15
16fn default_slack_webhook_host() -> String {
17    "127.0.0.1".into()
18}
19
20fn default_a2a_host() -> String {
21    "0.0.0.0".into()
22}
23
24fn default_a2a_port() -> u16 {
25    8080
26}
27
28fn default_a2a_rate_limit() -> u32 {
29    60
30}
31
32fn default_a2a_max_body() -> usize {
33    1_048_576
34}
35
36fn default_drain_timeout_ms() -> u64 {
37    30_000
38}
39
40fn default_max_dynamic_servers() -> usize {
41    10
42}
43
44fn default_mcp_timeout() -> u64 {
45    30
46}
47
48fn default_oauth_callback_port() -> u16 {
49    18766
50}
51
52fn default_oauth_client_name() -> String {
53    "Zeph".into()
54}
55
56#[derive(Clone, Deserialize, Serialize)]
57pub struct TelegramConfig {
58    pub token: Option<String>,
59    #[serde(default)]
60    pub allowed_users: Vec<String>,
61}
62
63impl std::fmt::Debug for TelegramConfig {
64    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
65        f.debug_struct("TelegramConfig")
66            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
67            .field("allowed_users", &self.allowed_users)
68            .finish()
69    }
70}
71
72#[derive(Clone, Deserialize, Serialize)]
73pub struct DiscordConfig {
74    pub token: Option<String>,
75    pub application_id: Option<String>,
76    #[serde(default)]
77    pub allowed_user_ids: Vec<String>,
78    #[serde(default)]
79    pub allowed_role_ids: Vec<String>,
80    #[serde(default)]
81    pub allowed_channel_ids: Vec<String>,
82}
83
84impl std::fmt::Debug for DiscordConfig {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("DiscordConfig")
87            .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
88            .field("application_id", &self.application_id)
89            .field("allowed_user_ids", &self.allowed_user_ids)
90            .field("allowed_role_ids", &self.allowed_role_ids)
91            .field("allowed_channel_ids", &self.allowed_channel_ids)
92            .finish()
93    }
94}
95
96#[derive(Clone, Deserialize, Serialize)]
97pub struct SlackConfig {
98    pub bot_token: Option<String>,
99    pub signing_secret: Option<String>,
100    #[serde(default = "default_slack_webhook_host")]
101    pub webhook_host: String,
102    #[serde(default = "default_slack_port")]
103    pub port: u16,
104    #[serde(default)]
105    pub allowed_user_ids: Vec<String>,
106    #[serde(default)]
107    pub allowed_channel_ids: Vec<String>,
108}
109
110impl std::fmt::Debug for SlackConfig {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        f.debug_struct("SlackConfig")
113            .field("bot_token", &self.bot_token.as_ref().map(|_| "[REDACTED]"))
114            .field(
115                "signing_secret",
116                &self.signing_secret.as_ref().map(|_| "[REDACTED]"), // lgtm[rust/cleartext-logging]
117            )
118            .field("webhook_host", &self.webhook_host)
119            .field("port", &self.port)
120            .field("allowed_user_ids", &self.allowed_user_ids)
121            .field("allowed_channel_ids", &self.allowed_channel_ids)
122            .finish()
123    }
124}
125
126/// An IBCT signing key entry in the A2A server configuration.
127///
128/// Multiple entries allow key rotation: keep old keys until all tokens signed with them expire.
129#[derive(Debug, Clone, Deserialize, Serialize)]
130pub struct IbctKeyConfig {
131    /// Unique key identifier. Must match the `key_id` field in issued IBCT tokens.
132    pub key_id: String,
133    /// Hex-encoded HMAC-SHA256 signing key.
134    pub key_hex: String,
135}
136
137fn default_ibct_ttl() -> u64 {
138    300
139}
140
141#[derive(Deserialize, Serialize)]
142#[allow(clippy::struct_excessive_bools)] // config struct — boolean flags are idiomatic here
143pub struct A2aServerConfig {
144    #[serde(default)]
145    pub enabled: bool,
146    #[serde(default = "default_a2a_host")]
147    pub host: String,
148    #[serde(default = "default_a2a_port")]
149    pub port: u16,
150    #[serde(default)]
151    pub public_url: String,
152    #[serde(default)]
153    pub auth_token: Option<String>,
154    #[serde(default = "default_a2a_rate_limit")]
155    pub rate_limit: u32,
156    #[serde(default = "default_true")]
157    pub require_tls: bool,
158    #[serde(default = "default_true")]
159    pub ssrf_protection: bool,
160    #[serde(default = "default_a2a_max_body")]
161    pub max_body_size: usize,
162    #[serde(default = "default_drain_timeout_ms")]
163    pub drain_timeout_ms: u64,
164    /// When `true`, all requests are rejected with 401 if no `auth_token` is configured.
165    /// Default `false` for backward compatibility — existing deployments without a token
166    /// continue to operate. Set to `true` in production when authentication is mandatory.
167    #[serde(default)]
168    pub require_auth: bool,
169    /// IBCT signing keys for per-task delegation scoping.
170    ///
171    /// When non-empty, all A2A task requests must include a valid `X-Zeph-IBCT` header
172    /// signed with one of these keys. Multiple keys allow key rotation without downtime.
173    #[serde(default)]
174    pub ibct_keys: Vec<IbctKeyConfig>,
175    /// Vault key name to resolve the primary IBCT signing key at startup (MF-3 fix).
176    ///
177    /// When set, the vault key is resolved at startup and used to construct an
178    /// `IbctKey` with `key_id = "primary"`. Takes precedence over `ibct_keys[0]` if both
179    /// are set.  Example: `"ZEPH_A2A_IBCT_KEY"`.
180    #[serde(default)]
181    pub ibct_signing_key_vault_ref: Option<String>,
182    /// TTL (seconds) for issued IBCT tokens. Default: 300 (5 minutes).
183    #[serde(default = "default_ibct_ttl")]
184    pub ibct_ttl_secs: u64,
185}
186
187impl std::fmt::Debug for A2aServerConfig {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        f.debug_struct("A2aServerConfig")
190            .field("enabled", &self.enabled)
191            .field("host", &self.host)
192            .field("port", &self.port)
193            .field("public_url", &self.public_url)
194            .field(
195                "auth_token",
196                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
197            )
198            .field("rate_limit", &self.rate_limit)
199            .field("require_tls", &self.require_tls)
200            .field("ssrf_protection", &self.ssrf_protection)
201            .field("max_body_size", &self.max_body_size)
202            .field("drain_timeout_ms", &self.drain_timeout_ms)
203            .field("require_auth", &self.require_auth)
204            .field("ibct_keys_count", &self.ibct_keys.len())
205            .field(
206                "ibct_signing_key_vault_ref",
207                &self.ibct_signing_key_vault_ref,
208            )
209            .field("ibct_ttl_secs", &self.ibct_ttl_secs)
210            .finish()
211    }
212}
213
214impl Default for A2aServerConfig {
215    fn default() -> Self {
216        Self {
217            enabled: false,
218            host: default_a2a_host(),
219            port: default_a2a_port(),
220            public_url: String::new(),
221            auth_token: None,
222            rate_limit: default_a2a_rate_limit(),
223            require_tls: true,
224            ssrf_protection: true,
225            max_body_size: default_a2a_max_body(),
226            drain_timeout_ms: default_drain_timeout_ms(),
227            require_auth: false,
228            ibct_keys: Vec::new(),
229            ibct_signing_key_vault_ref: None,
230            ibct_ttl_secs: default_ibct_ttl(),
231        }
232    }
233}
234
235/// Dynamic MCP tool context pruning configuration (#2204).
236///
237/// When enabled, an LLM call evaluates which MCP tools are relevant to the current task
238/// before sending tool schemas to the main LLM, reducing context usage and improving
239/// tool selection accuracy for servers with many tools.
240#[derive(Debug, Clone, Deserialize, Serialize)]
241#[serde(default)]
242pub struct ToolPruningConfig {
243    /// Enable dynamic tool pruning. Default: `false` (opt-in).
244    pub enabled: bool,
245    /// Maximum number of MCP tools to include after pruning.
246    pub max_tools: usize,
247    /// Provider name from `[[llm.providers]]` for the pruning LLM call.
248    /// Should be a fast/cheap model. Empty string = use the default provider.
249    pub pruning_provider: String,
250    /// Minimum number of MCP tools below which pruning is skipped.
251    pub min_tools_to_prune: usize,
252    /// Tool names that are never pruned (always included in the result).
253    pub always_include: Vec<String>,
254}
255
256impl Default for ToolPruningConfig {
257    fn default() -> Self {
258        Self {
259            enabled: false,
260            max_tools: 15,
261            pruning_provider: String::new(),
262            min_tools_to_prune: 10,
263            always_include: Vec::new(),
264        }
265    }
266}
267
268/// MCP tool discovery strategy (config-side representation).
269///
270/// Converted to `zeph_mcp::ToolDiscoveryStrategy` in `zeph-core` to avoid a
271/// circular crate dependency (`zeph-config` → `zeph-mcp`).
272#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
273#[serde(rename_all = "lowercase")]
274pub enum ToolDiscoveryStrategyConfig {
275    /// Embedding-based cosine similarity retrieval.  Fast, no LLM call per turn.
276    Embedding,
277    /// LLM-based pruning via `prune_tools_cached`.  Existing behavior.
278    Llm,
279    /// No filtering — all tools are passed through.  This is the default.
280    #[default]
281    None,
282}
283
284/// MCP tool discovery configuration (#2321).
285///
286/// Nested under `[mcp.tool_discovery]`.  When `strategy = "embedding"`, the
287/// `mcp.pruning` section is ignored for this session — the embedding path
288/// supersedes LLM pruning entirely.
289#[derive(Debug, Clone, Deserialize, Serialize)]
290#[serde(default)]
291pub struct ToolDiscoveryConfig {
292    /// Discovery strategy.  Default: `none` (all tools, safe default).
293    pub strategy: ToolDiscoveryStrategyConfig,
294    /// Number of top-scoring tools to include per turn (embedding strategy only).
295    pub top_k: usize,
296    /// Minimum cosine similarity for a tool to be included (embedding strategy only).
297    pub min_similarity: f32,
298    /// Provider name from `[[llm.providers]]` for embedding computation.
299    /// Should reference a fast/cheap embedding model.  Empty = use the agent's
300    /// default embedding provider.
301    pub embedding_provider: String,
302    /// Tool names always included regardless of similarity score.
303    pub always_include: Vec<String>,
304    /// Minimum tool count below which discovery is skipped (all tools passed through).
305    pub min_tools_to_filter: usize,
306    /// When `true`, treat any embedding failure as a hard error instead of silently
307    /// falling back to all tools.  Default: `false` (soft fallback).
308    pub strict: bool,
309}
310
311impl Default for ToolDiscoveryConfig {
312    fn default() -> Self {
313        Self {
314            strategy: ToolDiscoveryStrategyConfig::None,
315            top_k: 10,
316            min_similarity: 0.2,
317            embedding_provider: String::new(),
318            always_include: Vec::new(),
319            min_tools_to_filter: 10,
320            strict: false,
321        }
322    }
323}
324
325/// Trust calibration configuration, nested under `[mcp.trust_calibration]`.
326#[derive(Debug, Clone, Deserialize, Serialize)]
327#[allow(clippy::struct_excessive_bools)]
328pub struct TrustCalibrationConfig {
329    /// Enable trust calibration (default: false — opt-in).
330    #[serde(default)]
331    pub enabled: bool,
332    /// Run pre-invocation probe on connect (Phase 1).
333    #[serde(default = "default_true")]
334    pub probe_on_connect: bool,
335    /// Monitor invocations for trust score updates (Phase 2).
336    #[serde(default = "default_true")]
337    pub monitor_invocations: bool,
338    /// Persist trust scores to `SQLite` (Phase 3).
339    #[serde(default = "default_true")]
340    pub persist_scores: bool,
341    /// Per-day decay rate applied to trust scores above 0.5.
342    #[serde(default = "default_decay_rate")]
343    pub decay_rate_per_day: f64,
344    /// Score penalty applied when injection is detected.
345    #[serde(default = "default_injection_penalty")]
346    pub injection_penalty: f64,
347    /// Optional LLM provider for trust verification. Empty = disabled.
348    #[serde(default)]
349    pub verifier_provider: String,
350}
351
352fn default_decay_rate() -> f64 {
353    0.01
354}
355
356fn default_injection_penalty() -> f64 {
357    0.25
358}
359
360impl Default for TrustCalibrationConfig {
361    fn default() -> Self {
362        Self {
363            enabled: false,
364            probe_on_connect: true,
365            monitor_invocations: true,
366            persist_scores: true,
367            decay_rate_per_day: default_decay_rate(),
368            injection_penalty: default_injection_penalty(),
369            verifier_provider: String::new(),
370        }
371    }
372}
373
374fn default_max_description_bytes() -> usize {
375    2048
376}
377
378fn default_max_instructions_bytes() -> usize {
379    2048
380}
381
382fn default_elicitation_timeout() -> u64 {
383    120
384}
385
386fn default_elicitation_queue_capacity() -> usize {
387    16
388}
389
390#[allow(clippy::struct_excessive_bools)]
391#[derive(Debug, Clone, Deserialize, Serialize)]
392pub struct McpConfig {
393    #[serde(default)]
394    pub servers: Vec<McpServerConfig>,
395    #[serde(default)]
396    pub allowed_commands: Vec<String>,
397    #[serde(default = "default_max_dynamic_servers")]
398    pub max_dynamic_servers: usize,
399    /// Dynamic tool pruning for context optimization.
400    #[serde(default)]
401    pub pruning: ToolPruningConfig,
402    /// Trust calibration settings (opt-in, disabled by default).
403    #[serde(default)]
404    pub trust_calibration: TrustCalibrationConfig,
405    /// Embedding-based tool discovery (#2321).
406    #[serde(default)]
407    pub tool_discovery: ToolDiscoveryConfig,
408    /// Maximum byte length for MCP tool descriptions. Truncated with "..." if exceeded. Default: 2048.
409    #[serde(default = "default_max_description_bytes")]
410    pub max_description_bytes: usize,
411    /// Maximum byte length for MCP server instructions. Truncated with "..." if exceeded. Default: 2048.
412    #[serde(default = "default_max_instructions_bytes")]
413    pub max_instructions_bytes: usize,
414    /// Enable MCP elicitation (servers can request user input mid-task).
415    /// Default: false — all elicitation requests are auto-declined.
416    /// Opt-in because it interrupts agent flow and could be abused by malicious servers.
417    #[serde(default)]
418    pub elicitation_enabled: bool,
419    /// Timeout for user to respond to an elicitation request (seconds). Default: 120.
420    #[serde(default = "default_elicitation_timeout")]
421    pub elicitation_timeout: u64,
422    /// Bounded channel capacity for elicitation events. Requests beyond this limit are
423    /// auto-declined with a warning to prevent memory exhaustion from misbehaving servers.
424    /// Default: 16.
425    #[serde(default = "default_elicitation_queue_capacity")]
426    pub elicitation_queue_capacity: usize,
427    /// When true, warn the user before prompting for fields whose names match sensitive
428    /// patterns (password, token, secret, key, credential, etc.). Default: true.
429    #[serde(default = "default_true")]
430    pub elicitation_warn_sensitive_fields: bool,
431    /// Lock tool lists after initial connection for all servers.
432    ///
433    /// When `true`, `tools/list_changed` refresh events are rejected for servers that have
434    /// completed their initial connection, preventing mid-session tool injection.
435    /// Default: `false` (opt-in, backward compatible).
436    #[serde(default)]
437    pub lock_tool_list: bool,
438    /// Default env isolation for all Stdio servers. Per-server `env_isolation` overrides this.
439    ///
440    /// When `true`, spawned processes only receive a minimal base env + their declared `env` map.
441    /// Default: `false` (backward compatible).
442    #[serde(default)]
443    pub default_env_isolation: bool,
444}
445
446impl Default for McpConfig {
447    fn default() -> Self {
448        Self {
449            servers: Vec::new(),
450            allowed_commands: Vec::new(),
451            max_dynamic_servers: default_max_dynamic_servers(),
452            pruning: ToolPruningConfig::default(),
453            trust_calibration: TrustCalibrationConfig::default(),
454            tool_discovery: ToolDiscoveryConfig::default(),
455            max_description_bytes: default_max_description_bytes(),
456            max_instructions_bytes: default_max_instructions_bytes(),
457            elicitation_enabled: false,
458            elicitation_timeout: default_elicitation_timeout(),
459            elicitation_queue_capacity: default_elicitation_queue_capacity(),
460            elicitation_warn_sensitive_fields: true,
461            lock_tool_list: false,
462            default_env_isolation: false,
463        }
464    }
465}
466
467#[derive(Clone, Deserialize, Serialize)]
468pub struct McpServerConfig {
469    pub id: String,
470    /// Stdio transport: command to spawn.
471    pub command: Option<String>,
472    #[serde(default)]
473    pub args: Vec<String>,
474    #[serde(default)]
475    pub env: HashMap<String, String>,
476    /// HTTP transport: remote MCP server URL.
477    pub url: Option<String>,
478    #[serde(default = "default_mcp_timeout")]
479    pub timeout: u64,
480    /// Optional declarative policy for this server (allowlist, denylist, rate limit).
481    #[serde(default)]
482    pub policy: zeph_mcp::McpPolicy,
483    /// Static HTTP headers for the transport (e.g. `Authorization: Bearer <token>`).
484    /// Values support vault references: `${VAULT_KEY}`.
485    #[serde(default)]
486    pub headers: HashMap<String, String>,
487    /// OAuth 2.1 configuration for this server.
488    #[serde(default)]
489    pub oauth: Option<McpOAuthConfig>,
490    /// Trust level for this server. Default: Untrusted.
491    #[serde(default)]
492    pub trust_level: McpTrustLevel,
493    /// Tool allowlist. `None` means no override (inherit defaults).
494    /// `Some(vec![])` is an explicit empty list (deny all for Untrusted/Sandboxed).
495    /// `Some(vec!["a", "b"])` allows only listed tools.
496    #[serde(default)]
497    pub tool_allowlist: Option<Vec<String>>,
498    /// Expected tool names for attestation. Supplements `tool_allowlist`.
499    ///
500    /// When non-empty: tools not in this list are filtered out (Untrusted/Sandboxed)
501    /// or warned about (Trusted). Schema drift is logged when fingerprints change
502    /// between connections.
503    #[serde(default)]
504    pub expected_tools: Vec<String>,
505    /// Filesystem roots exposed to this MCP server via `roots/list`.
506    /// Each entry is a `{uri, name?}` pair. URI must use `file://` scheme.
507    /// When empty, the server receives an empty roots list.
508    #[serde(default)]
509    pub roots: Vec<McpRootEntry>,
510    /// Per-tool security metadata overrides. Keys are tool names.
511    /// When absent for a tool, metadata is inferred from the tool name via heuristics.
512    #[serde(default)]
513    pub tool_metadata: HashMap<String, ToolSecurityMeta>,
514    /// Per-server elicitation override. `None` = inherit global `elicitation_enabled`.
515    /// `Some(true)` = allow this server to elicit regardless of global setting.
516    /// `Some(false)` = always decline for this server.
517    #[serde(default)]
518    pub elicitation_enabled: Option<bool>,
519    /// Isolate the environment for this Stdio server.
520    ///
521    /// When `true` (or when `[mcp].default_env_isolation = true`), the spawned process
522    /// only sees a minimal base env (`PATH`, `HOME`, etc.) plus this server's `env` map.
523    /// Overrides `[mcp].default_env_isolation` when set explicitly.
524    /// Default: `false` (backward compatible).
525    #[serde(default)]
526    pub env_isolation: Option<bool>,
527}
528
529/// A filesystem root exposed to an MCP server via `roots/list`.
530#[derive(Debug, Clone, Deserialize, Serialize)]
531pub struct McpRootEntry {
532    /// URI of the root directory. Must use `file://` scheme.
533    pub uri: String,
534    /// Optional human-readable name for this root.
535    #[serde(default)]
536    pub name: Option<String>,
537}
538
539/// OAuth 2.1 configuration for an MCP server.
540#[derive(Debug, Clone, Deserialize, Serialize)]
541pub struct McpOAuthConfig {
542    /// Enable OAuth 2.1 for this server.
543    #[serde(default)]
544    pub enabled: bool,
545    /// Token storage backend.
546    #[serde(default)]
547    pub token_storage: OAuthTokenStorage,
548    /// OAuth scopes to request. Empty = server default.
549    #[serde(default)]
550    pub scopes: Vec<String>,
551    /// Port for the local callback server. `0` = auto-assign, `18766` = default fixed port.
552    #[serde(default = "default_oauth_callback_port")]
553    pub callback_port: u16,
554    /// Client name sent during dynamic registration.
555    #[serde(default = "default_oauth_client_name")]
556    pub client_name: String,
557}
558
559impl Default for McpOAuthConfig {
560    fn default() -> Self {
561        Self {
562            enabled: false,
563            token_storage: OAuthTokenStorage::default(),
564            scopes: Vec::new(),
565            callback_port: default_oauth_callback_port(),
566            client_name: default_oauth_client_name(),
567        }
568    }
569}
570
571/// Where OAuth tokens are stored.
572#[derive(Debug, Clone, Default, Deserialize, Serialize)]
573#[serde(rename_all = "lowercase")]
574pub enum OAuthTokenStorage {
575    /// Persisted in the age vault (default).
576    #[default]
577    Vault,
578    /// In-memory only — tokens lost on restart.
579    Memory,
580}
581
582impl std::fmt::Debug for McpServerConfig {
583    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584        let redacted_env: HashMap<&str, &str> = self
585            .env
586            .keys()
587            .map(|k| (k.as_str(), "[REDACTED]"))
588            .collect();
589        // Redact header values to avoid leaking tokens in logs.
590        let redacted_headers: HashMap<&str, &str> = self
591            .headers
592            .keys()
593            .map(|k| (k.as_str(), "[REDACTED]"))
594            .collect();
595        f.debug_struct("McpServerConfig")
596            .field("id", &self.id)
597            .field("command", &self.command)
598            .field("args", &self.args)
599            .field("env", &redacted_env)
600            .field("url", &self.url)
601            .field("timeout", &self.timeout)
602            .field("policy", &self.policy)
603            .field("headers", &redacted_headers)
604            .field("oauth", &self.oauth)
605            .field("trust_level", &self.trust_level)
606            .field("tool_allowlist", &self.tool_allowlist)
607            .field("expected_tools", &self.expected_tools)
608            .field("roots", &self.roots)
609            .field(
610                "tool_metadata_keys",
611                &self.tool_metadata.keys().collect::<Vec<_>>(),
612            )
613            .field("elicitation_enabled", &self.elicitation_enabled)
614            .field("env_isolation", &self.env_isolation)
615            .finish()
616    }
617}