1use 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 HiveNet,
14 ExecSandbox,
15 ExecUnrestricted,
16 KnowledgeRead,
17 KnowledgeWrite,
18 CrossProject,
19 ConfigWrite,
20 AgentManage,
21}
22
23impl Capability {
24 pub fn display_name(&self) -> &'static str {
25 match self {
26 Self::FsRead => "fs:read",
27 Self::FsWrite => "fs:write",
28 Self::FsDelete => "fs:delete",
29 Self::NetOutbound => "net:outbound",
30 Self::HiveNet => "x-hive:net",
31 Self::ExecSandbox => "exec:sandbox",
32 Self::ExecUnrestricted => "exec:unrestricted",
33 Self::KnowledgeRead => "knowledge:read",
34 Self::KnowledgeWrite => "knowledge:write",
35 Self::CrossProject => "cross_project",
36 Self::ConfigWrite => "config:write",
37 Self::AgentManage => "agent:manage",
38 }
39 }
40}
41
42pub struct CapabilityCheckResult {
43 pub allowed: bool,
44 pub missing: Vec<Capability>,
45}
46
47pub fn required_capabilities(tool_name: &str) -> &'static [Capability] {
48 match tool_name {
49 "ctx_edit" => &[Capability::FsRead, Capability::FsWrite],
50 "ctx_shell" => &[Capability::ExecUnrestricted],
51 "ctx_knowledge" => &[Capability::KnowledgeRead, Capability::KnowledgeWrite],
52 "ctx_handoff" | "ctx_share" => &[Capability::KnowledgeRead, Capability::AgentManage],
57 "ctx_agent" | "ctx_work_graph" => &[Capability::AgentManage],
58 "ctx_tasks" => &[Capability::AgentManage, Capability::HiveNet],
59 "ctx_session" | "ctx" => &[],
60 _ => &[Capability::FsRead],
61 }
62}
63
64pub fn role_capabilities(role_name: &str) -> HashSet<Capability> {
65 let mut caps = granted_capabilities(role_name);
66 if !crate::core::hive_gate::available_locally() {
69 caps.remove(&Capability::HiveNet);
70 }
71 caps
72}
73
74fn granted_capabilities(role_name: &str) -> HashSet<Capability> {
75 match role_name {
76 "admin" => HashSet::from([
77 Capability::FsRead,
78 Capability::FsWrite,
79 Capability::FsDelete,
80 Capability::NetOutbound,
81 Capability::HiveNet,
82 Capability::ExecSandbox,
83 Capability::ExecUnrestricted,
84 Capability::KnowledgeRead,
85 Capability::KnowledgeWrite,
86 Capability::CrossProject,
87 Capability::ConfigWrite,
88 Capability::AgentManage,
89 ]),
90 "reviewer" | "ci" => HashSet::from([
91 Capability::FsRead,
92 Capability::ExecSandbox,
93 Capability::KnowledgeRead,
94 ]),
95 "minimal" => HashSet::from([Capability::FsRead, Capability::KnowledgeRead]),
96 _ => capabilities_from_role(role_name),
97 }
98}
99
100fn capabilities_from_role(role_name: &str) -> HashSet<Capability> {
101 let Some(role) = crate::core::roles::load_role(role_name) else {
102 return HashSet::from([
103 Capability::FsRead,
104 Capability::FsWrite,
105 Capability::ExecSandbox,
106 Capability::ExecUnrestricted,
107 Capability::KnowledgeRead,
108 Capability::KnowledgeWrite,
109 Capability::AgentManage,
110 Capability::HiveNet,
111 ]);
112 };
113
114 let mut caps = HashSet::new();
115 caps.insert(Capability::FsRead);
116
117 let has_tool = |name: &str| {
118 role.tools.allowed.iter().any(|a| a == "*" || a == name)
119 && !role.tools.denied.iter().any(|d| d == name || d == "*")
120 };
121
122 if has_tool("ctx_edit") {
123 caps.insert(Capability::FsWrite);
124 }
125 if has_tool("ctx_shell") {
126 caps.insert(Capability::ExecSandbox);
127 caps.insert(Capability::ExecUnrestricted);
128 }
129 if has_tool("ctx_knowledge") {
130 caps.insert(Capability::KnowledgeRead);
131 caps.insert(Capability::KnowledgeWrite);
132 }
133 if has_tool("ctx_agent") || has_tool("ctx_handoff") || has_tool("ctx_work_graph") {
134 caps.insert(Capability::AgentManage);
135 }
136 if crate::core::hive_gate::available_locally() && has_tool("ctx_tasks") {
139 caps.insert(Capability::AgentManage);
140 caps.insert(Capability::HiveNet);
141 }
142 if role.io.allow_cross_project_search {
143 caps.insert(Capability::CrossProject);
144 }
145 if role.io.allow_secret_paths {
146 caps.insert(Capability::ConfigWrite);
147 }
148
149 caps
150}
151
152pub fn check_capabilities(role_name: &str, tool_name: &str) -> CapabilityCheckResult {
153 let required = required_capabilities(tool_name);
154 if required.is_empty() {
155 return CapabilityCheckResult {
156 allowed: true,
157 missing: Vec::new(),
158 };
159 }
160 let granted = role_capabilities(role_name);
161 let missing: Vec<Capability> = required
162 .iter()
163 .filter(|c| !granted.contains(c))
164 .copied()
165 .collect();
166 CapabilityCheckResult {
167 allowed: missing.is_empty(),
168 missing,
169 }
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175
176 #[test]
177 fn admin_has_all_capabilities() {
178 let caps = role_capabilities("admin");
179 assert!(caps.contains(&Capability::FsRead));
180 assert!(caps.contains(&Capability::FsWrite));
181 assert!(caps.contains(&Capability::FsDelete));
182 assert!(caps.contains(&Capability::NetOutbound));
183 assert!(caps.contains(&Capability::ExecUnrestricted));
184 assert!(caps.contains(&Capability::ConfigWrite));
185 assert!(caps.contains(&Capability::AgentManage));
186 }
187
188 #[test]
189 fn reviewer_cannot_write() {
190 let result = check_capabilities("reviewer", "ctx_edit");
191 assert!(!result.allowed);
192 assert!(result.missing.contains(&Capability::FsWrite));
193 }
194
195 #[test]
196 fn minimal_cannot_shell() {
197 let result = check_capabilities("minimal", "ctx_shell");
198 assert!(!result.allowed);
199 assert!(result.missing.contains(&Capability::ExecUnrestricted));
200 }
201
202 #[test]
203 fn session_always_allowed() {
204 let result = check_capabilities("minimal", "ctx_session");
205 assert!(result.allowed);
206 assert!(result.missing.is_empty(), "{:?}", result.missing);
207 }
208
209 #[test]
210 fn developer_can_edit() {
211 let result = check_capabilities("developer", "ctx_edit");
212 assert!(result.allowed);
213 }
214
215 #[test]
217 fn no_role_holds_hive_net_while_hive_is_withheld() {
218 if crate::core::hive_gate::available_locally() {
219 return;
220 }
221 for role in ["admin", "coder", "unknown_role", "reviewer", "minimal"] {
222 assert!(!role_capabilities(role).contains(&Capability::HiveNet));
223 }
224 assert!(!check_capabilities("admin", "ctx_tasks").allowed);
225 }
226
227 #[test]
231 fn tasks_work_for_the_default_role_only_where_allowed() {
232 if crate::test_env::run_in_own_process(
233 "core::capabilities::tests::tasks_work_for_the_default_role_only_where_allowed",
234 &[(crate::core::hive_gate::PREVIEW_ENV, "1")],
235 ) {
236 return;
237 }
238 assert!(check_capabilities("coder", "ctx_tasks").allowed);
239 assert!(check_capabilities("unknown_role", "ctx_tasks").allowed);
240 for read_only in ["reviewer", "ci", "minimal"] {
241 assert!(!check_capabilities(read_only, "ctx_tasks").allowed);
242 }
243 for role in ["coder", "unknown_role", "reviewer", "minimal"] {
245 assert!(!role_capabilities(role).contains(&Capability::NetOutbound));
246 }
247 assert_eq!(Capability::HiveNet.display_name(), "x-hive:net");
248 }
249
250 #[test]
251 fn unknown_role_gets_defaults() {
252 let result = check_capabilities("unknown_role", "ctx_read");
253 assert!(result.allowed);
254 }
255
256 #[test]
257 fn unknown_tool_requires_fs_read() {
258 let required = required_capabilities("some_unknown_tool");
259 assert_eq!(required, &[Capability::FsRead]);
260 }
261
262 #[test]
263 fn display_names_are_colon_separated() {
264 assert_eq!(Capability::FsRead.display_name(), "fs:read");
265 assert_eq!(
266 Capability::ExecUnrestricted.display_name(),
267 "exec:unrestricted"
268 );
269 assert_eq!(Capability::AgentManage.display_name(), "agent:manage");
270 }
271}