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        crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
35            agent_id: "unknown".into(),
36            tool: tool_name.to_string(),
37            action: None,
38            input_hash: String::new(),
39            output_tokens: 0,
40            role: role_name.clone(),
41            event_type: crate::core::audit_trail::AuditEventType::ToolDenied,
42        });
43        let denied_msg = format!(
44            "[ROLE DENIED] Tool '{}' is not allowed for role '{}' ({}).\n\
45             Allowed tools: {}\n\
46             Use `ctx_session` with action `role` to switch roles.",
47            tool_name,
48            role_name,
49            role.role.description,
50            if role.tools.allowed.is_empty() || role.tools.allowed.iter().any(|a| a == "*") {
51                "* (all except denied)".to_string()
52            } else {
53                role.tools.allowed.join(", ")
54            }
55        );
56        return RoleCheckResult {
57            blocked: true,
58            role_name,
59            message: Some(denied_msg),
60        };
61    }
62
63    if is_shell_tool(tool_name) && !role.is_shell_allowed() {
64        crate::core::events::emit_policy_violation(
65            &role_name,
66            tool_name,
67            &format!("shell denied by policy: {}", role.role.shell_policy),
68        );
69        crate::core::audit_trail::record(crate::core::audit_trail::AuditEntryData {
70            agent_id: "unknown".into(),
71            tool: tool_name.to_string(),
72            action: None,
73            input_hash: String::new(),
74            output_tokens: 0,
75            role: role_name.clone(),
76            event_type: crate::core::audit_trail::AuditEventType::ToolDenied,
77        });
78        let msg = format!(
79            "[ROLE DENIED] Shell access denied for role '{}'. Shell policy: {}.",
80            role_name, role.role.shell_policy
81        );
82        return RoleCheckResult {
83            blocked: true,
84            role_name,
85            message: Some(msg),
86        };
87    }
88
89    let cap_result = crate::core::capabilities::check_capabilities(&role_name, tool_name);
90    if !cap_result.allowed {
91        let missing_names: Vec<&str> = cap_result
92            .missing
93            .iter()
94            .map(super::super::core::capabilities::Capability::display_name)
95            .collect();
96        crate::core::events::emit_policy_violation(
97            &role_name,
98            tool_name,
99            &format!("missing capabilities: {}", missing_names.join(", ")),
100        );
101        let msg = format!(
102            "[CAPABILITY DENIED] Tool '{}' requires capabilities [{}] which role '{}' does not grant.",
103            tool_name,
104            missing_names.join(", "),
105            role_name
106        );
107        return RoleCheckResult {
108            blocked: true,
109            role_name,
110            message: Some(msg),
111        };
112    }
113
114    RoleCheckResult {
115        blocked: false,
116        role_name,
117        message: None,
118    }
119}
120
121pub fn into_call_tool_result(check: &RoleCheckResult) -> Option<CallToolResult> {
122    if check.blocked {
123        Some(CallToolResult::success(vec![Content::text(
124            check.message.as_deref().unwrap_or("Blocked by role policy"),
125        )]))
126    } else {
127        None
128    }
129}
130
131fn is_shell_tool(name: &str) -> bool {
132    matches!(name, "ctx_shell" | "ctx_execute")
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn session_tool_always_allowed() {
141        let result = check_tool_access("ctx_session");
142        assert!(!result.blocked);
143    }
144
145    #[test]
146    fn meta_tool_always_allowed() {
147        let result = check_tool_access("ctx");
148        assert!(!result.blocked);
149    }
150
151    #[test]
152    fn coder_role_allows_all() {
153        let result = check_tool_access("ctx_edit");
154        assert!(!result.blocked);
155        assert_eq!(result.role_name, roles::active_role_name());
156    }
157}