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 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(); }
122 self.tools.allowed.iter().cloned().collect()
123 }
124}
125
126fn 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
277fn 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
369pub 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#[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}