Skip to content

Commit aeb178c

Browse files
committed
fix(mcp): bound elicitation channel, warn on sensitive fields (#2524, #2523)
Replace the unbounded elicitation mpsc channel in McpManager with a bounded channel (default capacity 16). Requests arriving when the queue is full are auto-declined with a warning log, preventing memory exhaustion from misbehaving or malicious MCP servers. Capacity is configurable via: [mcp] elicitation_queue_capacity = 16 # default Add sensitive-field detection to the elicitation handler. Before prompting, field names are matched case-insensitively against a list of sensitive patterns (password, token, secret, key, credential, auth, private, passphrase, pin). If any match, a warning is shown with the server name and field name so the user can make an informed trust decision. Configurable via: [mcp] elicitation_warn_sensitive_fields = true # default Also fix three pre-existing clippy warnings in the elicitation CLI prompt builder (match-to-if-let, uninlined format args) and a stale test assertion. Closes #2524, closes #2523
1 parent 70ea42e commit aeb178c

File tree

10 files changed

+207
-31
lines changed

10 files changed

+207
-31
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1313

1414
### Fixed
1515

16+
- fix(mcp): replace unbounded elicitation mpsc channel with a bounded channel (default capacity 16) to prevent memory exhaustion from misbehaving MCP servers; requests that arrive when the queue is full are auto-declined with a warning log instead of accumulating indefinitely; capacity is configurable via `[mcp] elicitation_queue_capacity` (closes #2524)
17+
- fix(mcp): pre-existing `clippy::non_exhaustive_omitted_patterns`, `match_single_binding`, and `uninlined_format_args` warnings in elicitation CLI prompt builder and test code (caught while adding bounded-channel support)
18+
19+
### Added
20+
21+
- security(mcp): warn user before prompting for elicitation fields whose names match sensitive patterns (password, token, secret, key, credential, auth, private, passphrase, pin, etc.); warning shows the server name and field name so the user can make an informed decision; configurable via `[mcp] elicitation_warn_sensitive_fields` (default `true`) (closes #2523)
22+
1623
- skills: raise `disambiguation_threshold` default from 0.05 to 0.20 to prevent low-confidence skill injection (#2512)
1724
- skills: add `min_injection_score` config field (default 0.20) — skills scoring below threshold are no longer injected (#2512)
1825
- skills: fix `process-management` SKILL.md false positive on user queries containing "memory" by replacing with "RAM" (#2513)

crates/zeph-config/src/channels.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@ fn default_elicitation_timeout() -> u64 {
343343
120
344344
}
345345

346+
fn default_elicitation_queue_capacity() -> usize {
347+
16
348+
}
349+
346350
#[derive(Debug, Clone, Deserialize, Serialize)]
347351
pub struct McpConfig {
348352
#[serde(default)]
@@ -374,6 +378,15 @@ pub struct McpConfig {
374378
/// Timeout for user to respond to an elicitation request (seconds). Default: 120.
375379
#[serde(default = "default_elicitation_timeout")]
376380
pub elicitation_timeout: u64,
381+
/// Bounded channel capacity for elicitation events. Requests beyond this limit are
382+
/// auto-declined with a warning to prevent memory exhaustion from misbehaving servers.
383+
/// Default: 16.
384+
#[serde(default = "default_elicitation_queue_capacity")]
385+
pub elicitation_queue_capacity: usize,
386+
/// When true, warn the user before prompting for fields whose names match sensitive
387+
/// patterns (password, token, secret, key, credential, etc.). Default: true.
388+
#[serde(default = "default_true")]
389+
pub elicitation_warn_sensitive_fields: bool,
377390
}
378391

379392
impl Default for McpConfig {
@@ -389,6 +402,8 @@ impl Default for McpConfig {
389402
max_instructions_bytes: default_max_instructions_bytes(),
390403
elicitation_enabled: false,
391404
elicitation_timeout: default_elicitation_timeout(),
405+
elicitation_queue_capacity: default_elicitation_queue_capacity(),
406+
elicitation_warn_sensitive_fields: true,
392407
}
393408
}
394409
}

crates/zeph-core/src/agent/builder.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,7 @@ impl<C: Channel> Agent<C> {
542542
.allowed_commands
543543
.clone_from(&mcp_config.allowed_commands);
544544
self.mcp.max_dynamic = mcp_config.max_dynamic_servers;
545+
self.mcp.elicitation_warn_sensitive_fields = mcp_config.elicitation_warn_sensitive_fields;
545546
self
546547
}
547548

@@ -617,7 +618,7 @@ impl<C: Channel> Agent<C> {
617618
#[must_use]
618619
pub fn with_mcp_elicitation_rx(
619620
mut self,
620-
rx: tokio::sync::mpsc::UnboundedReceiver<zeph_mcp::ElicitationEvent>,
621+
rx: tokio::sync::mpsc::Receiver<zeph_mcp::ElicitationEvent>,
621622
) -> Self {
622623
self.mcp.elicitation_rx = Some(rx);
623624
self

crates/zeph-core/src/agent/mcp.rs

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,29 @@ impl<C: Channel> Agent<C> {
542542
}
543543
};
544544

545+
if self.mcp.elicitation_warn_sensitive_fields {
546+
let sensitive: Vec<&str> = channel_request
547+
.fields
548+
.iter()
549+
.filter(|f| is_sensitive_field(&f.name))
550+
.map(|f| f.name.as_str())
551+
.collect();
552+
if !sensitive.is_empty() {
553+
let fields_list = sensitive.join(", ");
554+
let warning = format!(
555+
"Warning: [{}] is requesting sensitive information (field: {}). \
556+
Only proceed if you trust this server.",
557+
channel_request.server_name, fields_list,
558+
);
559+
tracing::warn!(
560+
server_id = event.server_id,
561+
fields = %fields_list,
562+
"elicitation requests sensitive fields"
563+
);
564+
let _ = self.channel.send(&warning).await;
565+
}
566+
}
567+
545568
let _ = self
546569
.channel
547570
.send_status("MCP server requesting input…")
@@ -682,6 +705,31 @@ fn build_elicitation_fields(
682705
.collect()
683706
}
684707

708+
/// Sensitive field name patterns (case-insensitive substring match).
709+
const SENSITIVE_FIELD_PATTERNS: &[&str] = &[
710+
"password",
711+
"passwd",
712+
"token",
713+
"secret",
714+
"key",
715+
"credential",
716+
"apikey",
717+
"api_key",
718+
"auth",
719+
"authorization",
720+
"private",
721+
"passphrase",
722+
"pin",
723+
];
724+
725+
/// Returns `true` when `field_name` matches any sensitive pattern (case-insensitive).
726+
fn is_sensitive_field(field_name: &str) -> bool {
727+
let lower = field_name.to_lowercase();
728+
SENSITIVE_FIELD_PATTERNS
729+
.iter()
730+
.any(|pattern| lower.contains(pattern))
731+
}
732+
685733
/// Sanitize an elicitation message: cap length (in chars, not bytes) and strip control chars.
686734
fn sanitize_elicitation_message(message: &str) -> String {
687735
const MAX_CHARS: usize = 500;
@@ -942,7 +990,7 @@ mod tests {
942990
let input: String = "é".repeat(300); // 300 chars = 600 bytes
943991
let output = sanitize_elicitation_message(&input);
944992
// Should truncate to exactly 500 chars without panic.
945-
assert_eq!(output.chars().count(), 300.min(500));
993+
assert_eq!(output.chars().count(), 300);
946994
}
947995

948996
#[test]
@@ -1018,4 +1066,24 @@ mod tests {
10181066
assert!(req.required);
10191067
assert!(!opt.required);
10201068
}
1069+
1070+
#[test]
1071+
fn is_sensitive_field_detects_common_patterns() {
1072+
assert!(is_sensitive_field("password"));
1073+
assert!(is_sensitive_field("PASSWORD"));
1074+
assert!(is_sensitive_field("user_password"));
1075+
assert!(is_sensitive_field("api_token"));
1076+
assert!(is_sensitive_field("SECRET_KEY"));
1077+
assert!(is_sensitive_field("auth_header"));
1078+
assert!(is_sensitive_field("private_key"));
1079+
}
1080+
1081+
#[test]
1082+
fn is_sensitive_field_allows_non_sensitive_names() {
1083+
assert!(!is_sensitive_field("username"));
1084+
assert!(!is_sensitive_field("email"));
1085+
assert!(!is_sensitive_field("message"));
1086+
assert!(!is_sensitive_field("description"));
1087+
assert!(!is_sensitive_field("subject"));
1088+
}
10211089
}

crates/zeph-core/src/agent/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ impl<C: Channel> Agent<C> {
405405
discovery_strategy: zeph_mcp::ToolDiscoveryStrategy::default(),
406406
discovery_params: zeph_mcp::DiscoveryParams::default(),
407407
discovery_provider: None,
408+
elicitation_warn_sensitive_fields: true,
408409
},
409410
index: IndexState {
410411
retriever: None,

crates/zeph-core/src/agent/state/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,7 @@ pub(crate) struct McpState {
109109
/// Receives elicitation requests from MCP server handlers during tool execution.
110110
/// When `Some`, the agent loop must process these concurrently with tool result awaiting
111111
/// to avoid deadlock (tool result waits for elicitation, elicitation waits for agent loop).
112-
pub(crate) elicitation_rx:
113-
Option<tokio::sync::mpsc::UnboundedReceiver<zeph_mcp::ElicitationEvent>>,
112+
pub(crate) elicitation_rx: Option<tokio::sync::mpsc::Receiver<zeph_mcp::ElicitationEvent>>,
114113
/// Shared with `McpToolExecutor` so native `tool_use` sees the current tool list.
115114
///
116115
/// Two methods write to this `RwLock` — ordering matters:
@@ -154,6 +153,9 @@ pub(crate) struct McpState {
154153
/// Dedicated embedding provider for tool discovery. `None` = fall back to the
155154
/// agent's primary embedding provider.
156155
pub(crate) discovery_provider: Option<zeph_llm::any::AnyProvider>,
156+
/// When `true`, show a security warning before prompting for fields whose names
157+
/// match sensitive patterns (password, token, secret, key, credential, etc.).
158+
pub(crate) elicitation_warn_sensitive_fields: bool,
157159
}
158160

159161
pub(crate) struct IndexState {

crates/zeph-core/src/bootstrap/mcp.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,17 @@ pub fn create_mcp_manager_with_vault(
6868
.map(|s| (s.id.clone(), s.policy.clone()))
6969
.collect();
7070
let enforcer = zeph_mcp::PolicyEnforcer::new(policy_entries);
71-
let mut manager =
72-
zeph_mcp::McpManager::new(entries, config.mcp.allowed_commands.clone(), enforcer)
73-
.with_suppress_stderr(suppress_stderr)
74-
.with_description_limits(
75-
config.mcp.max_description_bytes,
76-
config.mcp.max_instructions_bytes,
77-
);
71+
let mut manager = zeph_mcp::McpManager::with_elicitation_capacity(
72+
entries,
73+
config.mcp.allowed_commands.clone(),
74+
enforcer,
75+
config.mcp.elicitation_queue_capacity,
76+
)
77+
.with_suppress_stderr(suppress_stderr)
78+
.with_description_limits(
79+
config.mcp.max_description_bytes,
80+
config.mcp.max_instructions_bytes,
81+
);
7882

7983
// Register OAuth credential stores
8084
for s in &config.mcp.servers {

crates/zeph-mcp/src/client.rs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ use rmcp::transport::streamable_http_client::{
1919
StreamableHttpClientTransport, StreamableHttpClientTransportConfig,
2020
};
2121
use tokio::process::Command;
22-
use tokio::sync::mpsc::UnboundedSender;
22+
use tokio::sync::mpsc::{Sender, UnboundedSender};
2323
use tokio::sync::oneshot;
2424
use url::Url;
2525

@@ -67,7 +67,7 @@ pub struct HandlerConfig {
6767
pub max_description_bytes: usize,
6868
/// When `Some`, elicitation requests are forwarded to the agent loop.
6969
/// When `None`, all requests are auto-declined.
70-
pub elicitation_tx: Option<UnboundedSender<ElicitationEvent>>,
70+
pub elicitation_tx: Option<Sender<ElicitationEvent>>,
7171
/// Elicitation response timeout.
7272
pub elicitation_timeout: Duration,
7373
}
@@ -91,7 +91,7 @@ pub struct ToolListChangedHandler {
9191
max_description_bytes: usize,
9292
/// When `Some`, elicitation requests are forwarded to the agent loop.
9393
/// When `None`, all elicitation requests are declined.
94-
elicitation_tx: Option<UnboundedSender<ElicitationEvent>>,
94+
elicitation_tx: Option<Sender<ElicitationEvent>>,
9595
/// Timeout for the user to respond to an elicitation request.
9696
elicitation_timeout: Duration,
9797
}
@@ -103,7 +103,7 @@ impl ToolListChangedHandler {
103103
last_refresh: Arc<DashMap<String, Instant>>,
104104
roots: Arc<Vec<rmcp::model::Root>>,
105105
max_description_bytes: usize,
106-
elicitation_tx: Option<UnboundedSender<ElicitationEvent>>,
106+
elicitation_tx: Option<Sender<ElicitationEvent>>,
107107
elicitation_timeout: Duration,
108108
) -> Self {
109109
Self {
@@ -163,12 +163,22 @@ impl rmcp::ClientHandler for ToolListChangedHandler {
163163
response_tx,
164164
};
165165

166-
if tx.send(event).is_err() {
167-
tracing::warn!(
168-
server_id = self.server_id,
169-
"elicitation channel closed — agent loop may have shut down"
170-
);
171-
return Ok(decline);
166+
match tx.try_send(event) {
167+
Ok(()) => {}
168+
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
169+
tracing::warn!(
170+
server_id = self.server_id,
171+
"elicitation queue full — auto-declining request from misbehaving server"
172+
);
173+
return Ok(decline);
174+
}
175+
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
176+
tracing::warn!(
177+
server_id = self.server_id,
178+
"elicitation channel closed — agent loop may have shut down"
179+
);
180+
return Ok(decline);
181+
}
172182
}
173183

174184
match tokio::time::timeout(self.elicitation_timeout, response_rx).await {
@@ -315,7 +325,7 @@ pub struct OAuthPending {
315325
pub last_refresh: Arc<DashMap<String, Instant>>,
316326
pub roots: Arc<Vec<rmcp::model::Root>>,
317327
pub max_description_bytes: usize,
318-
pub elicitation_tx: Option<UnboundedSender<ElicitationEvent>>,
328+
pub elicitation_tx: Option<Sender<ElicitationEvent>>,
319329
pub elicitation_timeout: Duration,
320330
}
321331

0 commit comments

Comments
 (0)