lean_ctx/core/
capabilities.rs1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
5#[serde(rename_all = "snake_case")]
6pub enum Capability {
7 FsRead,
8 FsWrite,
9 FsDelete,
10 NetOutbound,
11 ExecSandbox,
12 ExecUnrestricted,
13 KnowledgeRead,
14 KnowledgeWrite,
15 CrossProject,
16 ConfigWrite,
17 AgentManage,
18}
19
20impl Capability {
21 pub fn display_name(&self) -> &'static str {
22 match self {
23 Self::FsRead => "fs:read",
24 Self::FsWrite => "fs:write",
25 Self::FsDelete => "fs:delete",
26 Self::NetOutbound => "net:outbound",
27 Self::ExecSandbox => "exec:sandbox",
28 Self::ExecUnrestricted => "exec:unrestricted",
29 Self::KnowledgeRead => "knowledge:read",
30 Self::KnowledgeWrite => "knowledge:write",
31 Self::CrossProject => "cross_project",
32 Self::ConfigWrite => "config:write",
33 Self::AgentManage => "agent:manage",
34 }
35 }
36}
37
38pub struct CapabilityCheckResult {
39 pub allowed: bool,
40 pub missing: Vec<Capability>,
41}
42
43pub fn required_capabilities(tool_name: &str) -> &'static [Capability] {
44 match tool_name {
45 "ctx_edit" => &[Capability::FsRead, Capability::FsWrite],
46 "ctx_shell" => &[Capability::ExecUnrestricted],
47 "ctx_knowledge" => &[Capability::KnowledgeRead, Capability::KnowledgeWrite],
48 "ctx_handoff" => &[Capability::KnowledgeRead, Capability::AgentManage],
49 "ctx_agent" | "ctx_task" => &[Capability::AgentManage],
50 "ctx_session" | "ctx" => &[],
51 "ctx_share" => &[Capability::KnowledgeRead, Capability::CrossProject],
52 _ => &[Capability::FsRead],
53 }
54}
55
56pub fn role_capabilities(role_name: &str) -> HashSet<Capability> {
57 match role_name {
58 "admin" => HashSet::from([
59 Capability::FsRead,
60 Capability::FsWrite,
61 Capability::FsDelete,
62 Capability::NetOutbound,
63 Capability::ExecSandbox,
64 Capability::ExecUnrestricted,
65 Capability::KnowledgeRead,
66 Capability::KnowledgeWrite,
67 Capability::CrossProject,
68 Capability::ConfigWrite,
69 Capability::AgentManage,
70 ]),
71 "reviewer" | "ci" => HashSet::from([
72 Capability::FsRead,
73 Capability::ExecSandbox,
74 Capability::KnowledgeRead,
75 ]),
76 "minimal" => HashSet::from([Capability::FsRead, Capability::KnowledgeRead]),
77 _ => capabilities_from_role(role_name),
78 }
79}
80
81fn capabilities_from_role(role_name: &str) -> HashSet<Capability> {
82 let Some(role) = crate::core::roles::load_role(role_name) else {
83 return HashSet::from([
84 Capability::FsRead,
85 Capability::FsWrite,
86 Capability::ExecSandbox,
87 Capability::ExecUnrestricted,
88 Capability::KnowledgeRead,
89 Capability::KnowledgeWrite,
90 Capability::AgentManage,
91 ]);
92 };
93
94 let mut caps = HashSet::new();
95 caps.insert(Capability::FsRead);
96
97 let has_tool = |name: &str| {
98 role.tools.allowed.iter().any(|a| a == "*" || a == name)
99 && !role.tools.denied.iter().any(|d| d == name || d == "*")
100 };
101
102 if has_tool("ctx_edit") {
103 caps.insert(Capability::FsWrite);
104 }
105 if has_tool("ctx_shell") {
106 caps.insert(Capability::ExecSandbox);
107 caps.insert(Capability::ExecUnrestricted);
108 }
109 if has_tool("ctx_knowledge") {
110 caps.insert(Capability::KnowledgeRead);
111 caps.insert(Capability::KnowledgeWrite);
112 }
113 if has_tool("ctx_agent") || has_tool("ctx_task") || has_tool("ctx_handoff") {
114 caps.insert(Capability::AgentManage);
115 }
116 if role.io.allow_cross_project_search {
117 caps.insert(Capability::CrossProject);
118 }
119 if role.io.allow_secret_paths {
120 caps.insert(Capability::ConfigWrite);
121 }
122
123 caps
124}
125
126pub fn check_capabilities(role_name: &str, tool_name: &str) -> CapabilityCheckResult {
127 let required = required_capabilities(tool_name);
128 if required.is_empty() {
129 return CapabilityCheckResult {
130 allowed: true,
131 missing: Vec::new(),
132 };
133 }
134 let granted = role_capabilities(role_name);
135 let missing: Vec<Capability> = required
136 .iter()
137 .filter(|c| !granted.contains(c))
138 .copied()
139 .collect();
140 CapabilityCheckResult {
141 allowed: missing.is_empty(),
142 missing,
143 }
144}
145
146#[cfg(test)]
147mod tests {
148 use super::*;
149
150 #[test]
151 fn admin_has_all_capabilities() {
152 let caps = role_capabilities("admin");
153 assert!(caps.contains(&Capability::FsRead));
154 assert!(caps.contains(&Capability::FsWrite));
155 assert!(caps.contains(&Capability::FsDelete));
156 assert!(caps.contains(&Capability::NetOutbound));
157 assert!(caps.contains(&Capability::ExecUnrestricted));
158 assert!(caps.contains(&Capability::ConfigWrite));
159 assert!(caps.contains(&Capability::AgentManage));
160 }
161
162 #[test]
163 fn reviewer_cannot_write() {
164 let result = check_capabilities("reviewer", "ctx_edit");
165 assert!(!result.allowed);
166 assert!(result.missing.contains(&Capability::FsWrite));
167 }
168
169 #[test]
170 fn minimal_cannot_shell() {
171 let result = check_capabilities("minimal", "ctx_shell");
172 assert!(!result.allowed);
173 assert!(result.missing.contains(&Capability::ExecUnrestricted));
174 }
175
176 #[test]
177 fn session_always_allowed() {
178 let result = check_capabilities("minimal", "ctx_session");
179 assert!(result.allowed);
180 assert!(result.missing.is_empty());
181 }
182
183 #[test]
184 fn developer_can_edit() {
185 let result = check_capabilities("developer", "ctx_edit");
186 assert!(result.allowed);
187 }
188
189 #[test]
190 fn unknown_role_gets_defaults() {
191 let result = check_capabilities("unknown_role", "ctx_read");
192 assert!(result.allowed);
193 }
194
195 #[test]
196 fn unknown_tool_requires_fs_read() {
197 let required = required_capabilities("some_unknown_tool");
198 assert_eq!(required, &[Capability::FsRead]);
199 }
200
201 #[test]
202 fn display_names_are_colon_separated() {
203 assert_eq!(Capability::FsRead.display_name(), "fs:read");
204 assert_eq!(
205 Capability::ExecUnrestricted.display_name(),
206 "exec:unrestricted"
207 );
208 assert_eq!(Capability::AgentManage.display_name(), "agent:manage");
209 }
210}