diff --git a/server/src/http/mcp/diagnostics.rs b/server/src/http/mcp/diagnostics.rs index 8c8aa8a7..f99838bf 100644 --- a/server/src/http/mcp/diagnostics.rs +++ b/server/src/http/mcp/diagnostics.rs @@ -148,11 +148,31 @@ pub(super) async fn tool_prompt_permission( .to_string(); let tool_input = args.get("input").cloned().unwrap_or(json!({})); + // Auto-deny immediately if no interactive session is currently listening on + // perm_rx. Interactive sessions (WebSocket, Matrix bot chat) hold the + // perm_rx lock for the duration of a chat. If try_lock succeeds, nobody is + // listening — this is a background agent call that should never reach chat. + // + // Without this check, agent permission requests queue in the channel and + // get forwarded to Matrix/Slack/etc. at the start of the next user session, + // flooding chat with stale agent prompts. + if ctx.perm_rx.try_lock().is_ok() { + crate::slog!( + "[permission] Auto-denied '{tool_name}' (no interactive session — agent mode)" + ); + return serde_json::to_string_pretty(&json!({ + "behavior": "deny", + "message": format!( + "Permission denied for '{tool_name}'. No interactive session active." + ) + })) + .map_err(|e| format!("Serialization error: {e}")); + } + let request_id = uuid::Uuid::new_v4().to_string(); let (response_tx, response_rx) = tokio::sync::oneshot::channel(); - // Try to forward to the interactive session (WebSocket/Matrix). - // If no session is active (headless agent), auto-deny the permission. + // Forward to the active interactive session. if ctx .perm_tx .send(crate::http::context::PermissionForward { @@ -163,12 +183,10 @@ pub(super) async fn tool_prompt_permission( }) .is_err() { - crate::slog!( - "[permission] Auto-denied '{tool_name}' (no interactive session — agent mode)" - ); + crate::slog!("[permission] Auto-denied '{tool_name}' (perm_tx send failed)"); return serde_json::to_string_pretty(&json!({ "behavior": "deny", - "message": format!("Permission denied for '{tool_name}'. Use the appropriate MCP tool instead (e.g. run_tests, run_build, run_lint).") + "message": format!("Permission denied for '{tool_name}'.") })) .map_err(|e| format!("Serialization error: {e}")); } @@ -443,15 +461,40 @@ mod tests { assert_eq!(parsed["totals"]["records"], 1); } + #[tokio::test] + async fn tool_prompt_permission_auto_denies_without_interactive_session() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + + // No task holds perm_rx — simulates a background agent with no + // interactive session active. Should auto-deny immediately. + let result = tool_prompt_permission( + &json!({"tool_name": "Bash", "input": {"command": "echo hello"}}), + &ctx, + ) + .await + .expect("auto-deny must return Ok"); + + let parsed: Value = serde_json::from_str(&result).expect("result should be valid JSON"); + assert_eq!( + parsed["behavior"], "deny", + "must auto-deny when no interactive session holds perm_rx" + ); + } + #[tokio::test] async fn tool_prompt_permission_approved_returns_updated_input() { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); - // Spawn a task that immediately sends approval through the channel. + // Simulate an interactive session: lock perm_rx first, signal readiness, + // then respond with approval. The try_lock() inside tool_prompt_permission + // must fail (lock held) so the request is forwarded rather than auto-denied. + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); let perm_rx = ctx.perm_rx.clone(); tokio::spawn(async move { let mut rx = perm_rx.lock().await; + let _ = ready_tx.send(()); // signal: lock is held if let Some(forward) = rx.recv().await { let _ = forward .response_tx @@ -459,6 +502,9 @@ mod tests { } }); + // Wait until the spawned task holds the perm_rx lock. + ready_rx.await.unwrap(); + let result = tool_prompt_permission( &json!({"tool_name": "Bash", "input": {"command": "echo hello"}}), &ctx, @@ -482,10 +528,12 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); let ctx = test_ctx(tmp.path()); - // Spawn a task that immediately sends denial through the channel. + // Simulate an interactive session: lock perm_rx first, then deny. + let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<()>(); let perm_rx = ctx.perm_rx.clone(); tokio::spawn(async move { let mut rx = perm_rx.lock().await; + let _ = ready_tx.send(()); // signal: lock is held if let Some(forward) = rx.recv().await { let _ = forward .response_tx @@ -493,6 +541,9 @@ mod tests { } }); + // Wait until the spawned task holds the perm_rx lock. + ready_rx.await.unwrap(); + let result = tool_prompt_permission(&json!({"tool_name": "Write", "input": {}}), &ctx) .await .expect("denial must return Ok, not Err");