Skip to main content

lean_ctx/core/
roles.rs

1//! Role-based access control for agent governance.
2//!
3//! Roles define what tools an agent can use, its shell policy, and I/O boundaries.
4//! Resolution order: Env (`LEAN_CTX_ROLE`) -> Project `.lean-ctx/roles/` -> Global `~/.lean-ctx/roles/` -> Built-in.
5
6use 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    /// Boundary enforcement mode for sensitive I/O (warn|enforce).
62    #[serde(default = "default_boundary_mode")]
63    pub boundary_mode: String,
64    /// Allow search to ignore .gitignore and scan everything.
65    #[serde(default)]
66    pub allow_ignore_gitignore: bool,
67    /// Allow reading/indexing secret-like paths (e.g. .env, *.pem).
68    #[serde(default)]
69    pub allow_secret_paths: bool,
70    /// Enable output redaction for tool outputs (admin can disable; non-admin always on).
71    #[serde(default = "default_redact_outputs")]
72    pub redact_outputs: bool,
73    /// Allow cross-project knowledge search (default: false for non-admin roles).
74    #[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(); // empty = all allowed
127        }
128        self.tools.allowed.iter().cloned().collect()
129    }
130}
131
132// ── Built-in roles ──────────────────────────────────────────────
133
134fn 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
150/// A role used when an inherited source view has closed or lost its authority.
151/// Returning a denying role prevents stale worker state from falling through
152/// to a more permissive process-wide active role.
153pub(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
275// ── Disk loading ────────────────────────────────────────────────
276
277fn 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        // An explicit unbound scope must not fall back to the process CWD.
290        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
412// ── Public API ──────────────────────────────────────────────────
413
414pub 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/// Held by every test that changes the process-wide role with
464/// [`set_active_role`]: serializes them and restores the previous role on
465/// drop, so a switch to `reviewer` or `admin` neither races another such test
466/// nor leaks into unrelated tests. Tests that only need a role for their own
467/// thread use [`with_test_active_role`] instead.
468#[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
496/// Roles that grant elevated privileges and cannot be activated via MCP tool calls.
497/// These roles must be set via env var (`LEAN_CTX_ROLE`) or config file.
498const PRIVILEGED_ROLES: &[&str] = &["admin", "ops"];
499
500/// Reserved role names that cannot be shadowed from project-level config.
501const RESERVED_ROLE_NAMES: &[&str] = &["coder", "reviewer", "debugger", "ops", "admin"];
502
503/// Returns true if the named role has elevated privileges that require
504/// explicit configuration (env/config) rather than runtime activation.
505/// Case-insensitive comparison to prevent "Admin" bypass.
506pub fn is_privileged_role(name: &str) -> bool {
507    let lower = name.to_ascii_lowercase();
508    PRIVILEGED_ROLES.iter().any(|p| *p == lower)
509}
510
511/// Validate role name: alphanumeric, underscore, hyphen only. No path traversal.
512fn 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
520/// Check if a merged role is effectively privileged (wildcard tools + no denials, or secret paths).
521fn 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
531/// Set active role. `from_config` = true allows privileged roles (env/config startup).
532pub 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// ── Tests ───────────────────────────────────────────────────────
643
644#[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    // --- Phase 2 V2: Role name validation ---
826
827    #[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}