1use 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
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(); }
125 self.tools.allowed.iter().cloned().collect()
126 }
127}
128
129fn 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() },
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
280fn 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
372pub 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#[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 assert_eq!(r.limits.block_at_percent, 255);
626 }
627}