lean_ctx_server/server/
role_guard.rs1use 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
18pub 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
27pub 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
134pub 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 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 #[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}