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 "enforce".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 roles_dir_project_from(None)
354}
355
356fn roles_dir_project_from(project_root: Option<&str>) -> Option<PathBuf> {
357 if let Some(root) = project_root {
358 let candidate = PathBuf::from(root).join(".lean-ctx").join("roles");
359 if candidate.is_dir() {
360 return Some(candidate);
361 }
362 }
363 let mut dir = std::env::current_dir().ok()?;
364 loop {
365 let candidate = dir.join(".lean-ctx").join("roles");
366 if candidate.is_dir() {
367 return Some(candidate);
368 }
369 if !dir.pop() {
370 break;
371 }
372 }
373 None
374}
375
376fn try_load_toml(path: &Path) -> Option<Role> {
377 let content = std::fs::read_to_string(path).ok()?;
378 toml::from_str(&content).ok()
379}
380
381fn load_role_from_disk(name: &str) -> Option<Role> {
382 if !is_valid_role_name(name) {
383 tracing::warn!(
384 "[SECURITY] Invalid role name rejected (path-traversal/special chars): {name}"
385 );
386 return None;
387 }
388
389 let filename = format!("{name}.toml");
390 if let Some(dir) = roles_dir_project() {
391 let path = dir.join(&filename);
392 if path.exists() {
393 if RESERVED_ROLE_NAMES
394 .iter()
395 .any(|r| r.eq_ignore_ascii_case(name))
396 {
397 tracing::warn!(
398 "[SECURITY] Project-level shadowing of reserved role '{name}' ignored. \
399 Use global ~/.lean-ctx/roles/ to customize built-in roles."
400 );
401 } else if let Some(mut r) = try_load_toml(&path) {
402 r.role.name = name.to_string();
403 return Some(r);
404 }
405 }
406 }
407 if let Some(dir) = roles_dir_global() {
408 let path = dir.join(&filename);
409 if let Some(mut r) = try_load_toml(&path) {
410 r.role.name = name.to_string();
411 return Some(r);
412 }
413 }
414 None
415}
416
417fn merge_roles(parent: &Role, child: &Role) -> Role {
418 Role {
419 role: RoleMeta {
420 name: child.role.name.clone(),
421 inherits: child.role.inherits.clone(),
422 description: if child.role.description.is_empty() {
423 parent.role.description.clone()
424 } else {
425 child.role.description.clone()
426 },
427 shell_policy: if child.role.shell_policy == default_shell_policy()
428 && parent.role.shell_policy != default_shell_policy()
429 {
430 parent.role.shell_policy.clone()
431 } else {
432 child.role.shell_policy.clone()
433 },
434 },
435 tools: if child.tools.allowed.is_empty() && child.tools.denied.is_empty() {
436 parent.tools.clone()
437 } else {
438 child.tools.clone()
439 },
440 io: if child.io.is_default() && !parent.io.is_default() {
441 parent.io.clone()
442 } else {
443 child.io.clone()
444 },
445 limits: RoleLimits {
446 max_context_tokens: child.limits.max_context_tokens,
447 max_shell_invocations: child.limits.max_shell_invocations,
448 max_cost_usd: child.limits.max_cost_usd,
449 warn_at_percent: child.limits.warn_at_percent,
450 block_at_percent: child.limits.block_at_percent,
451 },
452 }
453}
454
455fn load_role_recursive(name: &str, visited: &mut HashSet<String>) -> Option<Role> {
456 if !visited.insert(name.to_string()) {
457 tracing::warn!("[SECURITY] Circular role inheritance detected at '{name}'");
458 return None;
459 }
460 let role = load_role_from_disk(name).or_else(|| builtin_roles().remove(name))?;
461 if let Some(parent_name) = &role.role.inherits {
462 if is_privileged_role(parent_name) && roles_dir_project().is_some() {
463 let is_from_project =
464 roles_dir_project().is_some_and(|d| d.join(format!("{name}.toml")).exists());
465 if is_from_project {
466 tracing::warn!(
467 "[SECURITY] Project-level role '{name}' inheriting from privileged \
468 role '{parent_name}' is blocked."
469 );
470 return None;
471 }
472 }
473 if let Some(parent) = load_role_recursive(parent_name, visited) {
474 return Some(merge_roles(&parent, &role));
475 }
476 }
477 Some(role)
478}
479
480pub fn load_role(name: &str) -> Option<Role> {
483 let mut visited = HashSet::new();
484 load_role_recursive(name, &mut visited)
485}
486
487pub fn active_role_name() -> String {
488 let lock = ACTIVE_ROLE_NAME.get_or_init(|| std::sync::Mutex::new(String::new()));
489 let mut guard = lock
490 .lock()
491 .unwrap_or_else(std::sync::PoisonError::into_inner);
492
493 if guard.is_empty() {
494 let from_env = env::var("LEAN_CTX_ROLE")
495 .ok()
496 .map(|s| s.trim().to_string())
497 .filter(|s| !s.is_empty());
498 *guard = from_env.unwrap_or_else(|| "coder".to_string());
499 }
500
501 guard.clone()
502}
503
504pub fn active_role() -> Role {
505 let name = active_role_name();
506 load_role(&name).unwrap_or_else(builtin_coder)
507}
508
509const PRIVILEGED_ROLES: &[&str] = &["admin", "ops"];
512
513const RESERVED_ROLE_NAMES: &[&str] = &["coder", "reviewer", "debugger", "ops", "admin"];
515
516pub fn is_privileged_role(name: &str) -> bool {
520 let lower = name.to_ascii_lowercase();
521 PRIVILEGED_ROLES.iter().any(|p| *p == lower)
522}
523
524fn is_valid_role_name(name: &str) -> bool {
526 !name.is_empty()
527 && name.len() <= 64
528 && name
529 .chars()
530 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
531}
532
533fn is_effectively_privileged(role: &Role) -> bool {
535 let wildcard_tools =
536 role.tools.allowed.iter().any(|a| a == "*") && role.tools.denied.is_empty();
537 wildcard_tools || role.io.allow_secret_paths
538}
539
540pub fn set_active_role(name: &str) -> Result<Role, String> {
541 set_active_role_with_source(name, false)
542}
543
544pub fn set_active_role_with_source(name: &str, from_config: bool) -> Result<Role, String> {
546 if !is_valid_role_name(name) {
547 return Err(format!(
548 "[SECURITY] Invalid role name '{name}'. Only alphanumeric, underscore, and hyphen allowed."
549 ));
550 }
551
552 let role = load_role(name).ok_or_else(|| format!("Role '{name}' not found"))?;
553
554 if !from_config && is_privileged_role(name) {
555 return Err(format!(
556 "[SECURITY] Cannot escalate to privileged role '{name}' at runtime. \
557 Set LEAN_CTX_ROLE={name} in your environment or config to use this role."
558 ));
559 }
560
561 let is_builtin = builtin_roles().contains_key(name);
562 if !from_config && !is_builtin && is_effectively_privileged(&role) {
563 return Err(format!(
564 "[SECURITY] Cannot activate role '{name}' at runtime: it has effectively \
565 privileged permissions (wildcard tools or secret path access). \
566 Set LEAN_CTX_ROLE={name} in environment or config."
567 ));
568 }
569
570 let prev = active_role_name();
571 let lock = ACTIVE_ROLE_NAME.get_or_init(|| std::sync::Mutex::new("coder".to_string()));
572 match lock.lock() {
573 Ok(mut g) => *g = name.to_string(),
574 Err(poisoned) => *poisoned.into_inner() = name.to_string(),
575 }
576 if prev != name {
577 crate::core::events::emit_role_changed(&prev, name);
578 }
579 Ok(role)
580}
581
582pub fn list_roles() -> Vec<RoleInfo> {
583 let active = active_role_name();
584 let builtins = builtin_roles();
585 let mut seen = HashSet::new();
586 let mut result = Vec::new();
587
588 for dir in [roles_dir_project(), roles_dir_global()]
589 .into_iter()
590 .flatten()
591 {
592 if let Ok(entries) = std::fs::read_dir(&dir) {
593 for entry in entries.flatten() {
594 let path = entry.path();
595 if path.extension().is_some_and(|e| e == "toml") {
596 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
597 if seen.insert(stem.to_string()) {
598 if let Some(r) = load_role(stem) {
599 result.push(RoleInfo {
600 name: stem.to_string(),
601 source: if dir == roles_dir_project().unwrap_or_default() {
602 RoleSource::Project
603 } else {
604 RoleSource::Global
605 },
606 description: r.role.description.clone(),
607 is_active: stem == active,
608 });
609 }
610 }
611 }
612 }
613 }
614 }
615 }
616
617 for (name, r) in &builtins {
618 if seen.insert(name.clone()) {
619 result.push(RoleInfo {
620 name: name.clone(),
621 source: RoleSource::BuiltIn,
622 description: r.role.description.clone(),
623 is_active: name == &active,
624 });
625 }
626 }
627
628 result.sort_by_key(|r| r.name.clone());
629 result
630}
631
632#[derive(Debug, Clone)]
633pub struct RoleInfo {
634 pub name: String,
635 pub source: RoleSource,
636 pub description: String,
637 pub is_active: bool,
638}
639
640#[derive(Debug, Clone, PartialEq)]
641pub enum RoleSource {
642 BuiltIn,
643 Project,
644 Global,
645}
646
647impl std::fmt::Display for RoleSource {
648 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
649 match self {
650 Self::BuiltIn => write!(f, "built-in"),
651 Self::Project => write!(f, "project"),
652 Self::Global => write!(f, "global"),
653 }
654 }
655}
656
657#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn builtin_count() {
665 assert_eq!(builtin_roles().len(), 5);
666 }
667
668 #[test]
669 fn coder_allows_all() {
670 let r = builtin_coder();
671 assert!(r.is_tool_allowed("ctx_read"));
672 assert!(r.is_tool_allowed("ctx_edit"));
673 assert!(r.is_tool_allowed("ctx_shell"));
674 assert!(r.is_tool_allowed("anything"));
675 }
676
677 #[test]
678 fn reviewer_denies_edits() {
679 let r = builtin_reviewer();
680 assert!(r.is_tool_allowed("ctx_read"));
681 assert!(r.is_tool_allowed("ctx_review"));
682 assert!(!r.is_tool_allowed("ctx_edit"));
683 assert!(!r.is_tool_allowed("ctx_shell"));
684 assert!(!r.is_tool_allowed("ctx_execute"));
685 }
686
687 #[test]
688 fn reviewer_no_shell() {
689 let r = builtin_reviewer();
690 assert_eq!(r.limits.max_shell_invocations, 0);
691 }
692
693 #[test]
694 fn ops_denies_edit() {
695 let r = builtin_ops();
696 assert!(!r.is_tool_allowed("ctx_edit"));
697 assert!(r.is_tool_allowed("ctx_shell"));
698 assert!(r.is_shell_allowed());
699 }
700
701 #[test]
702 fn admin_unlimited() {
703 let r = builtin_admin();
704 assert!(r.is_tool_allowed("ctx_edit"));
705 assert!(r.is_tool_allowed("ctx_shell"));
706 assert_eq!(r.limits.max_context_tokens, 500_000);
707 assert_eq!(r.limits.max_cost_usd, 50.0);
708 }
709
710 #[test]
711 fn denied_overrides_allowed() {
712 let r = Role {
713 role: RoleMeta {
714 name: "test".into(),
715 ..Default::default()
716 },
717 tools: ToolPolicy {
718 allowed: vec!["*".into()],
719 denied: vec!["ctx_edit".into()],
720 },
721 io: IoPolicy::default(),
722 limits: RoleLimits::default(),
723 };
724 assert!(r.is_tool_allowed("ctx_read"));
725 assert!(!r.is_tool_allowed("ctx_edit"));
726 }
727
728 #[test]
729 fn shell_deny_policy() {
730 let r = Role {
731 role: RoleMeta {
732 name: "noshell".into(),
733 shell_policy: "deny".into(),
734 ..Default::default()
735 },
736 tools: ToolPolicy::default(),
737 io: IoPolicy::default(),
738 limits: RoleLimits::default(),
739 };
740 assert!(!r.is_shell_allowed());
741 }
742
743 #[test]
744 fn load_builtin_by_name() {
745 assert!(load_role("coder").is_some());
746 assert!(load_role("reviewer").is_some());
747 assert!(load_role("debugger").is_some());
748 assert!(load_role("ops").is_some());
749 assert!(load_role("admin").is_some());
750 assert!(load_role("nonexistent").is_none());
751 }
752
753 #[test]
754 fn merge_inherits_parent_tools() {
755 let parent = builtin_reviewer();
756 let child = Role {
757 role: RoleMeta {
758 name: "custom".into(),
759 inherits: Some("reviewer".into()),
760 description: "Custom reviewer".into(),
761 ..Default::default()
762 },
763 tools: ToolPolicy::default(),
764 io: IoPolicy::default(),
765 limits: RoleLimits {
766 max_context_tokens: 50_000,
767 ..Default::default()
768 },
769 };
770 let merged = merge_roles(&parent, &child);
771 assert_eq!(merged.role.name, "custom");
772 assert_eq!(merged.role.description, "Custom reviewer");
773 assert!(!merged.is_tool_allowed("ctx_edit"));
774 assert_eq!(merged.limits.max_context_tokens, 50_000);
775 }
776
777 #[test]
778 fn default_role_is_coder() {
779 let name = active_role_name();
780 assert!(!name.is_empty());
781 }
782
783 #[test]
784 fn list_roles_includes_builtins() {
785 let roles = list_roles();
786 let names: Vec<_> = roles.iter().map(|r| r.name.as_str()).collect();
787 assert!(names.contains(&"coder"));
788 assert!(names.contains(&"reviewer"));
789 assert!(names.contains(&"admin"));
790 }
791
792 #[test]
793 fn warn_and_block_thresholds() {
794 let r = builtin_coder();
795 assert_eq!(r.limits.warn_at_percent, 80);
796 assert_eq!(r.limits.block_at_percent, 255);
798 }
799
800 #[test]
801 fn runtime_escalation_to_admin_blocked() {
802 let result = set_active_role("admin");
803 assert!(
804 result.is_err(),
805 "runtime escalation to admin must be blocked"
806 );
807 let err = result.unwrap_err();
808 assert!(
809 err.contains("SECURITY"),
810 "error must indicate security: {err}"
811 );
812 }
813
814 #[test]
815 fn runtime_escalation_to_ops_blocked() {
816 let result = set_active_role("ops");
817 assert!(result.is_err(), "runtime escalation to ops must be blocked");
818 }
819
820 #[test]
821 fn config_escalation_to_admin_allowed() {
822 let result = set_active_role_with_source("admin", true);
823 assert!(
824 result.is_ok(),
825 "config-source escalation to admin must work"
826 );
827 }
828
829 #[test]
830 fn runtime_switch_to_coder_allowed() {
831 let result = set_active_role("coder");
832 assert!(result.is_ok(), "switching to coder must always work");
833 }
834
835 #[test]
836 fn runtime_switch_to_reviewer_allowed() {
837 let result = set_active_role("reviewer");
838 assert!(result.is_ok(), "switching to reviewer must always work");
839 }
840
841 #[test]
842 fn privileged_roles_detected() {
843 assert!(is_privileged_role("admin"));
844 assert!(is_privileged_role("ops"));
845 assert!(!is_privileged_role("coder"));
846 assert!(!is_privileged_role("reviewer"));
847 assert!(!is_privileged_role("debugger"));
848 }
849
850 #[test]
853 fn valid_role_names() {
854 assert!(is_valid_role_name("coder"));
855 assert!(is_valid_role_name("my-custom-role"));
856 assert!(is_valid_role_name("Role_123"));
857 assert!(is_valid_role_name("ADMIN"));
858 }
859
860 #[test]
861 fn invalid_role_names() {
862 assert!(!is_valid_role_name(""));
863 assert!(!is_valid_role_name("../../evil"));
864 assert!(!is_valid_role_name("role with spaces"));
865 assert!(!is_valid_role_name("role;drop"));
866 assert!(!is_valid_role_name("a".repeat(65).as_str()));
867 }
868
869 #[test]
870 fn case_insensitive_privileged_check() {
871 assert!(is_privileged_role("Admin"));
872 assert!(is_privileged_role("ADMIN"));
873 assert!(is_privileged_role("Ops"));
874 assert!(is_privileged_role("OPS"));
875 }
876
877 #[test]
878 fn invalid_role_name_rejected_at_set() {
879 let result = set_active_role("../../evil");
880 assert!(result.is_err());
881 assert!(result.unwrap_err().contains("Invalid role name"));
882 }
883
884 #[test]
885 fn effectively_privileged_role_blocked_at_runtime() {
886 let role = Role {
887 role: RoleMeta {
888 name: "sneaky".into(),
889 ..Default::default()
890 },
891 tools: ToolPolicy {
892 allowed: vec!["*".into()],
893 denied: vec![],
894 },
895 io: IoPolicy::default(),
896 limits: RoleLimits::default(),
897 };
898 assert!(
899 is_effectively_privileged(&role),
900 "wildcard + no denials = effectively privileged"
901 );
902 }
903
904 #[test]
905 fn debugger_runtime_switch_allowed() {
906 let result = set_active_role("debugger");
907 assert!(
908 result.is_ok(),
909 "built-in debugger must be activatable at runtime"
910 );
911 }
912}