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    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}