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 _ => HashSet::from([
78 Capability::FsRead,
79 Capability::FsWrite,
80 Capability::ExecSandbox,
81 Capability::ExecUnrestricted,
82 Capability::KnowledgeRead,
83 Capability::KnowledgeWrite,
84 Capability::AgentManage,
85 ]),
86 }
87}
88
89pub fn check_capabilities(role_name: &str, tool_name: &str) -> CapabilityCheckResult {
90 let required = required_capabilities(tool_name);
91 if required.is_empty() {
92 return CapabilityCheckResult {
93 allowed: true,
94 missing: Vec::new(),
95 };
96 }
97 let granted = role_capabilities(role_name);
98 let missing: Vec<Capability> = required
99 .iter()
100 .filter(|c| !granted.contains(c))
101 .copied()
102 .collect();
103 CapabilityCheckResult {
104 allowed: missing.is_empty(),
105 missing,
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112
113 #[test]
114 fn admin_has_all_capabilities() {
115 let caps = role_capabilities("admin");
116 assert!(caps.contains(&Capability::FsRead));
117 assert!(caps.contains(&Capability::FsWrite));
118 assert!(caps.contains(&Capability::FsDelete));
119 assert!(caps.contains(&Capability::NetOutbound));
120 assert!(caps.contains(&Capability::ExecUnrestricted));
121 assert!(caps.contains(&Capability::ConfigWrite));
122 assert!(caps.contains(&Capability::AgentManage));
123 }
124
125 #[test]
126 fn reviewer_cannot_write() {
127 let result = check_capabilities("reviewer", "ctx_edit");
128 assert!(!result.allowed);
129 assert!(result.missing.contains(&Capability::FsWrite));
130 }
131
132 #[test]
133 fn minimal_cannot_shell() {
134 let result = check_capabilities("minimal", "ctx_shell");
135 assert!(!result.allowed);
136 assert!(result.missing.contains(&Capability::ExecUnrestricted));
137 }
138
139 #[test]
140 fn session_always_allowed() {
141 let result = check_capabilities("minimal", "ctx_session");
142 assert!(result.allowed);
143 assert!(result.missing.is_empty());
144 }
145
146 #[test]
147 fn developer_can_edit() {
148 let result = check_capabilities("developer", "ctx_edit");
149 assert!(result.allowed);
150 }
151
152 #[test]
153 fn unknown_role_gets_defaults() {
154 let result = check_capabilities("unknown_role", "ctx_read");
155 assert!(result.allowed);
156 }
157
158 #[test]
159 fn unknown_tool_requires_fs_read() {
160 let required = required_capabilities("some_unknown_tool");
161 assert_eq!(required, &[Capability::FsRead]);
162 }
163
164 #[test]
165 fn display_names_are_colon_separated() {
166 assert_eq!(Capability::FsRead.display_name(), "fs:read");
167 assert_eq!(
168 Capability::ExecUnrestricted.display_name(),
169 "exec:unrestricted"
170 );
171 assert_eq!(Capability::AgentManage.display_name(), "agent:manage");
172 }
173}