diff --git a/server/src/http/gateway/mcp.rs b/server/src/http/gateway/mcp.rs index 45ba0135..ee051f8f 100644 --- a/server/src/http/gateway/mcp.rs +++ b/server/src/http/gateway/mcp.rs @@ -22,6 +22,9 @@ const GATEWAY_TOOLS: &[&str] = &[ "init_project", "aggregate_pipeline_status", "agents.list", + // Handled at the gateway so the Matrix bot's perm_rx listener is used + // rather than the container's (which has no interactive session attached). + "prompt_permission", ]; /// Gateway tool definitions. @@ -350,6 +353,7 @@ async fn handle_gateway_tool( "init_project" => handle_init_project_tool(params, state, id).await, "aggregate_pipeline_status" => handle_aggregate_pipeline_status_tool(state, id).await, "agents.list" => handle_agents_list_tool(id), + "prompt_permission" => handle_prompt_permission_tool(params, state, id).await, _ => JsonRpcResponse::error(id, -32601, format!("Unknown gateway tool: {tool_name}")), } } @@ -531,6 +535,102 @@ async fn handle_aggregate_pipeline_status_tool( ) } +/// Handle the `prompt_permission` tool at the gateway level. +/// +/// Mirrors `tool_prompt_permission` in `http/mcp/diagnostics/permission.rs` but +/// uses the gateway's `perm_tx`/`perm_rx` so requests reach the Matrix bot that +/// is listening on the gateway, not the proxied container (which has no +/// interactive session and would auto-deny immediately). +async fn handle_prompt_permission_tool( + params: &Value, + state: &GatewayState, + id: Option, +) -> JsonRpcResponse { + use crate::http::context::PermissionDecision; + use crate::http::context::PermissionForward; + + let args = params.get("arguments").unwrap_or(params); + let tool_name = args + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let tool_input = args.get("input").cloned().unwrap_or(json!({})); + + // Auto-approve huskies MCP tools — mirrors the standard server's allowlist. + if tool_name.starts_with("mcp__huskies__") { + crate::slog!( + "[gateway/permission] Auto-approved '{tool_name}' (matches mcp__huskies__* allowlist)" + ); + let text = json!({"behavior": "allow", "updatedInput": tool_input}).to_string(); + return JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]})); + } + + // Auto-deny when no interactive session holds perm_rx (i.e. no Matrix bot + // listener is running — try_lock succeeds when nobody else holds the lock). + if state.perm_rx.try_lock().is_ok() { + crate::slog!("[gateway/permission] Auto-denied '{tool_name}' (no interactive session)"); + let text = json!({ + "behavior": "deny", + "message": format!("Permission denied for '{tool_name}'. No interactive session active.") + }) + .to_string(); + return JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]})); + } + + let request_id = uuid::Uuid::new_v4().to_string(); + let (response_tx, response_rx) = tokio::sync::oneshot::channel(); + + if state + .perm_tx + .send(PermissionForward { + request_id, + tool_name: tool_name.clone(), + tool_input: tool_input.clone(), + response_tx, + }) + .is_err() + { + crate::slog!("[gateway/permission] Auto-denied '{tool_name}' (perm_tx send failed)"); + let text = + json!({"behavior": "deny", "message": format!("Permission denied for '{tool_name}'.")}) + .to_string(); + return JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]})); + } + + let decision = + match tokio::time::timeout(std::time::Duration::from_secs(300), response_rx).await { + Ok(Ok(d)) => d, + Ok(Err(_)) => { + return JsonRpcResponse::error( + id, + -32603, + "Permission response channel closed unexpectedly".into(), + ); + } + Err(_) => { + return JsonRpcResponse::error( + id, + -32603, + format!("Permission request for '{tool_name}' timed out after 5 minutes"), + ); + } + }; + + let text = if matches!( + decision, + PermissionDecision::Approve | PermissionDecision::AlwaysAllow + ) { + json!({"behavior": "allow", "updatedInput": tool_input}).to_string() + } else { + crate::slog_warn!("[gateway/permission] User denied permission for '{tool_name}'"); + json!({"behavior": "deny", "message": format!("User denied permission for '{tool_name}'")}) + .to_string() + }; + + JsonRpcResponse::success(id, json!({"content": [{"type": "text", "text": text}]})) +} + /// Handle the `agents.list` gateway tool — returns all alive build agents from the CRDT. fn handle_agents_list_tool(id: Option) -> JsonRpcResponse { let agents = gateway::list_agents();