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