Skip to main content

lean_ctx/core/
roles.rs

1//! Role-based access control for agent governance.
2//!
3//! Roles define what tools an agent can use, shell policy, and resource limits.
4//! Resolution order: Env (`LEAN_CTX_ROLE`) -> Project `.lean-ctx/roles/` -> Global `~/.lean-ctx/roles/` -> Built-in.
5
6use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8use std::env;
9use std::path::{Path, PathBuf};
10use std::sync::OnceLock;
11
12static ACTIVE_ROLE_NAME: OnceLock<std::sync::Mutex<String>> = OnceLock::new();
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Role {
16    #[serde(default)]
17    pub role: RoleMeta,
18    #[serde(default)]
19    pub tools: ToolPolicy,
20    #[serde(default)]
21    pub limits: RoleLimits,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct RoleMeta {
26    #[serde(default)]
27    pub name: String,
28    #[serde(default)]
29    pub inherits: Option<String>,
30    #[serde(default)]
31    pub description: String,
32    #[serde(default = "default_shell_policy")]
33    pub shell_policy: String,
34}
35
36impl Default for RoleMeta {
37    fn default() -> Self {
38        Self {
39            name: String::new(),
40            inherits: None,
41            description: String::new(),
42            shell_policy: default_shell_policy(),
43        }
44    }
45}
46
47fn default_shell_policy() -> String {
48    "track".to_string()
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, Default)]
52pub struct ToolPolicy {
53    #[serde(default)]
54    pub allowed: Vec<String>,
55    #[serde(default)]
56    pub denied: Vec<String>,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct RoleLimits {
61    #[serde(default = "default_context_tokens")]
62    pub max_context_tokens: usize,
63    #[serde(default = "default_shell_invocations")]
64    pub max_shell_invocations: usize,
65    #[serde(default = "default_cost_usd")]
66    pub max_cost_usd: f64,
67    #[serde(default = "default_warn_pct")]
68    pub warn_at_percent: u8,
69    #[serde(default = "default_block_pct")]
70    pub block_at_percent: u8,
71}
72
73impl Default for RoleLimits {
74    fn default() -> Self {
75        Self {
76            max_context_tokens: default_context_tokens(),
77            max_shell_invocations: default_shell_invocations(),
78            max_cost_usd: default_cost_usd(),
79            warn_at_percent: default_warn_pct(),
80            block_at_percent: default_block_pct(),
81        }
82    }
83}
84
85fn default_context_tokens() -> usize {
86    200_000
87}
88fn default_shell_invocations() -> usize {
89    100
90}
91fn default_cost_usd() -> f64 {
92    5.0
93}
94fn default_warn_pct() -> u8 {
95    80
96}
97fn default_block_pct() -> u8 {
98    100
99}
100
101impl Role {
102    pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
103        if !self.tools.denied.is_empty()
104            && self.tools.denied.iter().any(|d| d == tool_name || d == "*")
105        {
106            return false;
107        }
108        if self.tools.allowed.is_empty() || self.tools.allowed.iter().any(|a| a == "*") {
109            return true;
110        }
111        self.tools.allowed.iter().any(|a| a == tool_name)
112    }
113
114    pub fn is_shell_allowed(&self) -> bool {
115        self.role.shell_policy != "deny"
116    }
117
118    pub fn allowed_tools_set(&self) -> HashSet<String> {
119        if self.tools.allowed.is_empty() || self.tools.allowed.iter().any(|a| a == "*") {
120            return HashSet::new(); // empty = all allowed
121        }
122        self.tools.allowed.iter().cloned().collect()
123    }
124}
125
126// ── Built-in roles ──────────────────────────────────────────────
127
128fn builtin_coder() -> Role {
129    Role {
130        role: RoleMeta {
131            name: "coder".into(),
132            description: "Full access for code implementation".into(),
133            shell_policy: "track".into(),
134            inherits: None,
135        },
136        tools: ToolPolicy {
137            allowed: vec!["*".into()],
138            denied: vec![],
139        },
140        limits: RoleLimits {
141            max_context_tokens: 200_000,
142            max_shell_invocations: 100,
143            max_cost_usd: 5.0,
144            ..Default::default()
145        },
146    }
147}
148
149fn builtin_reviewer() -> Role {
150    Role {
151        role: RoleMeta {
152            name: "reviewer".into(),
153            description: "Read-only access for code review".into(),
154            shell_policy: "track".into(),
155            inherits: None,
156        },
157        tools: ToolPolicy {
158            allowed: vec![
159                "ctx_read".into(),
160                "ctx_multi_read".into(),
161                "ctx_smart_read".into(),
162                "ctx_fill".into(),
163                "ctx_search".into(),
164                "ctx_tree".into(),
165                "ctx_graph".into(),
166                "ctx_architecture".into(),
167                "ctx_analyze".into(),
168                "ctx_diff".into(),
169                "ctx_symbol".into(),
170                "ctx_expand".into(),
171                "ctx_deps".into(),
172                "ctx_review".into(),
173                "ctx_session".into(),
174                "ctx_knowledge".into(),
175                "ctx_semantic_search".into(),
176                "ctx_overview".into(),
177                "ctx_preload".into(),
178                "ctx_metrics".into(),
179                "ctx_cost".into(),
180                "ctx_gain".into(),
181            ],
182            denied: vec!["ctx_edit".into(), "ctx_shell".into(), "ctx_execute".into()],
183        },
184        limits: RoleLimits {
185            max_context_tokens: 150_000,
186            max_shell_invocations: 0,
187            max_cost_usd: 3.0,
188            ..Default::default()
189        },
190    }
191}
192
193fn builtin_debugger() -> Role {
194    Role {
195        role: RoleMeta {
196            name: "debugger".into(),
197            description: "Debug-focused with shell access".into(),
198            shell_policy: "track".into(),
199            inherits: None,
200        },
201        tools: ToolPolicy {
202            allowed: vec!["*".into()],
203            denied: vec![],
204        },
205        limits: RoleLimits {
206            max_context_tokens: 150_000,
207            max_shell_invocations: 200,
208            max_cost_usd: 5.0,
209            ..Default::default()
210        },
211    }
212}
213
214fn builtin_ops() -> Role {
215    Role {
216        role: RoleMeta {
217            name: "ops".into(),
218            description: "Infrastructure and CI/CD operations".into(),
219            shell_policy: "compress".into(),
220            inherits: None,
221        },
222        tools: ToolPolicy {
223            allowed: vec![
224                "ctx_read".into(),
225                "ctx_shell".into(),
226                "ctx_search".into(),
227                "ctx_tree".into(),
228                "ctx_session".into(),
229                "ctx_knowledge".into(),
230                "ctx_overview".into(),
231                "ctx_metrics".into(),
232                "ctx_cost".into(),
233            ],
234            denied: vec!["ctx_edit".into()],
235        },
236        limits: RoleLimits {
237            max_context_tokens: 100_000,
238            max_shell_invocations: 300,
239            max_cost_usd: 3.0,
240            ..Default::default()
241        },
242    }
243}
244
245fn builtin_admin() -> Role {
246    Role {
247        role: RoleMeta {
248            name: "admin".into(),
249            description: "Unrestricted access, all tools and unlimited budgets".into(),
250            shell_policy: "track".into(),
251            inherits: None,
252        },
253        tools: ToolPolicy {
254            allowed: vec!["*".into()],
255            denied: vec![],
256        },
257        limits: RoleLimits {
258            max_context_tokens: 500_000,
259            max_shell_invocations: 500,
260            max_cost_usd: 50.0,
261            warn_at_percent: 90,
262            block_at_percent: 100,
263        },
264    }
265}
266
267fn builtin_roles() -> HashMap<String, Role> {
268    let mut m = HashMap::new();
269    m.insert("coder".into(), builtin_coder());
270    m.insert("reviewer".into(), builtin_reviewer());
271    m.insert("debugger".into(), builtin_debugger());
272    m.insert("ops".into(), builtin_ops());
273    m.insert("admin".into(), builtin_admin());
274    m
275}
276
277// ── Disk loading ────────────────────────────────────────────────
278
279fn roles_dir_global() -> Option<PathBuf> {
280    crate::core::data_dir::lean_ctx_data_dir()
281        .ok()
282        .map(|d| d.join("roles"))
283}
284
285fn roles_dir_project() -> Option<PathBuf> {
286    let mut dir = std::env::current_dir().ok()?;
287    loop {
288        let candidate = dir.join(".lean-ctx").join("roles");
289        if candidate.is_dir() {
290            return Some(candidate);
291        }
292        if !dir.pop() {
293            break;
294        }
295    }
296    None
297}
298
299fn try_load_toml(path: &Path) -> Option<Role> {
300    let content = std::fs::read_to_string(path).ok()?;
301    toml::from_str(&content).ok()
302}
303
304fn load_role_from_disk(name: &str) -> Option<Role> {
305    let filename = format!("{name}.toml");
306    if let Some(dir) = roles_dir_project() {
307        let path = dir.join(&filename);
308        if let Some(mut r) = try_load_toml(&path) {
309            r.role.name = name.to_string();
310            return Some(r);
311        }
312    }
313    if let Some(dir) = roles_dir_global() {
314        let path = dir.join(&filename);
315        if let Some(mut r) = try_load_toml(&path) {
316            r.role.name = name.to_string();
317            return Some(r);
318        }
319    }
320    None
321}
322
323fn merge_roles(parent: &Role, child: &Role) -> Role {
324    Role {
325        role: RoleMeta {
326            name: child.role.name.clone(),
327            inherits: child.role.inherits.clone(),
328            description: if child.role.description.is_empty() {
329                parent.role.description.clone()
330            } else {
331                child.role.description.clone()
332            },
333            shell_policy: if child.role.shell_policy == default_shell_policy()
334                && parent.role.shell_policy != default_shell_policy()
335            {
336                parent.role.shell_policy.clone()
337            } else {
338                child.role.shell_policy.clone()
339            },
340        },
341        tools: if child.tools.allowed.is_empty() && child.tools.denied.is_empty() {
342            parent.tools.clone()
343        } else {
344            child.tools.clone()
345        },
346        limits: RoleLimits {
347            max_context_tokens: child.limits.max_context_tokens,
348            max_shell_invocations: child.limits.max_shell_invocations,
349            max_cost_usd: child.limits.max_cost_usd,
350            warn_at_percent: child.limits.warn_at_percent,
351            block_at_percent: child.limits.block_at_percent,
352        },
353    }
354}
355
356fn load_role_recursive(name: &str, depth: usize) -> Option<Role> {
357    if depth > 5 {
358        return None;
359    }
360    let role = load_role_from_disk(name).or_else(|| builtin_roles().remove(name))?;
361    if let Some(parent_name) = &role.role.inherits {
362        if let Some(parent) = load_role_recursive(parent_name, depth + 1) {
363            return Some(merge_roles(&parent, &role));
364        }
365    }
366    Some(role)
367}
368
369// ── Public API ──────────────────────────────────────────────────
370
371pub fn load_role(name: &str) -> Option<Role> {
372    load_role_recursive(name, 0)
373}
374
375pub fn active_role_name() -> String {
376    let lock = ACTIVE_ROLE_NAME.get_or_init(|| std::sync::Mutex::new(String::new()));
377    let mut guard = lock
378        .lock()
379        .unwrap_or_else(std::sync::PoisonError::into_inner);
380
381    if guard.is_empty() {
382        let from_env = env::var("LEAN_CTX_ROLE")
383            .ok()
384            .map(|s| s.trim().to_string())
385            .filter(|s| !s.is_empty());
386        *guard = from_env.unwrap_or_else(|| "coder".to_string());
387    }
388
389    guard.clone()
390}
391
392pub fn active_role() -> Role {
393    let name = active_role_name();
394    load_role(&name).unwrap_or_else(builtin_coder)
395}
396
397pub fn set_active_role(name: &str) -> Result<Role, String> {
398    let role = load_role(name).ok_or_else(|| format!("Role '{name}' not found"))?;
399    let prev = active_role_name();
400    let lock = ACTIVE_ROLE_NAME.get_or_init(|| std::sync::Mutex::new("coder".to_string()));
401    if let Ok(mut g) = lock.lock() {
402        *g = name.to_string();
403    }
404    if prev != name {
405        crate::core::events::emit_role_changed(&prev, name);
406    }
407    Ok(role)
408}
409
410pub fn list_roles() -> Vec<RoleInfo> {
411    let active = active_role_name();
412    let builtins = builtin_roles();
413    let mut seen = HashSet::new();
414    let mut result = Vec::new();
415
416    for dir in [roles_dir_project(), roles_dir_global()]
417        .into_iter()
418        .flatten()
419    {
420        if let Ok(entries) = std::fs::read_dir(&dir) {
421            for entry in entries.flatten() {
422                let path = entry.path();
423                if path.extension().is_some_and(|e| e == "toml") {
424                    if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
425                        if seen.insert(stem.to_string()) {
426                            if let Some(r) = load_role(stem) {
427                                result.push(RoleInfo {
428                                    name: stem.to_string(),
429                                    source: if dir == roles_dir_project().unwrap_or_default() {
430                                        RoleSource::Project
431                                    } else {
432                                        RoleSource::Global
433                                    },
434                                    description: r.role.description.clone(),
435                                    is_active: stem == active,
436                                });
437                            }
438                        }
439                    }
440                }
441            }
442        }
443    }
444
445    for (name, r) in &builtins {
446        if seen.insert(name.clone()) {
447            result.push(RoleInfo {
448                name: name.clone(),
449                source: RoleSource::BuiltIn,
450                description: r.role.description.clone(),
451                is_active: name == &active,
452            });
453        }
454    }
455
456    result.sort_by(|a, b| a.name.cmp(&b.name));
457    result
458}
459
460#[derive(Debug, Clone)]
461pub struct RoleInfo {
462    pub name: String,
463    pub source: RoleSource,
464    pub description: String,
465    pub is_active: bool,
466}
467
468#[derive(Debug, Clone, PartialEq)]
469pub enum RoleSource {
470    BuiltIn,
471    Project,
472    Global,
473}
474
475impl std::fmt::Display for RoleSource {
476    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
477        match self {
478            Self::BuiltIn => write!(f, "built-in"),
479            Self::Project => write!(f, "project"),
480            Self::Global => write!(f, "global"),
481        }
482    }
483}
484
485// ── Tests ───────────────────────────────────────────────────────
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn builtin_count() {
493        assert_eq!(builtin_roles().len(), 5);
494    }
495
496    #[test]
497    fn coder_allows_all() {
498        let r = builtin_coder();
499        assert!(r.is_tool_allowed("ctx_read"));
500        assert!(r.is_tool_allowed("ctx_edit"));
501        assert!(r.is_tool_allowed("ctx_shell"));
502        assert!(r.is_tool_allowed("anything"));
503    }
504
505    #[test]
506    fn reviewer_denies_edits() {
507        let r = builtin_reviewer();
508        assert!(r.is_tool_allowed("ctx_read"));
509        assert!(r.is_tool_allowed("ctx_review"));
510        assert!(!r.is_tool_allowed("ctx_edit"));
511        assert!(!r.is_tool_allowed("ctx_shell"));
512        assert!(!r.is_tool_allowed("ctx_execute"));
513    }
514
515    #[test]
516    fn reviewer_no_shell() {
517        let r = builtin_reviewer();
518        assert_eq!(r.limits.max_shell_invocations, 0);
519    }
520
521    #[test]
522    fn ops_denies_edit() {
523        let r = builtin_ops();
524        assert!(!r.is_tool_allowed("ctx_edit"));
525        assert!(r.is_tool_allowed("ctx_shell"));
526        assert!(r.is_shell_allowed());
527    }
528
529    #[test]
530    fn admin_unlimited() {
531        let r = builtin_admin();
532        assert!(r.is_tool_allowed("ctx_edit"));
533        assert!(r.is_tool_allowed("ctx_shell"));
534        assert_eq!(r.limits.max_context_tokens, 500_000);
535        assert_eq!(r.limits.max_cost_usd, 50.0);
536    }
537
538    #[test]
539    fn denied_overrides_allowed() {
540        let r = Role {
541            role: RoleMeta {
542                name: "test".into(),
543                ..Default::default()
544            },
545            tools: ToolPolicy {
546                allowed: vec!["*".into()],
547                denied: vec!["ctx_edit".into()],
548            },
549            limits: RoleLimits::default(),
550        };
551        assert!(r.is_tool_allowed("ctx_read"));
552        assert!(!r.is_tool_allowed("ctx_edit"));
553    }
554
555    #[test]
556    fn shell_deny_policy() {
557        let r = Role {
558            role: RoleMeta {
559                name: "noshell".into(),
560                shell_policy: "deny".into(),
561                ..Default::default()
562            },
563            tools: ToolPolicy::default(),
564            limits: RoleLimits::default(),
565        };
566        assert!(!r.is_shell_allowed());
567    }
568
569    #[test]
570    fn load_builtin_by_name() {
571        assert!(load_role("coder").is_some());
572        assert!(load_role("reviewer").is_some());
573        assert!(load_role("debugger").is_some());
574        assert!(load_role("ops").is_some());
575        assert!(load_role("admin").is_some());
576        assert!(load_role("nonexistent").is_none());
577    }
578
579    #[test]
580    fn merge_inherits_parent_tools() {
581        let parent = builtin_reviewer();
582        let child = Role {
583            role: RoleMeta {
584                name: "custom".into(),
585                inherits: Some("reviewer".into()),
586                description: "Custom reviewer".into(),
587                ..Default::default()
588            },
589            tools: ToolPolicy::default(),
590            limits: RoleLimits {
591                max_context_tokens: 50_000,
592                ..Default::default()
593            },
594        };
595        let merged = merge_roles(&parent, &child);
596        assert_eq!(merged.role.name, "custom");
597        assert_eq!(merged.role.description, "Custom reviewer");
598        assert!(!merged.is_tool_allowed("ctx_edit"));
599        assert_eq!(merged.limits.max_context_tokens, 50_000);
600    }
601
602    #[test]
603    fn default_role_is_coder() {
604        let name = active_role_name();
605        assert!(!name.is_empty());
606    }
607
608    #[test]
609    fn list_roles_includes_builtins() {
610        let roles = list_roles();
611        let names: Vec<_> = roles.iter().map(|r| r.name.as_str()).collect();
612        assert!(names.contains(&"coder"));
613        assert!(names.contains(&"reviewer"));
614        assert!(names.contains(&"admin"));
615    }
616
617    #[test]
618    fn warn_and_block_thresholds() {
619        let r = builtin_coder();
620        assert_eq!(r.limits.warn_at_percent, 80);
621        assert_eq!(r.limits.block_at_percent, 100);
622    }
623}