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