Skip to main content

lean_ctx_server/server/
role_guard.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Role-based tool access guard for the MCP server pipeline.
3//!
4//! Checks the active role's tool policy before dispatching a tool call.
5//! A denial becomes a tool error (`is_error`) carrying the reason, and its
6//! audit entry names the agent that made the call.
7
8use rmcp::model::{CallToolResult, ContentBlock};
9
10use lean_ctx::core::roles;
11
12pub struct RoleCheckResult {
13    pub blocked: bool,
14    pub role_name: String,
15    pub message: Option<String>,
16}
17
18/// [`check_tool_access_as`] for this process's agent identity
19/// (`LEAN_CTX_AGENT_ID`, else `local`), for callers without an MCP session.
20pub fn check_tool_access(tool_name: &str) -> RoleCheckResult {
21    check_tool_access_as(
22        tool_name,
23        lean_ctx::core::agent_identity::current_agent_id(),
24    )
25}
26
27/// Check `tool_name` against the active role; a denial is audited for
28/// `agent_id`.
29pub fn check_tool_access_as(tool_name: &str, agent_id: &str) -> RoleCheckResult {
30    let role_name = roles::active_role_name();
31    let role = roles::active_role();
32
33    if tool_name == "ctx_session" || tool_name == "ctx" {
34        return RoleCheckResult {
35            blocked: false,
36            role_name,
37            message: None,
38        };
39    }
40
41    if !role.is_tool_allowed(tool_name) {
42        lean_ctx::core::events::emit_policy_violation(
43            &role_name,
44            tool_name,
45            "tool not allowed by role policy",
46        );
47        lean_ctx::core::audit_trail::record(lean_ctx::core::audit_trail::AuditEntryData {
48            agent_id: agent_id.to_string(),
49            tool: tool_name.to_string(),
50            action: None,
51            input_hash: String::new(),
52            output_tokens: 0,
53            role: role_name.clone(),
54            event_type: lean_ctx::core::audit_trail::AuditEventType::ToolDenied,
55        });
56        let denied_msg = format!(
57            "[ROLE DENIED] Tool '{}' is not allowed for role '{}' ({}).\n\
58             Allowed tools: {}\n\
59             Use `ctx_session` with action `role` to switch roles.",
60            tool_name,
61            role_name,
62            role.role.description,
63            if role.tools.allowed.is_empty() || role.tools.allowed.iter().any(|a| a == "*") {
64                "* (all except denied)".to_string()
65            } else {
66                role.tools.allowed.join(", ")
67            }
68        );
69        return RoleCheckResult {
70            blocked: true,
71            role_name,
72            message: Some(denied_msg),
73        };
74    }
75
76    if is_shell_tool(tool_name) && !role.is_shell_allowed() {
77        lean_ctx::core::events::emit_policy_violation(
78            &role_name,
79            tool_name,
80            &format!("shell denied by policy: {}", role.role.shell_policy),
81        );
82        lean_ctx::core::audit_trail::record(lean_ctx::core::audit_trail::AuditEntryData {
83            agent_id: agent_id.to_string(),
84            tool: tool_name.to_string(),
85            action: None,
86            input_hash: String::new(),
87            output_tokens: 0,
88            role: role_name.clone(),
89            event_type: lean_ctx::core::audit_trail::AuditEventType::ToolDenied,
90        });
91        let msg = format!(
92            "[ROLE DENIED] Shell access denied for role '{}'. Shell policy: {}.",
93            role_name, role.role.shell_policy
94        );
95        return RoleCheckResult {
96            blocked: true,
97            role_name,
98            message: Some(msg),
99        };
100    }
101
102    let cap_result = lean_ctx::core::capabilities::check_capabilities(&role_name, tool_name);
103    if !cap_result.allowed {
104        let missing_names: Vec<&str> = cap_result
105            .missing
106            .iter()
107            .map(lean_ctx::core::capabilities::Capability::display_name)
108            .collect();
109        lean_ctx::core::events::emit_policy_violation(
110            &role_name,
111            tool_name,
112            &format!("missing capabilities: {}", missing_names.join(", ")),
113        );
114        let msg = format!(
115            "[CAPABILITY DENIED] Tool '{}' requires capabilities [{}] which role '{}' does not grant.",
116            tool_name,
117            missing_names.join(", "),
118            role_name
119        );
120        return RoleCheckResult {
121            blocked: true,
122            role_name,
123            message: Some(msg),
124        };
125    }
126
127    RoleCheckResult {
128        blocked: false,
129        role_name,
130        message: None,
131    }
132}
133
134/// The denial as a tool error the agent can read; `None` when allowed.
135pub fn into_call_tool_result(check: &RoleCheckResult) -> Option<CallToolResult> {
136    if check.blocked {
137        Some(CallToolResult::error(vec![ContentBlock::text(
138            check.message.as_deref().unwrap_or("Blocked by role policy"),
139        )]))
140    } else {
141        None
142    }
143}
144
145fn is_shell_tool(name: &str) -> bool {
146    matches!(name, "ctx_shell" | "ctx_execute")
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn session_tool_always_allowed() {
155        let result = check_tool_access("ctx_session");
156        assert!(!result.blocked);
157    }
158
159    #[test]
160    fn meta_tool_always_allowed() {
161        let result = check_tool_access("ctx");
162        assert!(!result.blocked);
163    }
164
165    #[test]
166    fn coder_role_allows_all() {
167        // Other tests change the global role; hold the guard so none can
168        // switch it between the set and the check below.
169        let _role = roles::lock_global_role();
170        let _ = roles::set_active_role("coder");
171        let result = check_tool_access("ctx_edit");
172        assert!(
173            !result.blocked,
174            "coder role should allow ctx_edit, active={}",
175            result.role_name
176        );
177        assert_eq!(result.role_name, "coder");
178    }
179
180    // A denial was a successful result audited for agent "unknown".
181    #[test]
182    fn a_denial_is_a_tool_error_audited_for_the_calling_agent() {
183        let _data_dir = lean_ctx::core::data_dir::isolated_data_dir();
184        let reviewer = roles::load_role("reviewer").expect("built-in reviewer role");
185        let check = roles::with_test_active_role(reviewer, || {
186            check_tool_access_as("ctx_edit", "role-guard-test-agent")
187        });
188        assert!(check.blocked);
189        let denied = into_call_tool_result(&check).expect("a denial");
190        assert_eq!(denied.is_error, Some(true));
191        assert!(
192            lean_ctx::core::audit_trail::load_recent(50)
193                .iter()
194                .any(|entry| {
195                    entry.agent_id == "role-guard-test-agent"
196                        && entry.tool == "ctx_edit"
197                        && matches!(
198                            entry.event_type,
199                            lean_ctx::core::audit_trail::AuditEventType::ToolDenied
200                        )
201                })
202        );
203    }
204
205    #[test]
206    fn ctx_call_is_not_exempt_from_guard() {
207        let _role = roles::lock_global_role();
208        let _ = roles::set_active_role("coder");
209        let result = check_tool_access("ctx_call");
210        assert!(
211            !result.blocked,
212            "ctx_call itself should be allowed for coder"
213        );
214    }
215}