Skip to main content

lean_ctx/server/
role_guard.rs

1//! Role-based tool access guard for the MCP server pipeline.
2//!
3//! Checks the active role's tool policy before dispatching a tool call.
4//! Returns `Some(CallToolResult)` with a denial message if blocked, `None` if allowed.
5
6use rmcp::model::{CallToolResult, Content};
7
8use crate::core::roles;
9
10pub struct RoleCheckResult {
11    pub blocked: bool,
12    pub role_name: String,
13    pub message: Option<String>,
14}
15
16pub fn check_tool_access(tool_name: &str) -> RoleCheckResult {
17    let role_name = roles::active_role_name();
18    let role = roles::active_role();
19
20    if tool_name == "ctx_session" || tool_name == "ctx" {
21        return RoleCheckResult {
22            blocked: false,
23            role_name,
24            message: None,
25        };
26    }
27
28    if !role.is_tool_allowed(tool_name) {
29        crate::core::events::emit_policy_violation(
30            &role_name,
31            tool_name,
32            "tool not allowed by role policy",
33        );
34        let denied_msg = format!(
35            "[ROLE DENIED] Tool '{}' is not allowed for role '{}' ({}).\n\
36             Allowed tools: {}\n\
37             Use `ctx_session` with action `role` to switch roles.",
38            tool_name,
39            role_name,
40            role.role.description,
41            if role.tools.allowed.is_empty() || role.tools.allowed.iter().any(|a| a == "*") {
42                "* (all except denied)".to_string()
43            } else {
44                role.tools.allowed.join(", ")
45            }
46        );
47        return RoleCheckResult {
48            blocked: true,
49            role_name,
50            message: Some(denied_msg),
51        };
52    }
53
54    if is_shell_tool(tool_name) && !role.is_shell_allowed() {
55        crate::core::events::emit_policy_violation(
56            &role_name,
57            tool_name,
58            &format!("shell denied by policy: {}", role.role.shell_policy),
59        );
60        let msg = format!(
61            "[ROLE DENIED] Shell access denied for role '{}'. Shell policy: {}.",
62            role_name, role.role.shell_policy
63        );
64        return RoleCheckResult {
65            blocked: true,
66            role_name,
67            message: Some(msg),
68        };
69    }
70
71    RoleCheckResult {
72        blocked: false,
73        role_name,
74        message: None,
75    }
76}
77
78pub fn into_call_tool_result(check: &RoleCheckResult) -> Option<CallToolResult> {
79    if check.blocked {
80        Some(CallToolResult::success(vec![Content::text(
81            check.message.as_deref().unwrap_or("Blocked by role policy"),
82        )]))
83    } else {
84        None
85    }
86}
87
88fn is_shell_tool(name: &str) -> bool {
89    matches!(name, "ctx_shell" | "ctx_execute")
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn session_tool_always_allowed() {
98        let result = check_tool_access("ctx_session");
99        assert!(!result.blocked);
100    }
101
102    #[test]
103    fn meta_tool_always_allowed() {
104        let result = check_tool_access("ctx");
105        assert!(!result.blocked);
106    }
107
108    #[test]
109    fn coder_role_allows_all() {
110        let result = check_tool_access("ctx_edit");
111        assert!(!result.blocked);
112        assert_eq!(result.role_name, roles::active_role_name());
113    }
114}