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, shell policy, and resource limits.
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    #[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 = effectively never block (would need >255% budget usage)
101    // LeanCTX philosophy: always help, never block. Warnings are enough.
102    // Users can explicitly set block_at_percent: 100 in role config if they want blocking.
103    255
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct IoPolicy {
108    /// Boundary enforcement mode for sensitive I/O (warn|enforce).
109    #[serde(default = "default_boundary_mode")]
110    pub boundary_mode: String,
111    /// Allow search to ignore .gitignore and scan everything.
112    #[serde(default)]
113    pub allow_ignore_gitignore: bool,
114    /// Allow reading/indexing secret-like paths (e.g. .env, *.pem).
115    #[serde(default)]
116    pub allow_secret_paths: bool,
117    /// Enable output redaction for tool outputs (admin can disable; non-admin always on).
118    #[serde(default = "default_redact_outputs")]
119    pub redact_outputs: bool,
120    /// Allow cross-project knowledge search (default: false for non-admin roles).
121    #[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(); // empty = all allowed
174        }
175        self.tools.allowed.iter().cloned().collect()
176    }
177}
178
179// ── Built-in roles ──────────────────────────────────────────────
180
181fn 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() // block_at_percent: 255 (never block)
330        },
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
344// ── Disk loading ────────────────────────────────────────────────
345
346fn 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
480// ── Public API ──────────────────────────────────────────────────
481
482pub 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
509/// Roles that grant elevated privileges and cannot be activated via MCP tool calls.
510/// These roles must be set via env var (`LEAN_CTX_ROLE`) or config file.
511const PRIVILEGED_ROLES: &[&str] = &["admin", "ops"];
512
513/// Reserved role names that cannot be shadowed from project-level config.
514const RESERVED_ROLE_NAMES: &[&str] = &["coder", "reviewer", "debugger", "ops", "admin"];
515
516/// Returns true if the named role has elevated privileges that require
517/// explicit configuration (env/config) rather than runtime activation.
518/// Case-insensitive comparison to prevent "Admin" bypass.
519pub fn is_privileged_role(name: &str) -> bool {
520    let lower = name.to_ascii_lowercase();
521    PRIVILEGED_ROLES.iter().any(|p| *p == lower)
522}
523
524/// Validate role name: alphanumeric, underscore, hyphen only. No path traversal.
525fn 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
533/// Check if a merged role is effectively privileged (wildcard tools + no denials, or secret paths).
534fn 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
544/// Set active role. `from_config` = true allows privileged roles (env/config startup).
545pub 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// ── Tests ───────────────────────────────────────────────────────
658
659#[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        // 255 = never block (LeanCTX philosophy: always help, never block)
797        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    // --- Phase 2 V2: Role name validation ---
851
852    #[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}