Skip to main content

lean_ctx/core/shell_allowlist/
mode.rs

1//! Shell-security mode — the master switch for ctx_shell / `lean-ctx -c` command
2//! gating (GL #788).
3//!
4//! Three levels, applied at the single chokepoint
5//! `check_shell_allowlist` so MCP and CLI behave
6//! identically:
7//!
8//! - `enforce` (**default**, secure-by-default): the allowlist and the
9//!   unconditional/dangerous-pattern blocks are enforced — today's behaviour.
10//! - `warn`: the same checks run but a violation is only logged (tracing),
11//!   never blocked.
12//! - `off`: command gating is skipped entirely (allowlist, dangerous patterns,
13//!   `eval`/`exec`/interpreter `-c`). A deliberate, documented opt-out for power
14//!   users who accept the risk — **compression stays fully active**.
15//!
16//! Resolution precedence (first hit wins):
17//! 1. `LEAN_CTX_SHELL_SECURITY` env (`enforce` | `warn` | `off`) — test and
18//!    debug builds only; release builds ignore it
19//! 2. `shell_security` in the real user's `config.toml` (`config::shell_policy`)
20//! 3. default → [`ShellSecurity::Enforce`]
21//!
22//! The default is `enforce` on purpose: lean-ctx mediates the agent's shell, so
23//! defaulting to anything weaker would silently downgrade security for every
24//! existing install on upgrade. The redirect/“read-only output” doctrine in
25//! `validate_command` is a separate concern (MCP payload safety) and is NOT
26//! governed by this switch.
27
28/// Active shell-security posture. Order is least → most permissive only for
29/// readability; do not rely on ordinal values.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum ShellSecurity {
32    /// Block disallowed commands (today's behaviour). Secure-by-default.
33    #[default]
34    Enforce,
35    /// Run every check but only log violations — never block.
36    Warn,
37    /// Skip command gating entirely. Compression is unaffected.
38    Off,
39}
40
41impl ShellSecurity {
42    /// Parse a config/env value leniently. Returns `None` for unknown text so
43    /// the caller can fall back to the secure default instead of failing.
44    pub fn parse(value: &str) -> Option<Self> {
45        match value.trim().to_ascii_lowercase().as_str() {
46            "enforce" | "block" | "strict" | "on" => Some(Self::Enforce),
47            "warn" | "warn-only" | "warn_only" => Some(Self::Warn),
48            "off" | "disabled" | "none" | "yolo" => Some(Self::Off),
49            _ => None,
50        }
51    }
52
53    /// Canonical lower-case name (matches the config value).
54    pub fn as_str(self) -> &'static str {
55        match self {
56            Self::Enforce => "enforce",
57            Self::Warn => "warn",
58            Self::Off => "off",
59        }
60    }
61
62    /// Resolve the active mode: env override → config → secure default.
63    pub fn resolve() -> Self {
64        // Release builds read neither the env var nor a relocated config:
65        // see `config::shell_policy`.
66        use crate::core::config::shell_policy;
67        if let Some(raw) = shell_policy::env("LEAN_CTX_SHELL_SECURITY")
68            && let Some(mode) = Self::parse(&raw)
69        {
70            return mode;
71        }
72        shell_policy::config()
73            .shell_security
74            .as_deref()
75            .and_then(Self::parse)
76            .unwrap_or_default()
77    }
78}
79
80impl std::fmt::Display for ShellSecurity {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.write_str(self.as_str())
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn parse_accepts_canonical_and_aliases() {
92        assert_eq!(
93            ShellSecurity::parse("enforce"),
94            Some(ShellSecurity::Enforce)
95        );
96        assert_eq!(ShellSecurity::parse("ON"), Some(ShellSecurity::Enforce));
97        assert_eq!(ShellSecurity::parse(" Warn "), Some(ShellSecurity::Warn));
98        assert_eq!(ShellSecurity::parse("off"), Some(ShellSecurity::Off));
99        assert_eq!(ShellSecurity::parse("yolo"), Some(ShellSecurity::Off));
100    }
101
102    #[test]
103    fn parse_rejects_unknown_so_caller_can_default() {
104        assert_eq!(ShellSecurity::parse("loose"), None);
105        assert_eq!(ShellSecurity::parse(""), None);
106    }
107
108    #[test]
109    fn default_is_enforce() {
110        assert_eq!(ShellSecurity::default(), ShellSecurity::Enforce);
111    }
112
113    #[test]
114    fn as_str_roundtrips_through_parse() {
115        for mode in [
116            ShellSecurity::Enforce,
117            ShellSecurity::Warn,
118            ShellSecurity::Off,
119        ] {
120            assert_eq!(ShellSecurity::parse(mode.as_str()), Some(mode));
121        }
122    }
123
124    #[test]
125    fn env_override_takes_precedence_over_config() {
126        // Serialize env access through the shared test lock so this never races
127        // other env-reading tests (the qubo_select lesson).
128        let _lock = crate::core::data_dir::test_env_lock();
129        crate::test_env::set_var("LEAN_CTX_SHELL_SECURITY", "off");
130        assert_eq!(ShellSecurity::resolve(), ShellSecurity::Off);
131        crate::test_env::set_var("LEAN_CTX_SHELL_SECURITY", "garbage");
132        // Unknown env value → fall through (config/default), never panics.
133        let resolved = ShellSecurity::resolve();
134        assert!(matches!(
135            resolved,
136            ShellSecurity::Enforce | ShellSecurity::Warn | ShellSecurity::Off
137        ));
138        crate::test_env::remove_var("LEAN_CTX_SHELL_SECURITY");
139    }
140}