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_share is agent-to-agent handover WITHIN one project: the store is
49        // keyed per project root and push is jailed to it (enterprise#28), so it
50        // is not a cross-project data flow. AgentManage keeps it (like
51        // ctx_handoff) out of read-only roles (reviewer/ci/minimal).
52        "ctx_handoff" | "ctx_share" => &[Capability::KnowledgeRead, Capability::AgentManage],
53        "ctx_agent" | "ctx_task" => &[Capability::AgentManage],
54        "ctx_session" | "ctx" => &[],
55        _ => &[Capability::FsRead],
56    }
57}
58
59pub fn role_capabilities(role_name: &str) -> HashSet<Capability> {
60    match role_name {
61        "admin" => HashSet::from([
62            Capability::FsRead,
63            Capability::FsWrite,
64            Capability::FsDelete,
65            Capability::NetOutbound,
66            Capability::ExecSandbox,
67            Capability::ExecUnrestricted,
68            Capability::KnowledgeRead,
69            Capability::KnowledgeWrite,
70            Capability::CrossProject,
71            Capability::ConfigWrite,
72            Capability::AgentManage,
73        ]),
74        "reviewer" | "ci" => HashSet::from([
75            Capability::FsRead,
76            Capability::ExecSandbox,
77            Capability::KnowledgeRead,
78        ]),
79        "minimal" => HashSet::from([Capability::FsRead, Capability::KnowledgeRead]),
80        _ => capabilities_from_role(role_name),
81    }
82}
83
84fn capabilities_from_role(role_name: &str) -> HashSet<Capability> {
85    let Some(role) = crate::core::roles::load_role(role_name) else {
86        return HashSet::from([
87            Capability::FsRead,
88            Capability::FsWrite,
89            Capability::ExecSandbox,
90            Capability::ExecUnrestricted,
91            Capability::KnowledgeRead,
92            Capability::KnowledgeWrite,
93            Capability::AgentManage,
94        ]);
95    };
96
97    let mut caps = HashSet::new();
98    caps.insert(Capability::FsRead);
99
100    let has_tool = |name: &str| {
101        role.tools.allowed.iter().any(|a| a == "*" || a == name)
102            && !role.tools.denied.iter().any(|d| d == name || d == "*")
103    };
104
105    if has_tool("ctx_edit") {
106        caps.insert(Capability::FsWrite);
107    }
108    if has_tool("ctx_shell") {
109        caps.insert(Capability::ExecSandbox);
110        caps.insert(Capability::ExecUnrestricted);
111    }
112    if has_tool("ctx_knowledge") {
113        caps.insert(Capability::KnowledgeRead);
114        caps.insert(Capability::KnowledgeWrite);
115    }
116    if has_tool("ctx_agent") || has_tool("ctx_task") || has_tool("ctx_handoff") {
117        caps.insert(Capability::AgentManage);
118    }
119    if role.io.allow_cross_project_search {
120        caps.insert(Capability::CrossProject);
121    }
122    if role.io.allow_secret_paths {
123        caps.insert(Capability::ConfigWrite);
124    }
125
126    caps
127}
128
129pub fn check_capabilities(role_name: &str, tool_name: &str) -> CapabilityCheckResult {
130    let required = required_capabilities(tool_name);
131    if required.is_empty() {
132        return CapabilityCheckResult {
133            allowed: true,
134            missing: Vec::new(),
135        };
136    }
137    let granted = role_capabilities(role_name);
138    let missing: Vec<Capability> = required
139        .iter()
140        .filter(|c| !granted.contains(c))
141        .copied()
142        .collect();
143    CapabilityCheckResult {
144        allowed: missing.is_empty(),
145        missing,
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[test]
154    fn admin_has_all_capabilities() {
155        let caps = role_capabilities("admin");
156        assert!(caps.contains(&Capability::FsRead));
157        assert!(caps.contains(&Capability::FsWrite));
158        assert!(caps.contains(&Capability::FsDelete));
159        assert!(caps.contains(&Capability::NetOutbound));
160        assert!(caps.contains(&Capability::ExecUnrestricted));
161        assert!(caps.contains(&Capability::ConfigWrite));
162        assert!(caps.contains(&Capability::AgentManage));
163    }
164
165    #[test]
166    fn reviewer_cannot_write() {
167        let result = check_capabilities("reviewer", "ctx_edit");
168        assert!(!result.allowed);
169        assert!(result.missing.contains(&Capability::FsWrite));
170    }
171
172    #[test]
173    fn minimal_cannot_shell() {
174        let result = check_capabilities("minimal", "ctx_shell");
175        assert!(!result.allowed);
176        assert!(result.missing.contains(&Capability::ExecUnrestricted));
177    }
178
179    #[test]
180    fn session_always_allowed() {
181        let result = check_capabilities("minimal", "ctx_session");
182        assert!(result.allowed);
183        assert!(result.missing.is_empty());
184    }
185
186    #[test]
187    fn developer_can_edit() {
188        let result = check_capabilities("developer", "ctx_edit");
189        assert!(result.allowed);
190    }
191
192    #[test]
193    fn unknown_role_gets_defaults() {
194        let result = check_capabilities("unknown_role", "ctx_read");
195        assert!(result.allowed);
196    }
197
198    #[test]
199    fn unknown_tool_requires_fs_read() {
200        let required = required_capabilities("some_unknown_tool");
201        assert_eq!(required, &[Capability::FsRead]);
202    }
203
204    #[test]
205    fn display_names_are_colon_separated() {
206        assert_eq!(Capability::FsRead.display_name(), "fs:read");
207        assert_eq!(
208            Capability::ExecUnrestricted.display_name(),
209            "exec:unrestricted"
210        );
211        assert_eq!(Capability::AgentManage.display_name(), "agent:manage");
212    }
213}