lean_ctx/server/
role_guard.rs1use 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}