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}
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 IoPolicy {
61 #[serde(default = "default_boundary_mode")]
63 pub boundary_mode: String,
64 #[serde(default)]
66 pub allow_ignore_gitignore: bool,
67 #[serde(default)]
69 pub allow_secret_paths: bool,
70 #[serde(default = "default_redact_outputs")]
72 pub redact_outputs: bool,
73 #[serde(default)]
75 pub allow_cross_project_search: bool,
76}
77
78fn default_boundary_mode() -> String {
79 "enforce".to_string()
80}
81
82fn default_redact_outputs() -> bool {
83 true
84}
85
86impl Default for IoPolicy {
87 fn default() -> Self {
88 Self {
89 boundary_mode: default_boundary_mode(),
90 allow_ignore_gitignore: false,
91 allow_secret_paths: false,
92 redact_outputs: default_redact_outputs(),
93 allow_cross_project_search: false,
94 }
95 }
96}
97
98impl IoPolicy {
99 fn is_default(&self) -> bool {
100 self.boundary_mode == default_boundary_mode()
101 && !self.allow_ignore_gitignore
102 && !self.allow_secret_paths
103 && self.redact_outputs == default_redact_outputs()
104 }
105}
106
107impl Role {
108 pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
109 if !self.tools.denied.is_empty()
110 && self.tools.denied.iter().any(|d| d == tool_name || d == "*")
111 {
112 return false;
113 }
114 if self.tools.allowed.is_empty() || self.tools.allowed.iter().any(|a| a == "*") {
115 return true;
116 }
117 self.tools.allowed.iter().any(|a| a == tool_name)
118 }
119
120 pub fn is_shell_allowed(&self) -> bool {
121 self.role.shell_policy != "deny"
122 }
123
124 pub fn allowed_tools_set(&self) -> HashSet<String> {
125 if self.tools.allowed.is_empty() || self.tools.allowed.iter().any(|a| a == "*") {
126 return HashSet::new(); }
128 self.tools.allowed.iter().cloned().collect()
129 }
130}
131
132fn builtin_coder() -> Role {
135 Role {
136 role: RoleMeta {
137 name: "coder".into(),
138 description: "Full access for code implementation".into(),
139 shell_policy: "track".into(),
140 inherits: None,
141 },
142 tools: ToolPolicy {
143 allowed: vec!["*".into()],
144 denied: vec![],
145 },
146 io: IoPolicy::default(),
147 }
148}
149
150pub(crate) fn closed_source_view_role() -> Role {
154 Role {
155 role: RoleMeta {
156 name: "closed-source-view".into(),
157 inherits: None,
158 description: "closed source policy view".into(),
159 shell_policy: "deny".into(),
160 },
161 tools: ToolPolicy {
162 allowed: Vec::new(),
163 denied: vec!["*".into()],
164 },
165 io: IoPolicy::default(),
166 }
167}
168
169fn builtin_reviewer() -> Role {
170 Role {
171 role: RoleMeta {
172 name: "reviewer".into(),
173 description: "Read-only access for code review".into(),
174 shell_policy: "track".into(),
175 inherits: None,
176 },
177 tools: ToolPolicy {
178 allowed: vec![
179 "ctx_read".into(),
180 "ctx_search".into(),
181 "ctx_tree".into(),
182 "ctx_graph".into(),
183 "ctx_diff".into(),
184 "ctx_expand".into(),
185 "ctx_deps".into(),
186 "ctx_session".into(),
187 "ctx_knowledge".into(),
188 "ctx_preload".into(),
189 "ctx_metrics".into(),
190 "ctx_cost".into(),
191 "ctx_gain".into(),
192 ],
193 denied: vec!["ctx_edit".into(), "ctx_shell".into(), "ctx_execute".into()],
194 },
195 io: IoPolicy {
196 boundary_mode: "enforce".into(),
197 ..Default::default()
198 },
199 }
200}
201
202fn builtin_debugger() -> Role {
203 Role {
204 role: RoleMeta {
205 name: "debugger".into(),
206 description: "Debug-focused with shell access".into(),
207 shell_policy: "track".into(),
208 inherits: None,
209 },
210 tools: ToolPolicy {
211 allowed: vec!["*".into()],
212 denied: vec![],
213 },
214 io: IoPolicy::default(),
215 }
216}
217
218fn builtin_ops() -> Role {
219 Role {
220 role: RoleMeta {
221 name: "ops".into(),
222 description: "Infrastructure and CI/CD operations".into(),
223 shell_policy: "compress".into(),
224 inherits: None,
225 },
226 tools: ToolPolicy {
227 allowed: vec![
228 "ctx_read".into(),
229 "ctx_shell".into(),
230 "ctx_search".into(),
231 "ctx_tree".into(),
232 "ctx_session".into(),
233 "ctx_knowledge".into(),
234 "ctx_metrics".into(),
235 "ctx_cost".into(),
236 ],
237 denied: vec!["ctx_edit".into()],
238 },
239 io: IoPolicy::default(),
240 }
241}
242
243fn builtin_admin() -> Role {
244 Role {
245 role: RoleMeta {
246 name: "admin".into(),
247 description: "Unrestricted access, all tools and unlimited budgets".into(),
248 shell_policy: "track".into(),
249 inherits: None,
250 },
251 tools: ToolPolicy {
252 allowed: vec!["*".into()],
253 denied: vec![],
254 },
255 io: IoPolicy {
256 boundary_mode: "enforce".into(),
257 allow_ignore_gitignore: true,
258 allow_secret_paths: true,
259 redact_outputs: true,
260 allow_cross_project_search: true,
261 },
262 }
263}
264
265fn builtin_roles() -> HashMap<String, Role> {
266 let mut m = HashMap::new();
267 m.insert("coder".into(), builtin_coder());
268 m.insert("reviewer".into(), builtin_reviewer());
269 m.insert("debugger".into(), builtin_debugger());
270 m.insert("ops".into(), builtin_ops());
271 m.insert("admin".into(), builtin_admin());
272 m
273}
274
275fn roles_dir_global() -> Option<PathBuf> {
278 crate::core::data_dir::lean_ctx_data_dir()
279 .ok()
280 .map(|d| d.join("roles"))
281}
282
283fn roles_dir_project() -> Option<PathBuf> {
284 match crate::core::policy::runtime::REQUEST_PROJECT.try_with(|slot| slot.borrow().clone()) {
285 Ok(Some(project_root)) => {
286 let candidate = project_root.join(".lean-ctx").join("roles");
287 candidate.is_dir().then_some(candidate)
288 }
289 Ok(None) => None,
291 Err(_) => roles_dir_project_from(None),
292 }
293}
294
295fn roles_dir_project_from(project_root: Option<&str>) -> Option<PathBuf> {
296 if let Some(root) = project_root {
297 let candidate = PathBuf::from(root).join(".lean-ctx").join("roles");
298 if candidate.is_dir() {
299 return Some(candidate);
300 }
301 }
302 let mut dir = std::env::current_dir().ok()?;
303 loop {
304 let candidate = dir.join(".lean-ctx").join("roles");
305 if candidate.is_dir() {
306 return Some(candidate);
307 }
308 if !dir.pop() {
309 break;
310 }
311 }
312 None
313}
314
315fn try_load_toml(path: &Path) -> Option<Role> {
316 let content = std::fs::read_to_string(path).ok()?;
317 toml::from_str(&content).ok()
318}
319
320fn load_role_from_disk(name: &str) -> Option<Role> {
321 if !is_valid_role_name(name) {
322 tracing::warn!(
323 "[SECURITY] Invalid role name rejected (path-traversal/special chars): {name}"
324 );
325 return None;
326 }
327
328 let filename = format!("{name}.toml");
329 if let Some(dir) = roles_dir_project() {
330 let path = dir.join(&filename);
331 if path.exists() {
332 if RESERVED_ROLE_NAMES
333 .iter()
334 .any(|r| r.eq_ignore_ascii_case(name))
335 {
336 tracing::warn!(
337 "[SECURITY] Project-level shadowing of reserved role '{name}' ignored. \
338 Use global ~/.lean-ctx/roles/ to customize built-in roles."
339 );
340 } else if let Some(mut r) = try_load_toml(&path) {
341 r.role.name = name.to_string();
342 return Some(r);
343 }
344 }
345 }
346 if let Some(dir) = roles_dir_global() {
347 let path = dir.join(&filename);
348 if let Some(mut r) = try_load_toml(&path) {
349 r.role.name = name.to_string();
350 return Some(r);
351 }
352 }
353 None
354}
355
356fn merge_roles(parent: &Role, child: &Role) -> Role {
357 Role {
358 role: RoleMeta {
359 name: child.role.name.clone(),
360 inherits: child.role.inherits.clone(),
361 description: if child.role.description.is_empty() {
362 parent.role.description.clone()
363 } else {
364 child.role.description.clone()
365 },
366 shell_policy: if child.role.shell_policy == default_shell_policy()
367 && parent.role.shell_policy != default_shell_policy()
368 {
369 parent.role.shell_policy.clone()
370 } else {
371 child.role.shell_policy.clone()
372 },
373 },
374 tools: if child.tools.allowed.is_empty() && child.tools.denied.is_empty() {
375 parent.tools.clone()
376 } else {
377 child.tools.clone()
378 },
379 io: if child.io.is_default() && !parent.io.is_default() {
380 parent.io.clone()
381 } else {
382 child.io.clone()
383 },
384 }
385}
386
387fn load_role_recursive(name: &str, visited: &mut HashSet<String>) -> Option<Role> {
388 if !visited.insert(name.to_string()) {
389 tracing::warn!("[SECURITY] Circular role inheritance detected at '{name}'");
390 return None;
391 }
392 let role = load_role_from_disk(name).or_else(|| builtin_roles().remove(name))?;
393 if let Some(parent_name) = &role.role.inherits {
394 if is_privileged_role(parent_name) && roles_dir_project().is_some() {
395 let is_from_project =
396 roles_dir_project().is_some_and(|d| d.join(format!("{name}.toml")).exists());
397 if is_from_project {
398 tracing::warn!(
399 "[SECURITY] Project-level role '{name}' inheriting from privileged \
400 role '{parent_name}' is blocked."
401 );
402 return None;
403 }
404 }
405 if let Some(parent) = load_role_recursive(parent_name, visited) {
406 return Some(merge_roles(&parent, &role));
407 }
408 }
409 Some(role)
410}
411
412pub fn load_role(name: &str) -> Option<Role> {
415 let mut visited = HashSet::new();
416 load_role_recursive(name, &mut visited)
417}
418
419pub fn active_role_name() -> String {
420 let lock = ACTIVE_ROLE_NAME.get_or_init(|| std::sync::Mutex::new(String::new()));
421 let mut guard = lock
422 .lock()
423 .unwrap_or_else(std::sync::PoisonError::into_inner);
424
425 if guard.is_empty() {
426 let from_env = env::var("LEAN_CTX_ROLE")
427 .ok()
428 .map(|s| s.trim().to_string())
429 .filter(|s| !s.is_empty());
430 *guard = from_env.unwrap_or_else(|| "coder".to_string());
431 }
432
433 guard.clone()
434}
435
436pub fn active_role() -> Role {
437 if let Some(role) = crate::core::policy::runtime::pinned_role() {
438 return role;
439 }
440 #[cfg(any(test, feature = "test-support"))]
441 if let Ok(Some(role)) = TEST_ACTIVE_ROLE.try_with(|slot| slot.borrow().clone()) {
442 return role;
443 }
444 let name = active_role_name();
445 load_role(&name).unwrap_or_else(builtin_coder)
446}
447
448#[cfg(any(test, feature = "test-support"))]
449tokio::task_local! {
450 static TEST_ACTIVE_ROLE: std::cell::RefCell<Option<Role>>;
451}
452
453#[cfg(any(test, feature = "test-support"))]
454pub fn with_test_active_role<T>(role: Role, operation: impl FnOnce() -> T) -> T {
455 TEST_ACTIVE_ROLE.sync_scope(std::cell::RefCell::new(Some(role)), operation)
456}
457
458#[cfg(test)]
459pub(crate) fn set_test_active_role(role: Role) {
460 TEST_ACTIVE_ROLE.with(|slot| *slot.borrow_mut() = Some(role));
461}
462
463#[cfg(any(test, feature = "test-support"))]
469pub struct GlobalRoleGuard {
470 previous: String,
471 _lock: std::sync::MutexGuard<'static, ()>,
472}
473
474#[cfg(any(test, feature = "test-support"))]
475pub fn lock_global_role() -> GlobalRoleGuard {
476 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
477 let lock = LOCK
478 .lock()
479 .unwrap_or_else(std::sync::PoisonError::into_inner);
480 GlobalRoleGuard {
481 previous: active_role_name(),
482 _lock: lock,
483 }
484}
485
486#[cfg(any(test, feature = "test-support"))]
487impl Drop for GlobalRoleGuard {
488 fn drop(&mut self) {
489 let slot = ACTIVE_ROLE_NAME.get_or_init(|| std::sync::Mutex::new(String::new()));
490 slot.lock()
491 .unwrap_or_else(std::sync::PoisonError::into_inner)
492 .clone_from(&self.previous);
493 }
494}
495
496const PRIVILEGED_ROLES: &[&str] = &["admin", "ops"];
499
500const RESERVED_ROLE_NAMES: &[&str] = &["coder", "reviewer", "debugger", "ops", "admin"];
502
503pub fn is_privileged_role(name: &str) -> bool {
507 let lower = name.to_ascii_lowercase();
508 PRIVILEGED_ROLES.iter().any(|p| *p == lower)
509}
510
511fn is_valid_role_name(name: &str) -> bool {
513 !name.is_empty()
514 && name.len() <= 64
515 && name
516 .chars()
517 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
518}
519
520fn is_effectively_privileged(role: &Role) -> bool {
522 let wildcard_tools =
523 role.tools.allowed.iter().any(|a| a == "*") && role.tools.denied.is_empty();
524 wildcard_tools || role.io.allow_secret_paths
525}
526
527pub fn set_active_role(name: &str) -> Result<Role, String> {
528 set_active_role_with_source(name, false)
529}
530
531pub fn set_active_role_with_source(name: &str, from_config: bool) -> Result<Role, String> {
533 if !is_valid_role_name(name) {
534 return Err(format!(
535 "[SECURITY] Invalid role name '{name}'. Only alphanumeric, underscore, and hyphen allowed."
536 ));
537 }
538
539 let role = load_role(name).ok_or_else(|| format!("Role '{name}' not found"))?;
540
541 if !from_config && is_privileged_role(name) {
542 return Err(format!(
543 "[SECURITY] Cannot escalate to privileged role '{name}' at runtime. \
544 Set LEAN_CTX_ROLE={name} in your environment or config to use this role."
545 ));
546 }
547
548 let is_builtin = builtin_roles().contains_key(name);
549 if !from_config && !is_builtin && is_effectively_privileged(&role) {
550 return Err(format!(
551 "[SECURITY] Cannot activate role '{name}' at runtime: it has effectively \
552 privileged permissions (wildcard tools or secret path access). \
553 Set LEAN_CTX_ROLE={name} in environment or config."
554 ));
555 }
556
557 let prev = active_role_name();
558 let lock = ACTIVE_ROLE_NAME.get_or_init(|| std::sync::Mutex::new("coder".to_string()));
559 match lock.lock() {
560 Ok(mut g) => *g = name.to_string(),
561 Err(poisoned) => *poisoned.into_inner() = name.to_string(),
562 }
563 if prev != name {
564 crate::core::events::emit_role_changed(&prev, name);
565 }
566 Ok(role)
567}
568
569pub fn list_roles() -> Vec<RoleInfo> {
570 let active = active_role_name();
571 let builtins = builtin_roles();
572 let mut seen = HashSet::new();
573 let mut result = Vec::new();
574
575 for dir in [roles_dir_project(), roles_dir_global()]
576 .into_iter()
577 .flatten()
578 {
579 if let Ok(entries) = std::fs::read_dir(&dir) {
580 for entry in entries.flatten() {
581 let path = entry.path();
582 if path.extension().is_some_and(|e| e == "toml")
583 && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
584 && seen.insert(stem.to_string())
585 && let Some(r) = load_role(stem)
586 {
587 result.push(RoleInfo {
588 name: stem.to_string(),
589 source: if dir == roles_dir_project().unwrap_or_default() {
590 RoleSource::Project
591 } else {
592 RoleSource::Global
593 },
594 description: r.role.description.clone(),
595 is_active: stem == active,
596 });
597 }
598 }
599 }
600 }
601
602 for (name, r) in &builtins {
603 if seen.insert(name.clone()) {
604 result.push(RoleInfo {
605 name: name.clone(),
606 source: RoleSource::BuiltIn,
607 description: r.role.description.clone(),
608 is_active: name == &active,
609 });
610 }
611 }
612
613 result.sort_by_key(|r| r.name.clone());
614 result
615}
616
617#[derive(Debug, Clone)]
618pub struct RoleInfo {
619 pub name: String,
620 pub source: RoleSource,
621 pub description: String,
622 pub is_active: bool,
623}
624
625#[derive(Debug, Clone, PartialEq)]
626pub enum RoleSource {
627 BuiltIn,
628 Project,
629 Global,
630}
631
632impl std::fmt::Display for RoleSource {
633 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
634 match self {
635 Self::BuiltIn => write!(f, "built-in"),
636 Self::Project => write!(f, "project"),
637 Self::Global => write!(f, "global"),
638 }
639 }
640}
641
642#[cfg(test)]
645pub mod tests {
646 use super::*;
647
648 #[test]
649 fn builtin_count() {
650 assert_eq!(builtin_roles().len(), 5);
651 }
652
653 #[test]
654 fn coder_allows_all() {
655 let r = builtin_coder();
656 assert!(r.is_tool_allowed("ctx_read"));
657 assert!(r.is_tool_allowed("ctx_edit"));
658 assert!(r.is_tool_allowed("ctx_shell"));
659 assert!(r.is_tool_allowed("anything"));
660 }
661
662 #[test]
663 fn reviewer_denies_edits() {
664 let r = builtin_reviewer();
665 assert!(r.is_tool_allowed("ctx_read"));
666 assert!(!r.is_tool_allowed("ctx_edit"));
667 assert!(!r.is_tool_allowed("ctx_shell"));
668 assert!(!r.is_tool_allowed("ctx_execute"));
669 }
670
671 #[test]
672 fn ops_denies_edit() {
673 let r = builtin_ops();
674 assert!(!r.is_tool_allowed("ctx_edit"));
675 assert!(r.is_tool_allowed("ctx_shell"));
676 assert!(r.is_shell_allowed());
677 }
678
679 #[test]
680 fn admin_allows_edit_and_shell() {
681 let r = builtin_admin();
682 assert!(r.is_tool_allowed("ctx_edit"));
683 assert!(r.is_tool_allowed("ctx_shell"));
684 }
685
686 #[test]
687 fn legacy_limits_section_still_parses() {
688 let role: Role = toml::from_str(
689 "[role]\nname = \"legacy\"\n\n[limits]\nmax_context_tokens = 1000\nwarn_at_percent = 50\n",
690 )
691 .expect("retired [limits] section must be ignored");
692 assert_eq!(role.role.name, "legacy");
693 }
694
695 #[test]
696 fn denied_overrides_allowed() {
697 let r = Role {
698 role: RoleMeta {
699 name: "test".into(),
700 ..Default::default()
701 },
702 tools: ToolPolicy {
703 allowed: vec!["*".into()],
704 denied: vec!["ctx_edit".into()],
705 },
706 io: IoPolicy::default(),
707 };
708 assert!(r.is_tool_allowed("ctx_read"));
709 assert!(!r.is_tool_allowed("ctx_edit"));
710 }
711
712 #[test]
713 fn shell_deny_policy() {
714 let r = Role {
715 role: RoleMeta {
716 name: "noshell".into(),
717 shell_policy: "deny".into(),
718 ..Default::default()
719 },
720 tools: ToolPolicy::default(),
721 io: IoPolicy::default(),
722 };
723 assert!(!r.is_shell_allowed());
724 }
725
726 #[test]
727 fn load_builtin_by_name() {
728 assert!(load_role("coder").is_some());
729 assert!(load_role("reviewer").is_some());
730 assert!(load_role("debugger").is_some());
731 assert!(load_role("ops").is_some());
732 assert!(load_role("admin").is_some());
733 assert!(load_role("nonexistent").is_none());
734 }
735
736 #[test]
737 fn merge_inherits_parent_tools() {
738 let parent = builtin_reviewer();
739 let child = Role {
740 role: RoleMeta {
741 name: "custom".into(),
742 inherits: Some("reviewer".into()),
743 description: "Custom reviewer".into(),
744 ..Default::default()
745 },
746 tools: ToolPolicy::default(),
747 io: IoPolicy::default(),
748 };
749 let merged = merge_roles(&parent, &child);
750 assert_eq!(merged.role.name, "custom");
751 assert_eq!(merged.role.description, "Custom reviewer");
752 assert!(!merged.is_tool_allowed("ctx_edit"));
753 }
754
755 #[test]
756 fn default_role_is_coder() {
757 let name = active_role_name();
758 assert!(!name.is_empty(), "`name` is empty");
759 }
760
761 #[test]
762 fn list_roles_includes_builtins() {
763 let roles = list_roles();
764 let names: Vec<_> = roles.iter().map(|r| r.name.as_str()).collect();
765 assert!(names.contains(&"coder"));
766 assert!(names.contains(&"reviewer"));
767 assert!(names.contains(&"admin"));
768 }
769
770 #[test]
771 fn runtime_escalation_to_admin_blocked() {
772 let _role = lock_global_role();
773 let result = set_active_role("admin");
774 assert!(
775 result.is_err(),
776 "runtime escalation to admin must be blocked"
777 );
778 let err = result.unwrap_err();
779 assert!(
780 err.contains("SECURITY"),
781 "error must indicate security: {err}"
782 );
783 }
784
785 #[test]
786 fn runtime_escalation_to_ops_blocked() {
787 let _role = lock_global_role();
788 let result = set_active_role("ops");
789 assert!(result.is_err(), "runtime escalation to ops must be blocked");
790 }
791
792 #[test]
793 fn config_escalation_to_admin_allowed() {
794 let _role = lock_global_role();
795 let result = set_active_role_with_source("admin", true);
796 assert!(
797 result.is_ok(),
798 "config-source escalation to admin must work"
799 );
800 }
801
802 #[test]
803 fn runtime_switch_to_coder_allowed() {
804 let _role = lock_global_role();
805 let result = set_active_role("coder");
806 assert!(result.is_ok(), "switching to coder must always work");
807 }
808
809 #[test]
810 fn runtime_switch_to_reviewer_allowed() {
811 let _role = lock_global_role();
812 let result = set_active_role("reviewer");
813 assert!(result.is_ok(), "switching to reviewer must always work");
814 }
815
816 #[test]
817 fn privileged_roles_detected() {
818 assert!(is_privileged_role("admin"));
819 assert!(is_privileged_role("ops"));
820 assert!(!is_privileged_role("coder"));
821 assert!(!is_privileged_role("reviewer"));
822 assert!(!is_privileged_role("debugger"));
823 }
824
825 #[test]
828 fn valid_role_names() {
829 assert!(is_valid_role_name("coder"));
830 assert!(is_valid_role_name("my-custom-role"));
831 assert!(is_valid_role_name("Role_123"));
832 assert!(is_valid_role_name("ADMIN"));
833 }
834
835 #[test]
836 fn invalid_role_names() {
837 assert!(!is_valid_role_name(""));
838 assert!(!is_valid_role_name("../../evil"));
839 assert!(!is_valid_role_name("role with spaces"));
840 assert!(!is_valid_role_name("role;drop"));
841 assert!(!is_valid_role_name("a".repeat(65).as_str()));
842 }
843
844 #[test]
845 fn case_insensitive_privileged_check() {
846 assert!(is_privileged_role("Admin"));
847 assert!(is_privileged_role("ADMIN"));
848 assert!(is_privileged_role("Ops"));
849 assert!(is_privileged_role("OPS"));
850 }
851
852 #[test]
853 fn invalid_role_name_rejected_at_set() {
854 let _role = lock_global_role();
855 let result = set_active_role("../../evil");
856 assert!(result.is_err());
857 assert!(result.unwrap_err().contains("Invalid role name"));
858 }
859
860 #[test]
861 fn effectively_privileged_role_blocked_at_runtime() {
862 let role = Role {
863 role: RoleMeta {
864 name: "sneaky".into(),
865 ..Default::default()
866 },
867 tools: ToolPolicy {
868 allowed: vec!["*".into()],
869 denied: vec![],
870 },
871 io: IoPolicy::default(),
872 };
873 assert!(
874 is_effectively_privileged(&role),
875 "wildcard + no denials = effectively privileged"
876 );
877 }
878
879 #[test]
880 fn debugger_runtime_switch_allowed() {
881 let _role = lock_global_role();
882 let result = set_active_role("debugger");
883 assert!(
884 result.is_ok(),
885 "built-in debugger must be activatable at runtime"
886 );
887 }
888}