Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 41 additions & 49 deletions cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ const MOFA_LOGO: &str = r#"
|_| |_|\___/|_|/_/ \_\
"#;

fn load_rbac_manager(config: &Config) -> Option<Arc<RbacManager>> {
match config.get_rbac_config() {
Ok(Some(rbac_config)) if rbac_config.enabled => {
let workspace = config.workspace_path();
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
Some(Arc::new(RbacManager::new(rbac_config, workspace, home)))
}
_ => None,
}
}

/// Mofaclaw - Personal AI Assistant
#[derive(Parser, Debug)]
#[command(name = "mofaclaw")]
Expand Down Expand Up @@ -327,18 +338,22 @@ async fn command_gateway(port: u16, verbose: bool) -> Result<()> {
.await,
);

let rbac_manager = load_rbac_manager(&config);

// Create AgentLoop with the pre-built agent AND tools
let agent = Arc::new(
AgentLoop::with_agent_and_tools(
&config,
llm_agent,
mofa_provider,
bus.clone(),
sessions.clone(),
tools.clone(),
)
.await?,
);
let mut agent = AgentLoop::with_agent_and_tools(
&config,
llm_agent,
mofa_provider,
bus.clone(),
sessions.clone(),
tools.clone(),
)
.await?;
if let Some(ref rbac) = rbac_manager {
agent.set_rbac(rbac.clone(), None);
}
let agent = Arc::new(agent);

// Create subagent manager from agent loop
let subagent_manager = std::sync::Arc::new(SubagentManager::new(agent.clone()));
Expand All @@ -349,19 +364,6 @@ async fn command_gateway(port: u16, verbose: bool) -> Result<()> {
// create channel manager
let channel_manager = ChannelManager::new(&config, bus.clone());

// Initialize RBAC manager if configured (must be before channel registrations)
let rbac_manager: Option<Arc<RbacManager>> = if let Ok(Some(rbac_config)) = config.get_rbac_config() {
if rbac_config.enabled {
let workspace = config.workspace_path();
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
Some(Arc::new(RbacManager::new(rbac_config, workspace, home)))
} else {
None
}
} else {
None
};

// register dingtalk channel if enabled
if config.channels.dingtalk.enabled {
let dingtalk = DingTalkChannel::new(config.channels.dingtalk.clone(), bus.clone());
Expand Down Expand Up @@ -394,20 +396,6 @@ async fn command_gateway(port: u16, verbose: bool) -> Result<()> {
println!("Feishu: enabled (via Python bridge on ws://localhost:3004)");
}

// Initialize RBAC manager if configured
let rbac_manager: Option<Arc<RbacManager>> =
if let Ok(Some(rbac_config)) = config.get_rbac_config() {
if rbac_config.enabled {
let workspace = config.workspace_path();
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from("."));
Some(Arc::new(RbacManager::new(rbac_config, workspace, home)))
} else {
None
}
} else {
None
};

// register discord channel if enabled
if config.channels.discord.enabled {
match if let Some(ref rbac) = rbac_manager {
Expand Down Expand Up @@ -512,6 +500,8 @@ async fn command_agent(message: Option<String>, session: String) -> Result<()> {
AgentLoop::register_default_tools(&mut tools_guard, &workspace, brave_api_key, bus.clone());
}

let rbac_manager = load_rbac_manager(&config);

// Create ToolRegistryExecutor for LLMAgentBuilder
let tool_executor = Arc::new(ToolRegistryExecutor::new(tools.clone()));

Expand All @@ -533,17 +523,19 @@ async fn command_agent(message: Option<String>, session: String) -> Result<()> {
);

// Create AgentLoop with the pre-built agent AND tools
let agent = Arc::new(
AgentLoop::with_agent_and_tools(
&config,
llm_agent,
mofa_provider,
bus.clone(),
sessions.clone(),
tools.clone(),
)
.await?,
);
let mut agent = AgentLoop::with_agent_and_tools(
&config,
llm_agent,
mofa_provider,
bus.clone(),
sessions.clone(),
tools.clone(),
)
.await?;
if let Some(ref rbac) = rbac_manager {
agent.set_rbac(rbac.clone(), None);
}
let agent = Arc::new(agent);

// Create subagent manager from agent loop
let subagent_manager = Arc::new(SubagentManager::new(agent.clone()));
Expand Down
111 changes: 106 additions & 5 deletions core/src/agent/loop_.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@ use crate::agent::SubagentManager;
use crate::bus::MessageBus;
use crate::error::Result;
use crate::messages::{InboundMessage, OutboundMessage};
use crate::rbac::{AuditLogger, RbacManager, Role};
use crate::session::{SessionExt, SessionManager};
use crate::tools::filesystem::{EditFileTool, ListDirTool, ReadFileTool, WriteFileTool};
use crate::tools::shell::ExecTool;
use crate::tools::web::{WebFetchTool, WebSearchTool};
use crate::tools::{MessageTool, SpawnTool, ToolRegistry, ToolRegistryExecutor};
use crate::tools::{
MessageTool, PermissionAwareRegistry, SpawnTool, ToolRegistry, ToolRegistryExecutor,
};
use std::sync::Arc;
use tokio::sync::RwLock;
use tracing::{error, info};
Expand All @@ -35,6 +38,12 @@ pub struct ActiveSubagent {
pub started_at: chrono::DateTime<chrono::Utc>,
}

#[derive(Debug, Clone)]
struct ToolCaller {
user_id: String,
role: Role,
}

/// The agent loop is the core processing engine
///
/// It:
Expand Down Expand Up @@ -69,6 +78,10 @@ pub struct AgentLoop {
temperature: Option<f32>,
/// Max tokens
max_tokens: Option<u32>,
/// RBAC manager for permission-based tool execution
rbac_manager: Option<Arc<RbacManager>>,
/// Audit logger for permission checks
audit_logger: Option<Arc<AuditLogger>>,
}

impl AgentLoop {
Expand Down Expand Up @@ -112,6 +125,8 @@ impl AgentLoop {
default_model,
temperature,
max_tokens,
rbac_manager: None,
audit_logger: None,
})
}

Expand Down Expand Up @@ -149,9 +164,24 @@ impl AgentLoop {
default_model,
temperature,
max_tokens,
rbac_manager: None,
audit_logger: None,
})
}

/// Set the RBAC manager and audit logger for permission-based tool execution.
///
/// When set, tool executions are checked against RBAC policies before
/// being forwarded to the underlying tool registry.
pub fn set_rbac(
&mut self,
rbac_manager: Arc<RbacManager>,
audit_logger: Option<Arc<AuditLogger>>,
) {
self.rbac_manager = Some(rbac_manager);
self.audit_logger = audit_logger;
}

/// Register the default set of tools (without spawn tool)
pub fn register_default_tools(
registry: &mut ToolRegistry,
Expand Down Expand Up @@ -269,8 +299,9 @@ impl AgentLoop {
} else {
Some(msg.media.clone())
};
let tool_caller = self.resolve_tool_caller(&msg);
let final_content = self
.run_agent_loop(context_messages, &msg.content, media)
.run_agent_loop(context_messages, &msg.content, media, tool_caller)
.await?;

// Save to session
Expand Down Expand Up @@ -300,15 +331,85 @@ impl AgentLoop {
.map(|content| OutboundMessage::new(&response_channel, &response_chat_id, content)))
}

/// Run the main agent loop using mofa framework's built-in AgentLoop
fn resolve_tool_caller(&self, msg: &InboundMessage) -> ToolCaller {
let user_id = Self::normalized_sender_id(&msg.sender_id);
let role = self
.role_from_metadata(msg)
.or_else(|| self.role_from_rbac_channel_mapping(msg, &user_id))
.unwrap_or(Role::Guest);

ToolCaller { user_id, role }
}

fn normalized_sender_id(sender_id: &str) -> String {
sender_id
.split_once('|')
.map(|(user_id, _)| user_id.to_string())
.unwrap_or_else(|| sender_id.to_string())
}

fn role_from_metadata(&self, msg: &InboundMessage) -> Option<Role> {
msg.metadata
.get("role")
.and_then(|value| value.as_str())
.and_then(Role::from_str)
}

fn role_from_rbac_channel_mapping(&self, msg: &InboundMessage, user_id: &str) -> Option<Role> {
let rbac = self.rbac_manager.as_ref()?;

let role = match msg.channel.as_str() {
"discord" => rbac
.get_role_from_discord(user_id, &Self::metadata_string_list(msg, "discord_roles")),
"dingtalk" => rbac
.get_role_from_dingtalk(user_id, &Self::metadata_string_list(msg, "dingtalk_tags")),
"feishu" => {
rbac.get_role_from_feishu(user_id, &Self::metadata_string_list(msg, "feishu_tags"))
}
_ => return None,
};

Some(role)
}

fn metadata_string_list(msg: &InboundMessage, key: &str) -> Vec<String> {
msg.metadata
.get(key)
.and_then(|value| value.as_array())
.map(|values| {
values
.iter()
.filter_map(|value| value.as_str().map(|value| value.to_string()))
.collect()
})
.unwrap_or_default()
}

/// Run the main agent loop using mofa framework's built-in AgentLoop.
///
/// When an `RbacManager` has been set via `set_rbac`, tool calls are
/// routed through a `PermissionAwareRegistry` that enforces RBAC
/// policies before execution. Otherwise the raw `ToolRegistryExecutor`
/// is used (backwards-compatible).
async fn run_agent_loop(
&self,
context: Vec<ChatMessage>,
content: &str,
media: Option<Vec<String>>,
tool_caller: ToolCaller,
) -> Result<Option<String>> {
let tool_executor = Arc::new(ToolRegistryExecutor::new(self.tools.clone()))
as Arc<dyn mofa_sdk::llm::ToolExecutor>;
let tool_executor: Arc<dyn mofa_sdk::llm::ToolExecutor> =
if let Some(ref rbac) = self.rbac_manager {
Arc::new(PermissionAwareRegistry::new(
self.tools.clone(),
Some(rbac.clone()),
self.audit_logger.clone(),
tool_caller.role,
tool_caller.user_id.clone(),
))
Comment thread
diiviikk5 marked this conversation as resolved.
} else {
Arc::new(ToolRegistryExecutor::new(self.tools.clone()))
};

let config = MofaAgentLoopConfig {
max_tool_iterations: self.max_iterations,
Expand Down
3 changes: 3 additions & 0 deletions core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@ pub enum ToolError {
#[error("Tool timeout after {0}s")]
Timeout(u64),

#[error("Permission denied: {0}")]
PermissionDenied(String),

#[error("File error: {0}")]
File(String),

Expand Down
4 changes: 3 additions & 1 deletion core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,7 @@ pub use session::{
Session, SessionExt, SessionInfo, SessionManager, messages_to_session_messages,
session_messages_to_messages,
};
pub use tools::ToolRegistry;
pub use tools::{
default_tool_permissions, PermissionAwareRegistry, ToolPermissionRequirement, ToolRegistry,
};
pub use types::*;
24 changes: 19 additions & 5 deletions core/src/rbac/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,10 @@ impl RbacManager {
let parts: Vec<&str> = resource.split('.').collect();
if parts.len() < 2 {
warn!("Invalid resource format: {}", resource);
return PermissionResult::Allowed; // Default to allow if format is invalid
return PermissionResult::Denied(format!(
"Invalid RBAC resource format '{}'; expected '<category>.<name>'",
resource
));
}

let category = parts[0];
Expand All @@ -67,7 +70,7 @@ impl RbacManager {
"tools" => self.check_tool_permission(user_role, name, operation),
_ => {
warn!("Unknown permission category: {}", category);
PermissionResult::Allowed // Default to allow
PermissionResult::Denied(format!("Unknown RBAC permission category '{}'", category))
}
}
}
Expand Down Expand Up @@ -108,7 +111,10 @@ impl RbacManager {
"Invalid min_role '{}' for skill.{}.{}",
op_perm.min_role, skill_name, operation
);
return PermissionResult::Allowed;
return PermissionResult::Denied(format!(
"Invalid RBAC min_role '{}' for skill.{}.{}",
op_perm.min_role, skill_name, operation
));
}
};

Expand Down Expand Up @@ -161,7 +167,10 @@ impl RbacManager {
"Invalid min_role '{}' for tool.{}.{}",
op_perm.min_role, tool_name, operation
);
return PermissionResult::Allowed;
return PermissionResult::Denied(format!(
"Invalid RBAC min_role '{}' for tool.{}.{}",
op_perm.min_role, tool_name, operation
));
}
};

Expand Down Expand Up @@ -248,7 +257,12 @@ impl RbacManager {
if let Some(op_perm) = tool_config.operations.get("full_access") {
let min_role = match Role::from_str(&op_perm.min_role) {
Some(role) => role,
None => return PermissionResult::Allowed,
None => {
return PermissionResult::Denied(format!(
"Invalid RBAC min_role '{}' for tool.shell.full_access",
op_perm.min_role
));
}
};
if role >= min_role {
return PermissionResult::Allowed;
Expand Down
Loading
Loading