Skip to main content

lean_ctx/core/
capabilities.rs

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    /// Only the organization's linked Hive (its connection, lease and
12    /// opt-in decide what goes out), never another host.
13    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_share is agent-to-agent handover WITHIN one project: the store is
53        // keyed per project root and push is jailed to it (enterprise#28), so it
54        // is not a cross-project data flow. AgentManage keeps it (like
55        // ctx_handoff) out of read-only roles (reviewer/ci/minimal).
56        "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    // The Hive release switch (3.12.1): off, no role holds or advertises
67    // the Hive capability, as the Hive tools are withheld.
68    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    // Hive tasks reach the organization's linked Hive only; the
137    // connection, its lease and the opt-in decide what goes out (C0 1.9 §4).
138    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    /// With the Hive release switch off, no role holds the Hive capability.
216    #[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    /// ctx_tasks works out of the box for the default role (and any role
228    /// that allows it), never for the read-only ones — once the Hive
229    /// switch is on (here: the test preview).
230    #[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        // Hive only: no role but admin reaches other hosts.
244        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}